Profil et extraction de voyage

ReCHor – étape 6

1. Introduction

Le but de cette sixième étape — la dernière de la première partie du projet — est d'une part de terminer la rédaction des classes représentant l'horaire aplati, et d'autre part d'écrire celles permettant de représenter ce que nous appellerons un profil, et d'en extraire des voyages.

Notez que cette étape devra être rendue deux fois :

  1. pour le rendu testé habituel (délai : le 28/3 à 18h00),
  2. pour le rendu intermédiaire (délai : le 4/4 à 18h00).

Le deuxième de ces rendus sera corrigé par lecture du code de vos étapes 1 à 6, et il vous faudra donc soigner sa qualité et sa documentation. Il est fortement conseillé de lire notre guide à ce sujet.

2. Concepts

2.1. « Mappage » de fichiers en mémoire

Les deux étapes précédentes ont été consacrées à la rédaction de classes permettant de manipuler les différents éléments de l'horaire aplati — gares, voies/quais, liaisons, etc. — se trouvant dans des tableaux d'octets. Il reste à savoir comment obtenir ces tableaux d'octets.

Comme expliqué à la §3, nous mettons à votre disposition un certain nombre de fichiers dont le contenu correspond exactement à celui des tableaux d'octets attendus. Par exemple, un des fichiers que nous vous fournissons, nommé stations.bin, contient la totalité des gares aplaties au format décrit à la §2.3 de l'étape 4.

Il serait donc techniquement possible de charger le contenu de ce fichier et de le placer dans un tableau d'octets — de type ByteBuffer — passé ensuite à la classe BufferedStations. Cette solution aurait toutefois l'inconvénient de ralentir le démarrage du programme, la quantité de données à charger étant relativement grande.

Heureusement, une autre solution existe, le « mappage » de fichier en mémoire (memory mapping). Cet anglicisme désigne la possibilité de faire apparaître le contenu d'un fichier en mémoire, comme s'il avait effectivement été chargé, mais sans qu'il ne le soit réellement. Au lieu de cela, le contenu du fichier n'est chargé que lorsqu'on accède aux données, et seules les parties effectivement nécessaires de ce contenu sont chargées.

Cette possibilité est intéressante, car elle nous permet d'écrire le programme comme si la totalité des données de l'horaire était chargée en mémoire au démarrage du programme, alors qu'en réalité seules les parties effectivement utilisées le sont, au moment de leur première utilisation.

2.2. Profil

Comme nous le verrons à l'étape suivante, l'algorithme de recherche de voyage optimaux produit en résultat une table donnant, pour chaque gare du réseau, la frontière de Pareto des critères d'optimisation — heure de départ et d'arrivée, nombre de changements — des voyages permettant d'atteindre la destination choisie, le jour choisi. Nous nommerons cette table un profil (profile).

Par exemple, si on utilise l'algorithme de recherche pour trouver les voyages optimaux permettant de se rendre à Gruyères le 18 février 2025, il produit un profil associant une frontière de Pareto à chacune des plus de 30 000 gares existantes. Parmi ces frontières figure celle correspondant à la gare Ecublens VD, EPFL et contenant, entre autres, les tuples suivant :

\[ \ldots, (\textrm{15h55}, \textrm{17h57}, 3), (\textrm{16h13}, \textrm{17h57}, 4), (\textrm{16h38}, \textrm{18h21}, 3), (\textrm{16h38}, \textrm{18h28}, 2), \ldots \]

qui correspondent aux quatre premiers voyages visibles dans la copie d'écran de l'interface graphique finale du programme ci-dessous, déjà présentée dans l'introduction au projet.

rechor-gui.png
Figure 1 : Voyages de Ecublens VD, EPFL à Gruyères

Il est important de comprendre qu'un profil contient la frontière de Pareto de chacune des gares pour une destination et un jour de voyage donnés, et n'est donc pas lié à une gare de départ particulière.

2.3. Profil augmenté

Un profil contient, pour chaque gare, la frontière de Pareto de tous les voyages optimaux permettant de se rendre de cette gare-là à la gare de destination, à la date de voyage donnée. Il permet donc à un voyageur se trouvant dans une gare quelconque du réseau ce jour-là de déterminer quand il peut arriver à la destination, et au prix de combien de changements.

Lors du calcul des voyages optimaux, il se trouve qu'il est utile d'avoir cette information-là non seulement pour toutes les gares du réseau, mais aussi pour toutes les courses circulant ce jour-là. En d'autres termes, d'offrir un profil permettant également à un voyageur se trouvant à bord d'un véhicule effectuant une course donnée de déterminer quand il peut arriver à destination, et au prix de combien de changements. Contrairement aux frontières de Pareto associées aux gares, celles associées aux courses ne comportent que deux critères d'optimisation (l'heure d'arrivée et le nombre de changements), car l'heure de départ d'une course ne peut pas être choisie.

Nous appellerons profil augmenté un profil possédant non seulement les frontières de Pareto correspondant aux gares, mais aussi celles correspondant aux courses.

2.4. Extraction de voyages

À eux seuls, les tuples associés à une gare dans un profil ne contiennent pas assez d'information pour permettre à un voyageur de déterminer comment se rendre à destination. Par exemple, le tuple \((\textrm{15h55}, \textrm{17h57}, 3)\) de la frontière de Pareto donnée en exemple plus haut permet uniquement de savoir qu'il est possible de partir de l'EPFL à 15h55 pour arriver à Gruyères à 17h57 après avoir effectué 3 changements, mais sans savoir comment. Ainsi, si deux métros partent de l'EPFL à 15h55, l'un en direction de Renens, l'autre en direction de Lausanne, il est impossible de savoir lequel prendre sans autre information que le tuple.

Dès lors, pour pouvoir extraire d'un profil la totalité des voyages optimaux pour un arrêt de départ donné, des informations supplémentaires sont nécessaires. Ces informations seront calculées par l'algorithme de recherche des voyages optimaux, et stockées dans ce que nous avons appelé la charge utile (payload) associée aux tuples.

Pour les tuples des frontières de Pareto des gares, contenant trois critères (heure de départ et d'arrivée, nombre de changements), la charge utile contient deux informations :

  • l'identité de la première liaison à emprunter pour débuter le voyage,
  • le nombre d'arrêts intermédiaires à laisser passer avant de descendre du véhicule.

Ces deux informations sont empaquetées dans les 32 bits de la charge utile, les 24 bits de poids fort contenant l'identité de la liaison, les 8 bits de poids faible contenant le nombre d'arrêts intermédiaires.

Par exemple, au tuple \((\textrm{15h55}, \textrm{17h57}, 3)\) mentionné plus haut pourrait être associée une charge utile contenant :

  • l'identité de la liaison partant à 15h55 de Ecublens VD, EPFL pour arriver à 15h56 à Ecublens VD, Bassenges,
  • un nombre d'arrêts intermédiaires valant 4.

Grâce à cette information, on peut déterminer que la première étape du voyage part à 15h55 de Ecublens VD, EPFL pour arriver à 16h01 à Renens VD, gare, simplement en consultant les liaisons qui suivent la première dans la course à laquelle elle appartient.

Une fois la première étape déterminée, on peut consulter la frontière de Pareto de sa gare d'arrivée (Renens VD, gare) pour déterminer comment continuer le voyage. En effet, on sait que le tuple qui nous intéresse est celui permettant d'arriver à destination à 17h57 en effectuant encore 2 changements supplémentaires — étant donné que le premier des 3 changements totaux vient d'être fait.

En consultant la frontière de Pareto de Renens VD, gare, on y trouve le tuple \((\textrm{16h08}, \textrm{17h57}, 2)\) dont la charge utile contient :

  • l'identité de la liaison partant à 16h08 de Renens VD pour arriver à 16h14 à Lausanne,
  • un nombre d'arrêts intermédiaires valant 0.

On peut en déterminer :

  1. la prochaine étape du voyage, qui doit être une étape à pied allant de Renens VD, gare à Renens VD, car pour qu'un voyage soit valide il doit alterner les étapes en véhicule et à pied,
  2. la prochaine étape en véhicule du voyage, qui part de Renens VD à 16h08 et arrive à Lausanne à 16h14.

Pour continuer, il suffit de rechercher dans la frontière de Pareto associée à l'arrêt Lausanne le tuple permettant d'arriver à destination à 17h57 en effectuant encore 1 changement, puis de procéder comme ci-dessus. Et ainsi de suite jusqu'à ce qu'il ne reste plus de changements à effectuer.

2.5. Étapes initiales et finales

La technique d'extraction de voyage présentée à la section précédente produit toujours des voyages dont la première et la dernière étape se font en véhicule et non à pied. Or parfois, l'une ou l'autre, ou les deux, de ces étapes doivent se faire à pied.

Par exemple, si au lieu de partir depuis l'arrêt Ecublens VD, EPFL, on désire partir depuis l'arrêt Ecublens VD, EPFL (bus) — l'arrêt de bus se trouvant juste à côté de l'arrêt du m1 —, la première étape consiste à marcher jusqu'à l'arrêt du m1. Bien entendu, une situation similaire peut se produire pour la dernière étape d'un voyage.

Dès lors, la technique d'extraction mentionnée précédemment doit être légèrement augmentée afin d'ajouter une étape à pied initiale si la première liaison part d'un arrêt différent de l'arrêt de départ choisi. De même, une étape à pied finale doit être ajoutée si, après avoir effectué tous les changements requis, on se trouve à un arrêt qui n'est pas encore l'arrêt de destination.

3. Mise en œuvre Java

Avant de commencer à travailler à cette étape, il vous faut télécharger l'archive Zip que nous mettons à votre disposition et qui contient les données horaire aplaties. Cette archive est constituée d'un dossier nommé timetable dans lequel se trouvent les fichiers suivants :

  • strings.txt contenant la table des chaînes, avec une chaîne par ligne, et dont les caractères sont encodés en ISO 8859-1,
  • stations.bin contenant les gares,
  • station-aliases.bin contenant les noms alternatifs des gares,
  • platforms.bin contenant les voies/quais,
  • routes.bin contenant les lignes,
  • transfers.bin contenant les changements.

Dans le dossier timetable se trouvent de plus 7 dossiers journaliers dont le nom est la date d'un jour de la semaine 12 de 2025 — qui va du 17 au 23 mars — et contenant chacun les trois fichiers dont les données varient de jour en jour, à savoir :

  • trips.bin contenant les courses du jour,
  • connections.bin contenant les liaisons du jour,
  • connections-succ.bin contenant les successeurs des liaisons du jour.

Nous vous recommandons d'extraire le contenu de cette archive directement dans le dossier principal de votre projet, de manière à ce que le dossier timetable qu'elle contient se trouve au même niveau que le dossier src contenant votre code.

Attention : si vous utilisez git, ne stockez en aucun cas les données horaires dans votre entrepôt ! Pour éviter toute fausse manipulation, ajoutez une ligne à votre fichier .gitignore pour que git ignore le dossier les contenant (/timetable/).

Chaque lundi jusqu'à la fin du semestre, nous publierons sur la page consacrée au projet une nouvelle archive contenant les données de la semaine.

3.1. Enregistrement FileTimeTable

L'enregistrement FileTimeTable du sous-paquetage timetable.mapped, public, implémente l'interface TimeTable et représente un horaire de transport public dont les données (aplaties) sont stockées dans des fichiers. Il possède les attributs suivants :

Path directory
le chemin d'accès au dossier contenant les fichiers des données horaires,
List<String> stringTable
la table des chaînes de caractères,
Stations stations
les gares,
StationAliases stationAliases
les noms alternatifs des gares,
Platforms platforms
les voies/quai,
Routes routes
les lignes,
Transfers transfers
les changements.

FileTimeTable possède une méthode publique et statique facilitant la création d'une instance à partir du chemin d'accès au dossier contenant les données horaires :

TimeTable in(Path directory) throws IOException
qui retourne une nouvelle instance de FileTimeTable dont les données aplaties ont été obtenues à partir des fichiers se trouvant dans le dossier dont le chemin d'accès est donné, en faisant l'hypothèse que ce dossier est organisé de la même manière que le dossier timetable de l'archive que nous vous fournissons ; lève une IOException en cas d'erreur d'entrée/sortie.

En dehors de cette méthode statique, les seules méthodes publiques offertes par FileTimeTable sont des versions concrètes des méthodes abstraites de TimeTable.

3.1.1. Conseils de programmation

  1. Chemins d'accès

    La classe Path de la bibliothèque Java représente un chemin d'accès à un fichier ou un dossier. Les deux seules méthodes de cette classe nécessaires à cette étape sont :

    • la méthode statique of, qui permet de créer un chemin d'accès à un dossier à partir de son nom,
    • la méthode resolve, qui permet d'obtenir un chemin d'accès à un fichier ou un sous-dossier inclu dans celui auquel on applique la méthode.

    L'extrait de code suivant illustre leur utilisation en obtenant d'abord un chemin d'accès vers le dossier timetable contenant les données horaires, puis un chemin vers le fichier strings.txt contenant la table des chaînes :

    Path directory = Path.of("timetable");
    Path strings = directory.resolve("strings.txt");
    
  2. Lecture de la table des chaînes

    La lecture de la table des chaînes peut se faire aisément au moyen de la méthode readAllLines de Files. N'oubliez pas que les caractères du fichier strings.txt fourni sont encodés en ISO 8859-1, et pas en UTF-8.

    Comme sa documentation le mentionne, readAllLines retourne une liste qui peut être modifiable ou non. Pour garantir l'immuabilité de cette liste, on peut donc vouloir la copier au moyen de copyOf avant de la passer aux constructeurs des différentes classes Buffered….

    Notez que cette copie est obligatoire lorsque les constructeurs des classes Buffered… eux-mêmes copient la table reçue avec copyOf. En effet, au vu de sa taille, il est crucial qu'une seule copie de la table des chaînes existe dans tout le programme. Or si les constructeurs utilisent copyOf, la seule manière de garantir l'unicité de la table est de leur fournir une liste qui a déjà été préalablement copiée avec cette méthode, car dans ce cas elle retourne la liste reçue sans la copier, comme l'explique sa documentation.

  3. « Mappage » mémoire

    Pour obtenir les tableaux d'octets de type ByteBuffer avec le contenu des différents fichiers, il faut utiliser le « mappage » mémoire, comme expliqué plus haut. Cela peut se faire au moyen de la classe FileChannel du paquetage java.nio, qui n'a pas été vue au cours mais qui a un but similaire à InputStream. Pour les besoins de ce projet, il n'est pas nécessaire de la comprendre en détail, le « mappage » d'un fichier pouvant se faire simplement en :

    • obtenant un « canal » de type FileChannel permettant de lire les données du fichier qui nous intéresse, au moyen de la méthode open à laquelle on ne passe aucune option,
    • « mappant » le contenu du fichier au moyen de la méthode map, à laquelle on passe READ_ONLY comme mode, 0 comme position et la valeur retournée par la méthode size du canal comme taille — afin de « mapper » en lecture seule la totalité des données du fichier,
    • fermant le canal, de préférence de manière implicite avec un try-with-resource.

    La méthode map retourne un tableau d'octets de type ByteBuffer contenant la totalité des données du fichier. Toutefois, comme elle fait cela par « mappage » de son contenu en mémoire, ces données sont en réalité lues à la demande, au moment de leur première utilisation. Il faut noter que les tableaux retournés par map restent utilisables même après la fermeture du canal, et sont immuables.

  4. Gestion des exceptions

    Les fichiers contenant les données qui ne dépendent pas de la date du voyage doivent tous être « mappés » dans la méthode in. Par contre, ceux qui dépendent de la date ne doivent l'être que dans les méthodes connectionsFor et tripsFor. Cela pose un petit problème, car lors du « mappage », une IOException pourrait être levée, et il s'agit d'une exception de type checked. La manière standard de résoudre ce problème serait d'ajouter un throws IOException à ces méthodes, mais nous ne pouvons le faire car il s'agit de redéfinitions de méthodes, et les méthodes originales ne lèvent pas d'exception.

    Nous résoudrons donc ce problème en attrapant les exceptions de type IOException et en levant une UncheckedIOException qui, comme son nom l'indique, est de type unchecked. Cela peut se faire ainsi :

    try {
      // … code pouvant lever une IOException
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
    

    Attention : seules les méthodes connectionsFor et tripsFor doivent utiliser cette technique !

3.2. Enregistrement Profile

L'enregistrement Profile du sous-paquetage journey, public et immuable, représente un profil. Il possède les attributs suivants :

TimeTable timeTable
l'horaire auquel correspond le profil,
LocalDate date
la date à laquelle correspond le profil,
int arrStationId
l'index de la gare d'arrivée à laquelle correspond le profil,
List<ParetoFront> stationFront
la table des frontières de Pareto de toutes les gares, qui contient, à un index donné, la frontière de la gare de même index.

Le constructeur compact de Profile copie la table des frontières de Pareto afin de garantir l'immuabilité de la classe.

Profile possède les méthodes publiques suivantes :

Connections connections()
qui retourne les liaisons correspondant au profil, qui sont simplement celles de l'horaire, à la date à laquelle correspond le profil,
Trips trips()
qui retourne les courses correspondant au profil, qui sont simplement celles de l'horaire, à la date à laquelle correspond le profil,
ParetoFront forStation(int stationId)
qui retourne la frontière de Pareto pour la gare d'index donné, ou lève une IndexOutOfBoundsException si cet index est invalide.

3.3. Classe Profile.Builder

La classe Builder, publique, finale et imbriquée statiquement dans l'enregistrement Profile, représente un bâtisseur de profil. Ce bâtisseur étant destiné à être utilisé lors du calcul des voyages optimaux, il stocke non seulement les frontières de Pareto en cours de construction pour les gares, mais aussi pour les courses. En d'autres termes, il représente un profil augmenté en cours de construction, mais finit par bâtir un profil simple.

Builder offre un unique constructeur public :

Builder(TimeTable timeTable, LocalDate date, int arrStationId)
qui construit un bâtisseur de profil pour l'horaire, la date et la gare de destination donnés.

Le constructeur stocke non seulement les arguments qu'on lui a passé dans des attributs, afin de pouvoir les passer plus tard au constructeur de Profile, mais il initialise également deux tableaux primitifs destinés à contenir les bâtisseurs des frontières de Pareto des gares et des courses. Ces tableaux ne doivent pas être remplis initialement, donc leurs éléments doivent tous être null.

En plus de ce constructeur, Builder offre les méthodes publiques suivantes :

ParetoFront.Builder forStation(int stationId)
qui retourne le bâtisseur de la frontière de Pareto pour la gare d'index donné, qui est null si aucun appel à setForStation n'a été fait précédemment pour cette gare, ou lève une IndexOutOfBoundsException si l'index est invalide,
void setForStation(int stationId, ParetoFront.Builder builder)
qui associe le bâtisseur de frontière de Pareto donné à la gare d'index donné, ou lève une IndexOutOfBoundsException si l'index est invalide,
ParetoFront.Builder forTrip(int tripId)
qui fait la même chose que forStation mais pour la course d'index donné,
void setForTrip(int tripId, ParetoFront.Builder builder)
qui fait la même chose que setForStation mais pour la course d'index donné,
Profile build()
qui retourne le profil simple — sans les frontières de Pareto correspondant aux courses — en cours de construction.

3.3.1. Conseils de programmation

La liste des frontières de Pareto passée au constructeur de Profile ne doit pas contenir de valeur null, entre autres car cela provoquerait la levée d'une exception par copyOf. La méthode build doit donc utiliser la constante ParetoFront.EMPTY chaque fois qu'elle rencontre un bâtisseur de gare valant null.

3.4. Classe JourneyExtractor

La classe JourneyExtractor du sous-paquetage journey, publique et non instanciable, représente un « extracteur de voyages » et contient une seule méthode publique :

List<Journey> journeys(Profile profile, int depStationId)
qui retourne la totalité des voyages optimaux correspondant au profil et à la gare de départ donnés, triés d'abord par heure de départ (croissante) puis par heure d'arrivée (croissante).

3.4.1. Conseils de programmation

  1. Parcours des frontières de Pareto

    L'extraction de tous les voyages se fait en parcourant tous les éléments de la frontière de Pareto de la gare de départ. Ce parcours ne peut se faire qu'au moyen de la méthode forEach de ParetoFront, et la manière la plus simple d'utiliser cette méthode consiste à lui passer une lambda. Nous n'avons pas encore vu ce concept au cours, mais il n'est pas nécessaire de le comprendre en détail pour cette étape, il suffit juste de savoir qu'au moyen de la syntaxe suivante :

    ParetoFront pf = profile.forStation(stationId);
    pf.forEach((long criteria) -> {
        // … code qui utilise criteria
      });
    

    on peut parcourir la totalité des critères de la frontière de Pareto pf, chacun d'entre eux étant à tour de rôle assigné à la variable criteria. En d'autres termes, cette syntaxe permet de faire la même chose que la syntaxe suivante, que vous connaissez déjà et que nous utiliserions si la frontière de Pareto était stockée dans un tableau de type long[] :

    long[] pf = profile.forStation(stationId);
    for (long criteria : pf) {
      // … code qui utilise criteria
    }
    
  2. Tri des voyages

    Une fois les voyages extraits, il faut les trier par ordre croissant d'heure de départ, puis par ordre croissant d'heure d'arrivée. Une liste peut être triée au moyen de la méthode sort, qui attend en argument un comparateur. Comme la notion de comparateur n'a pas encore été vue au cours, et que la création du comparateur requis nécessite l'utilisation d'une syntaxe que vous ne connaissez pas encore, nous vous donnons le code à écrire pour effectuer le tri :

    List<Journey> journeys = …;
    journeys.sort(Comparator
                  .comparing(Journey::depTime)
                  .thenComparing(Journey::arrTime))
    

    Même si cette syntaxe vous est encore inconnue, il ne devrait pas être difficile de comprendre que le tri est fait :

    • d'abord par heure de départ (comparing(Journey::depTime)),
    • puis par heure d'arrivée (thenComparing(Journey::arrTime)).

    Les arguments passés à comparing et thenComparing sont des lambdas obtenues au moyen de ce qu'on appelle des références de méthodes.

  3. Gares et voies/quais

    Lors de l'extraction des voyages, il faut faire bien attention au fait que les liaisons partent et arrivent à des arrêts qui peuvent être des voies/quais, et pas seulement des gares. Par contre, les changements se font toujours entre deux gares, ou au sein d'une seule gare. De même, les frontières de Pareto stockées dans le profil sont toujours associées aux gares, et jamais aux voies/quai.

    Gardez toujours cela à l'esprit, et utilisez la méthode stationId de TimeTable pour obtenir la gare correspondant à un arrêt chaque fois que cela est nécessaire.

3.5. Tests

Comme d'habitude, nous ne vous fournissons plus de tests mais un fichier de vérification de signatures à importer dans votre projet.

Pour vous aider à tester l'extraction de voyage, nous vous fournissons une archive Zip contenant un fichier nommé profile_2025-03-18_11486.txt. Il contient la représentation textuelle d'un profil pour le 18 mars 2025, avec la gare 11486 (Gruyères) comme destination. Chaque ligne correspond à une gare, dont les éléments de la frontière de Pareto sont représentés en base 16, séparés par des virgules. Par exemple, la première ligne du fichier commence ainsi :

552b298408159611,56db1b070a9ad404,570b0b840ad9ae11,…

ce qui signifie que la frontière de Pareto associée à la gare d'index 0 dans le profil a comme premier tuple empaqueté 552b29840815961116 — soit \((\textrm{18h50}, \textrm{22h59}, 4)\) avec une charge utile dont la liaison est celle d'index 529814 (8159616) et le nombre d'arrêts intermédiaires est 17 (1116).

Pour lire ce profil, vous pouvez vous inspirer du code ci-dessous, qui n'utilise que des concepts que vous connaissez déjà et que vous devriez donc pouvoir comprendre sans difficulté majeure :

Profile readProfile(TimeTable timeTable,
                    LocalDate date,
                    int arrStationId) throws IOException {
  Path path =
    Path.of("profile_" + date + "_" + arrStationId + ".txt");
  try (BufferedReader r = Files.newBufferedReader(path)) {
    Profile.Builder profileB =
      new Profile.Builder(timeTable, date, arrStationId);
    int stationId = -1;
    String line;
    while ((line = r.readLine()) != null) {
      stationId += 1;
      if (line.isEmpty()) continue;
      ParetoFront.Builder frontB = new ParetoFront.Builder();
      for (String t : line.split(","))
        frontB.add(Long.parseLong(t, 16));
      profileB.setForStation(stationId, frontB);
    }
    return profileB.build();
  }
}

Une fois le profil lu, vous pouvez vérifier que votre code est correct en extrayant tous les voyages au départ de la gare 7872 (Ecublens VD, EPFL), et en convertissant celui d'index 32 en événement iCalendar, au moyen de code similaire à celui-ci :

TimeTable t = FileTimeTable.in(Path.of("timetable"));
LocalDate date = LocalDate.of(2025, Month.MARCH, 18);
Profile p = readProfile(t, date, 11486);
List<Journey> js = JourneyExtractor.journeys(p, 7872);
String j = JourneyIcalConverter.toIcalendar(js.get(32));
System.out.println(j);

Vous devriez voir un événement iCalendar presque identique à celui de la §2.1.8 de l'étape 2.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes FileTimeTable, Profile, Profile.Builder et JourneyExtractor selon les indications plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre code au plus tard le 28 mars 2025 à 18h00, au moyen du programme Submit.java fourni et des jetons disponibles sur votre page privée.

Ce rendu est un rendu testé, auquel 20 points sont attribués, au prorata des tests unitaires passés avec succès.

N'attendez surtout pas le dernier moment pour effectuer votre rendu, car vous n'êtes pas à l'abri d'imprévus.

Si vous manquez la date limite de rendu, vous avez encore la possibilité de faire un rendu tardif au moyen des jetons prévus à cet effet, et ce durant les 2 heures qui suivent, mais il vous en coûtera une pénalité inconditionnelle de 2 points.