Aller au contenu principal

Réconcilier les lieux de deux systèmes différents

Gaël Haméon
Créateur de Bimo

Le service Bimo findBestMatchForTargetAmongCandidates permet de trouver la meilleure association entre des objets par un système d'itérations successives. Voici quelques exemples d'utilisations de ce service:

  • Trouver la variante (ou le voyage modèle) qui correspond le mieux à un voyage importé d'un système maison pour ensuite enrichir ce voyage avec les infos de la variante (ou du voyage modèle)
  • Réconcilier des listes de lieux sur la base de libellés qui varient légèrement. Par exemple: "Paris Nord" vs "Gare du Nord"
  • Trouver le noeud ou l'arc qui correspond le mieux à un lieu dans un graphe représentant le réseau
  • ...

Chacune de ces utilisations est un peu abstraite et difficile à décrire en une phrase ... n'hésitez pas à laisser un commentaire si vous aimeriez en savoir plus sur l'une d'entre elles.

Dans cet article, nous allons nous intéresser à une utilisation relativement simple de ce service:

  • Trouver le lieu Hastus qui correspond le mieux à un lieu en provenance d'un système maison

Problème

On a:

  • d'une part, un environnement Hastus dans lequel l'ensemble du Réseau Ferré National français est cartographié dans le module Geo, et ou chaque voie à quai d'une gare commerciale est modélisée par un lieu

  • d'autre part, un outil SNCF maison où des informations importantes sur les voies à quai ont été saisies manuellement

On souhaite établir une équivalence entre les référentiels de voies à quai de ces deux systèmes afin de pouvoir importer dans Hastus les informations saisies dans le système maison.

Le service findBestMatchForTargetAmongCandidates est parfait pour répondre à ce type de problème.

Aperçu des données

Données du système maison

Le seul élément "standard" dans ces données est le code UIC8 de la gare. Ensuite, on trouve des id incrémentaux pour chaque voie, mais en y regardant de plus près, on se rend compte qu'une même voie semble pouvoir exister avec plusieurs ID. Dans les exemples de données ci-dessous, les id 1 et 2 semblent tous deux désigner la voie B de Bellegarde, l'une dans le sens Culoz vers Genève et l'inverse pour l'autre.

On trouve par ailleurs deux champs contenant:

  • le libellé "infra" de la voie (celui utilisé par les agents SNCF Réseau)
  • le libellé "commercial" de la voie (celui utilisé par les agents commerciaux et les passagers)

Par exemple, dans les données ci-dessous, on voit qu'à Libourne, la voie qui est connue comme voie B pour les passagers semble être connue comme voie 2 pour les agents SNCF Réseau. Mais on voit également que dans certains cas, le libellé commercial a été répété entre parenthèses dans le champ du libellé infra, et inversement.

En synthèse, ces données ne sont pas particulièrement propres: elles ont été saisies manuellement par des utilisateurs variés dans un système où ils pouvaient saisir ce qu'ils voulaient dans un champ texte libre. Un nettoyage va s'imposer.

Cliquez ici pour un aperçu des données
idVoieuic8libGarelibInfraVoielibCommVoie
187745000Bellegarde (Ain)B (CUZ>GCO)B (CUZ>GCO)
287745000Bellegarde (Ain)B (GCO>CUZ)B (GCO>CUZ)
387745000Bellegarde (Ain)A(CUZ>GCOA (CUZ>GCO)
487745000Bellegarde (Ain)1H1
587745000Bellegarde (Ain)GG
687745000Bellegarde (Ain)E (GCO>CUZ)E (GCO>CUZ)
787745000Bellegarde (Ain)C (CUZ>GCO)
887745000Bellegarde (Ain)C (GCO>CUZ)C (GCO>CUZ)
987745000Bellegarde (Ain)A (GCO>CUZ)
1087745000Bellegarde (Ain)E (CUZ>GCO)E (CUZ>GCO)
1187745000Bellegarde (Ain)2H2
1287584052Libourne2(B)B(2)
1387584052Libourne1C(1)
1487584052Libourne2(B)B (2)
1587584052Libourne4(A)A(4)
1687584052Libourne3D(3)
1787584052Libourne3D(3)
1887584052Libourne3D(3)
1987584052Libourne1C(1)
2087584052Libourne1C(1)
2187584052Libourne4A
2287584052Libourne2B
2387584052Libourne2B
2487741439Lépin-le-LacDB (Direct)
2587741439Lépin-le-LacEA (Evi)

Données de l'environnement Hastus

La plupart des attributs qui nous intéressent ici sont en réalité spécifiques à la version SNCF d'Hastus. Les attributs Code Réseau, Code Immuable et Code Chantier ont été créés pour stocker les codes UIC de la gare à laquelle correspond chaque lieu. Le code UIC8 disponible dans les données du système maison correspond donc aux deux derniers caractères du code réseau, accolés au code immuable.

Les attributs Id Voie Agathe et Libellé Voie Agathe donnent des indications sur le nom des voies, mais encore une fois, ne sont pas standardisés.

Cliquez ici pour un aperçu des données
plcIdentifierplcDescriptionplcCodeReseauplcCodeImmuableplcCodeChantierplcIdVoieAgatheplcLibelleVoieAgathe
LI1Libourne/BV - V10087584052BV1V1
LI2Libourne/BV - V20087584052BV2V2
LI3Libourne/BV - 30087584052BV33
LI4Libourne/BV - 40087584052BV44
LI5Libourne/BV - 50087584052BV55
LPLdLépin-le-Lac-La Bauche/00 - UNIQUE008774143900UNIQUED
LPLeLépin-le-Lac-La Bauche/00 - E008774143900EVITE
BGD1Bellegarde (Ain)/BV - V10087745000BVV1V1
BGD1hBellegarde (Ain)/BV - V1H0087745000BVV1HV1H
BGD2hBellegarde (Ain)/BV - V2H0087745000BVV2HV2H
BGD5Bellegarde (Ain)/BV - V50087745000BVV5V5
BGDaBellegarde (Ain)/BV - A0087745000BVAA
BGDbBellegarde (Ain)/BV - B0087745000BVBB
BGDe-Bellegarde (Ain)/BV - VE-0087745000BVVE-E
BGDfiBellegarde (Ain)/BV - FI0087745000BVFIFI
BGDgBellegarde (Ain)/BV - V20087745000BVV2VG

Principe du traitement

Étant donné qu'une même voie peut exister sous plusieurs ID dans le système maison, mais pas dans Hastus, on va itérer sur les voies du système maison, et tenter de trouver pour chacune la meilleure voie Hastus possible. Plusieurs voies du système maison pourront donc être associées à la même voie Hastus. Mais certaines voies Hastus pourraient n'être associées à aucune voie du système maison. Dans notre cas, les cibles sont donc les voies du système maison et les candidats sont les lieux Hastus.

remarque

findBestMatchForTargetAmongCandidates, comme son nom l'indique, trouve une correspondance pour une cible parmi plusieurs candidats et n'est pas approprié pour les cas où on souhaite des correspondances strictes un pour un entre deux listes. Il faut alors utiliser d'autres services, comme matchTwoListsOfString, ou utiliser findBestMatchForTargetAmongCandidates mais ajouter de la logique autour pour retirer les candidats de la liste à mesure qu'ils sont associés aux cibles.

Pour une cible donnée, la liste de candidats sera constituée des lieux Hastus qui ont le même code UIC8 que la cible. Dans cette liste de candidat, on va alors essayer d'en trouver un qui a le même nom de voie que la cible. Mais comme on l'a vu précédemment, le nom de voie n'est pas standard, et dans beaucoup de cas, les noms de voies devront être nettoyés si on veut qu'ils correspondent entre eux.

C'est ici que le mécanisme itératif de findBestMatchForTargetAmongCandidates sera utile.

Concepts de findBestMatchForTargetAmongCandidates

Ci-dessous, un résumé du README de findBestMatchForTargetAmongCandidates (auquel vous devriez vous référer pour plus de détails) avec la traduction des concepts pour notre cas précis.

Principe de fonctionnement

On essaie en plusieurs itérations de trouver la meilleure correspondance pour une cible parmi plusieurs candidats.

À chaque itération, on passe par deux phases:

  1. Réduire la liste de candiats
  2. Calculer une distance entre la cible et chaque candidat, et déclarer un bestMatch si les critères sont atteints

Ces principes donnent beaucoup de flexibilité et permettent d'ajuster rapidement et facilement les critères afin d'optimiser la performance et la qualité des correspondances. Par exemple:

  • on défini une première itération très stricte, qui cible une liste réduite de candidats, avec un calcul de distance simple pour abattre rapidement une grosse partie du travail
  • dans les itérations suivantes, on peut se permettre de cibler plus de candidats et d'avoir des critères plus complexes, car on sait qu'elle ne seront exécutées que sur les quelques cibles restantes
  • si ça n'a toujours pas suffi, on peut faire des tentatives carrément loufoques dans les dernières itérations, quitte à traiter avec un grain de sel les résultats obtenus dans celles-ci

target et candidates

La target ou cible est l'objet pour lequel on recherche une correspondance parmi plusieurs candidates ou candidats.

Dans notre cas, la cible sera une voie du système maison et les candidats seront les lieux Hastus.

Phase 1 : Réduire la liste de candidats

Les options pour cette phase sont définies sous la clé candidatesFilteringConfig de la configuration globale. Si cette clé n'est pas définie, tous les candidats sont utilisés. Si la clef est définie, les candidats sont filtrés dans deux sous-phases.

Phase 1A: filterPredicate

Cette phase est utile pour éliminer des candidats sur la base de critères fixes, qui ne varient pas en fonction de la cible.

Elle n'est pas utilisée dans notre cas car nos filtres fixes sont les mêmes pour toutes les itérations, donc nous les appliquons une fois pour toute dans le script avant même de rentrer dans findBestMatchForTargetAmongCandidates

Phase 1B: regrouper les candidats et conserver le groupe qui correspond à une clef calculée sur la cible

Cette phase permet de ne conserver que les candidats qui ont une caractéristique commune avec la cible.

D'abord, on regroupe tous les candidats sur la base de cette caractéristique. Ensuite, on calcule (si nécessaire) la valeur de cette caractéristique pour la cible, et on ne conserve que les candidats qui ont la même valeur que la cible.

Dans notre cas, la caractéristique commune qui nous intéresse est le code UIC8. On va regrouper les lieux Hastus par code UIC8 et ne considérer que ceux qui ont le même que la voie maison.

Phase 2 : trouver le meilleur candidat parmi ceux qui restent

Les options pour cette phase sont définies sous la clé getBestMatchConfig de la configuration globale. Plusieurs options de configuration existent, mais les deux plus importantes sont distanceFunction et maxDistance

distanceFunction est une fonction qui doit calculer une distance entre la cible et le candidat. Pour un candidat qui correspond parfaitement à la cible, distanceFunction devrait renvoyer 0. Moins le candidat correspond à la cible, plus la valeur renvoyée par distanceFunction devrait être grande.

Si aucun des candidats n'a une valeur de 0, alors c'est le candidat qui a la plus petite valeur qui est considéré comme la meilleure correspondance, à condition que cette valeur soit inférieure à la valeur définie dans maxDistance

Dans notre cas, on ne va pas utiliser ce mécanisme à son plein potentiel. Notre distanceFunction va simplement comparer les libellés de voie, après les avoir plus ou moins nettoyés. Si elle trouve une correspondance exacte entre libellés de voies nettoyés, elle va renvoyer 0. Sinon, elle va renvoyer +infini, et il faudra passer au candidat suivant, puis à l'itération suivante le cas échéant.

Étapes de nettoyage

À chaque itération, on fait les 4 comparaisons possibles:

  • Libellé Infra vs Id Voie
  • Libellé Commercial vs Id voie
  • Libellé Infra vs Libellé Voie
  • Libellé Commercial vs Libellé Voie

On considère qu'un lieu Hastus correspond à la voie maison dès que les valeurs sont les mêmes pour une des comparaison.

À chaque fois, on applique des "nettoyages" aux valeurs de la cible et des candidats avant de les comparer.

À chaque itération, on ajoute des "nettoyages" en plus de ceux faits aux itérations précédentes. Par exemple:

  1. mettre tout en minuscules
  2. retirer les "V" ou "v" au début des libellés
  3. retirer tout texte entre parenthèses
  4. transformer UNIQUE en U, BIS en B, TER en T
  5. retirer les espaces au milieu des libellés

Mise en oeuvre

Ci-dessous, un aperçu commenté de la fonction qui utilise findBestMatchForTargetAmongCandidates dans le script.

getBestMatchResultByExternalTrack.js
/** Les utilitaires ci-dessous seront brièvement décrits quand ils seront
* utilisés dans le code */
const findBestMatch = require('@bimo/core-utils-find-best-match-for-target-among-candidates');
const {
cleanStringUsingRegexAndReplacePairs,
} = require('@bimo/core-utils-string');
const matchTwoListsOfStrings = require('@bimo/core-utils-match-two-lists-of-strings');
const { countBy } = require('lodash');

/** Cette fonction est appellée par le script, qui s'est chargé par ailleurs
* - de charger les voies maisons et d'en faire un array d'objets
* (externalTracks)
* - de charger et pré-filtrer le jeu de lieux Hastus (placesCollection)
*/
function getBestMatchResultByExternalTrack(
externalTracks,
placesCollection,
logger
) {
/** Dans notre cas, on va filtrer les lieux de la même manière pour toutes les
* itérations. On définit donc une seule fois candidatesFilteringConfig
* Mais gardez en tête qu'on peut parfaitement le faire varier à chaque
* itération quand c'est utile.
*/
const candidatesFilteringConfig = {
/** La target est la voie maison, qui possède directement une clé 'uic8' */
getKeyFromTargetConfig: 'uic8',
/** Il n'y a pas directement de clé équivalent sur les lieux Hastus,
* on la fabrique à la volée avec la fonction ci-dessous */
groupCandidatesByConfig: (place) =>
`${place.plcCodeReseau.substr(-2)}${place.plcCodeImmuable}`,
};

/** La "distanceFunction" varie à chaque itération, mais elle a toujours la
* même forme. On va donc créer une "distanceFunctionFactory": une fonction
* qui retourne une fonction.
* À chaque itération, on va appeler la factory en lui passant des
* regexAndReplacePairs plus ou moins complexes pour transformer les strings.
* On obtiendra alors une "distanceFunction" classique qui transforme les
* strings, puis les compare, et retourne 0 s'il y a un match, +infini sinon.
* */
const distanceFunctionFactory =
(regexAndReplacePairs) => (externalTrackPlace, hastusPlace) => {
/** Pour simplifier les manipulations, on regroupe les strings qui nous intéressent */
const rawExternalStrings = [
externalTrackPlace.libInfraVoie,
externalTrackPlace.libCommVoie,
];
const rawHastusStrings = [
hastusPlace.plcIdVoieAgathe,
hastusPlace.plcLibelleVoieAgathe,
];

/** cleanStringUsingRegexAndReplacePairs applique successivement des
* expressions régulières à une string pour la nettoyer */
const cleanExternalStrings = rawExternalStrings.map((s) =>
cleanStringUsingRegexAndReplacePairs(
s,
regexAndReplacePairs
).toLowerCase()
);
const cleanHastusStrings = rawHastusStrings.map((s) =>
cleanStringUsingRegexAndReplacePairs(
s,
regexAndReplacePairs
).toLowerCase()
);

/** En l'utilisant avec l'option { returnOnFirstMatch: true }
* matchTwoListsOfStrings permet de savoir rapidement s'il y a une
* correspondance exacte entre n'importe quelles 2 strings de deux
* listes */
const { matched } = matchTwoListsOfStrings(
[cleanExternalStrings, cleanHastusStrings],
{ returnOnFirstMatch: true }
);

return matched.length === 0 ? Number.POSITIVE_INFINITY : 0;
};

/**On stockera les résultats dans cette variable */
const bestMatchResultByExternalTrack = new Map();

/** Les regexAndReplacePairs ci-dessous vont permettre de nettoyer les
* strings. Si besoin de plus de détails sur le fonctionnement des
* regex, n'hésitez pas à laisser un commentaire sous cet article */
const removeVRegexAndReplacePair = ['/^[vV](.+)$/', '$1'];
const removeParenthesisRegexAndReplacePair = [
'/(\\w+).*(\\(.+\\))/gim',
'$1',
];
const transformUniqueIntoU = ['/^UNIQUE$/i', 'U'];
const transformBisIntoB = ['/BIS/i', 'B'];
const transformTerIntoT = ['/TER/i', 'T'];
const removeSpaces = ['/ /g', ''];

externalTracks.forEach((externalTrack) => {
/** Noter que findBestMatch... accepte les candidates sous la forme d'un
* array ou d'une collection. La collection est préférable car permet de
* profiter du cache pour améliorer la performance */
const bestMatchResult = findBestMatch(
{ target: externalTrack, candidates: placesCollection },
{
iterationConfigs: [
{
iterationName: '1 = tout mettre en minuscule',
candidatesFilteringConfig /** Voir ligne 25 */,
getBestMatchConfig: {
detailedResults: true,
distanceFunction: distanceFunctionFactory([
/**Aucun nettoyage particulier pour cette itération */
]),
maxDistance: 1,
},
},
{
iterationName:
'2 = 1 + retrait des "V" ou "v" au début des noms de voies',
candidatesFilteringConfig,
getBestMatchConfig: {
detailedResults: true,
distanceFunction: distanceFunctionFactory([
removeVRegexAndReplacePair,
]),
maxDistance: 1,
},
},
{
iterationName: '3 = 2 + retrait du texte entre parenthèses',
candidatesFilteringConfig,
getBestMatchConfig: {
detailedResults: true,
distanceFunction: distanceFunctionFactory([
removeVRegexAndReplacePair,
removeParenthesisRegexAndReplacePair,
]),
maxDistance: 1,
},
},
{
iterationName: '4 = 3 + transfo UNIQUE > U, BIS > B, TER > T',
candidatesFilteringConfig,
getBestMatchConfig: {
detailedResults: true,
distanceFunction: distanceFunctionFactory([
removeVRegexAndReplacePair,
removeParenthesisRegexAndReplacePair,
transformUniqueIntoU,
transformBisIntoB,
transformTerIntoT,
]),
maxDistance: 1,
},
},
{
iterationName: '5 = 4 + retrait des espaces',
candidatesFilteringConfig,
getBestMatchConfig: {
detailedResults: true,
distanceFunction: distanceFunctionFactory([
removeVRegexAndReplacePair,
removeParenthesisRegexAndReplacePair,
transformUniqueIntoU,
transformBisIntoB,
transformTerIntoT,
removeSpaces,
]),
maxDistance: 1,
},
},
],
}
);
bestMatchResultByExternalTrack.set(externalTrack, bestMatchResult);
});

/** on laisse ce petit exemple de rapport minimaliste pour montrer que les
* résultats incluent des infos sur l'itération qui a produit le match */
const numberOfMatchesByIteration = countBy(
Array.from(bestMatchResultByExternalTrack.values()),
'indexOfIterationThatMatched'
);
logger.info(JSON.stringify(numberOfMatchesByIteration));

return bestMatchResultByExternalTrack;
}

module.exports = getBestMatchResultByExternalTrack;

Conclusion

Même si cet exemple ne tire pas profit de toute la puissance de findBestMatchForTargetAmongCandidates il illustre bien l'expressivité rendue possible grâce au système des itérations.

En quelques dizaines de lignes de code, on met en place une structure qui permet ensuite de s'amuser facilement avec les données, en ajoutant simplement de nouvelles itérations, mais en conservant beaucoup de puissance et de flexibilité.