Exercice 02 — Les recrues
Partie 2 —
DataSeries.FromCsv+Enumerable.Range+Select(map sur un range) + seed fixe
Concepts théoriques
- Thématique 02 — Filter et fonctions d'ordre supérieur
- Fonctions d'ordre supérieur
- Closures
- Évaluation paresseuse
Contexte
Les matchs en dur de l'exercice 01 ne suffisent plus : les 75 matchs de la saison attendent dans data/. Ensuite, place aux recrues : Raphaël, Noé, Dylan et Kiara n'ont rejoint Team Helvetia qu'en cours de saison — leurs matchs précédents sont perdus. Le data analyst doit simuler des données plausibles pour compléter l'historique.
Prérequis — DataSeries<T> comme vraie série temporelle
À faire avant l'étape 1 si l'exercice 01 a été réalisé sans
DataPoint<T>.
Une série temporelle sans date n'a pas de sens : impossible de filtrer par période, de tracer une évolution, ou d'exporter un CSV cohérent. DataSeries<T> doit donc toujours stocker des DataPoint<T> — chaque donnée est ancrée dans le temps.
valorant.csv DataSeries<ValorantMatch>
────────────────────────────── ──────────────────────────────────────────
date,player,agent,kills,... ┌── DataPoint<ValorantMatch> ───────────┐
│ Timestamp : 2024-01-05 │
2024-01-05,Léa,Jett,18,6,... ───────►│ Value : ValorantMatch { │
│ │ │ Player = "Léa" │
│ cols[0] │ cols[1..] │ Agent = "Jett" │
│ DateTime.Parse │ parser() │ Kills = 18 Deaths = 6}│
└────────────────────┘ └───────────────────────────────────────┘
┌── DataPoint<ValorantMatch> ───────────┐
2024-01-12,Léa,Reyna,22,8,... ──────►│ Timestamp : 2024-01-12 │
│ Value : ValorantMatch { ... } │
└───────────────────────────────────────┘
┌── DataPoint<ValorantMatch> ───────────┐
2024-02-03,Léa,Neon,20,7,... ──────►│ Timestamp : 2024-02-03 │
│ Value : ValorantMatch { ... } │
└───────────────────────────────────────┘Adapter DataSeries/DataSeries.cs :
// Avant — stockage de valeurs nues
private readonly IEnumerable<T> _data;
private DataSeries(IEnumerable<T> data) => _data = data;
public static DataSeries<T> From(IEnumerable<T> source) => new DataSeries<T>(source);
public IEnumerable<T> Values => _data;
// Après — chaque valeur est datée
private readonly IEnumerable<DataPoint<T>> _data;
private DataSeries(IEnumerable<DataPoint<T>> data) => _data = data;
public static DataSeries<T> From(IEnumerable<DataPoint<T>> source) => new DataSeries<T>(source);
public IEnumerable<T> Values => _data.Select(dp => dp.Value); // valeurs seules
public IEnumerable<DataPoint<T>> DataPoints => _data; // valeurs + datesAdapter les données en dur de l'exercice 01 :
// Avant
new ValorantMatch("Léa", "Jett", 18, 6, 4, 8, 13, true)
// Après — la date du match fait partie de la donnée
new DataPoint<ValorantMatch>(new DateTime(2024, 1, 15), new ValorantMatch("Léa", "Jett", 18, 6, 4, 8, 13, true))Values retourne toujours IEnumerable<T> — le reste du code de l'exercice 01 compile sans modification.
Étape 1 — DataSeries<T>.FromCsv (import fichier)
Première fonction d'ordre supérieur concrète du cours : une méthode qui reçoit une fonction en paramètre.
Avant de coder : FromCsv doit fonctionner pour Valorant, CS2 et LoL. Comment éviter d'écrire trois méthodes différentes ? Quel paramètre permet de déléguer la logique de parsing à l'appelant ?
Indice sur la signature
Le parser est une fonction Func<string[], T> — elle reçoit les colonnes d'une ligne CSV et retourne un objet du type souhaité. FromCsv reste générique, le domaine reste dans EsportApp. → Func et Action
Implémenter la méthode dans DataSeries.cs :
public static DataSeries<T> FromCsv(string path, Func<string[], T> parser)
{
// lire le fichier, ignorer la première ligne (en-tête)
// parser chaque ligne en la découpant par ','
// ...
}Voir la solution
public static DataSeries<T> FromCsv(string path, Func<string[], T> parser)
{
var lines = File.ReadAllLines(path).Skip(1); // ignorer l'en-tête
return new DataSeries<T>(lines.Select(line =>
{
var cols = line.Split(',');
return new DataPoint<T>(DateTime.Parse(cols[0]), parser(cols));
}));
}La colonne date (cols[0]) est parsée en DateTime et stockée dans le DataPoint<T>. Le parser reçoit toutes les colonnes — il continue à lire à partir de cols[1].
DateTime.Parse— ce qu'il faut savoir
- Format ISO 8601 (
2024-01-05) :DateTime.Parsele reconnaît sur toutes les cultures .NET. Pour le rendre explicite :DateTime.Parse(cols[0], CultureInfo.InvariantCulture).- Format custom (
05/01/2024,01-05-24...) : préférerDateTime.ParseExactavec le masque exact :DateTime.ParseExact(cols[0], "dd/MM/yyyy", CultureInfo.InvariantCulture). Échoue immédiatement si le format ne correspond pas — erreur claire plutôt que date silencieusement fausse.
Avant de coder les parsers : pour chaque jeu, identifier l'indice de chaque colonne dans le CSV (date est à l'indice 0 — déjà traité par FromCsv ; le parser commence à cols[1]).
// Valorant : date(0), player(1), agent(2), kills(3), deaths(4), assists(5), ...
ValorantMatch ParseValorant(string[] cols) => new ValorantMatch(
cols[1], // player
cols[2], // agent
int.Parse(cols[3]), // kills
// ...
);Voir les parsers complets (Valorant, CS2, LoL)
ValorantMatch ParseValorant(string[] cols) => new ValorantMatch(
cols[1], // player
cols[2], // agent
int.Parse(cols[3]), // kills
int.Parse(cols[4]), // deaths
int.Parse(cols[5]), // assists
int.Parse(cols[6]), // headshots
int.Parse(cols[7]), // roundsWon
bool.Parse(cols[8]) // won
);
Cs2Match ParseCs2(string[] cols) => new Cs2Match(
cols[1], // player
cols[2], // map
cols[3], // startSide (côté joué en 1re mi-temps — CT ou T)
int.Parse(cols[4]), // kills
int.Parse(cols[5]), // deaths
int.Parse(cols[6]), // assists
int.Parse(cols[7]), // mvps
bool.Parse(cols[8]) // won
);
LolMatch ParseLol(string[] cols) => new LolMatch(
cols[1], // player
cols[2], // champion
int.Parse(cols[4]), // kills
int.Parse(cols[5]), // deaths
int.Parse(cols[6]), // assists
int.Parse(cols[7]), // cs
int.Parse(cols[8]), // visionScore
bool.Parse(cols[9]) // won
);Les classes ValorantMatch, Cs2Match et LolMatch viennent de l'exercice 01.
DataSeries<T>.Count— cette propriété sera implémentée dans un chapitre ultérieur. Pour l'instant, l'ajouter telle quelle dansDataSeries.cs:
public int Count => _data.Count();Charger les trois fichiers et vérifier le total :
var valorant = DataSeries<ValorantMatch>.FromCsv("data/valorant.csv", ParseValorant);
var cs2 = DataSeries<Cs2Match>.FromCsv("data/cs2.csv", ParseCs2);
var lol = DataSeries<LolMatch>.FromCsv("data/lol.csv", ParseLol);
Console.WriteLine($"Valorant : {valorant.Count} matchs");
Console.WriteLine($"CS2 : {cs2.Count} matchs");
Console.WriteLine($"LoL : {lol.Count} matchs");
// Total : 75 matchsUn seul FromCsv pour trois formats différents : la logique de parsing est une valeur passée en paramètre. C'est exactement ce qui manquait à l'exercice 01.
La date de chaque match est désormais conservée. Requêtes chronologiques disponibles :
// Matchs du premier trimestre
var q1 = valorant.FilterByDate(d => d.Month <= 3);
Console.WriteLine($"Matchs jan–mars : {q1.Count}");
// Combiner filtre temporel et filtre métier — composition naturelle
var q1Wins = valorant.FilterByDate(d => d.Month <= 3).Filter(m => m.Won);
Console.WriteLine($"Victoires jan–mars : {q1Wins.Count}");FilterByDate est à ajouter dans DataSeries<T> — symétrique de Filter :
public DataSeries<T> FilterByDate(Func<DateTime, bool> predicate)
=> new DataSeries<T>(_data.Where(dp => predicate(dp.Timestamp)));Étape 2 — Générer 20 matchs CS2 pour Raphaël
Enumerable.Range génère une séquence d'entiers. Combiné avec Select, il devient un générateur fonctionnel — l'équivalent d'une boucle for, mais déclaratif :
// Impératif
var list = new List<int>();
for (int i = 1; i <= 20; i++)
list.Add(i * 2);
// Fonctionnel — même résultat
var list = Enumerable.Range(1, 20).Select(i => i * 2);La seed fixe (new Random(42)) garantit un résultat reproductible — propriété essentielle pour des données de test : tout le monde obtient les mêmes valeurs.
Avant de coder : quelles informations faut-il générer aléatoirement pour un match CS2 ? Quelles valeurs sont réalistes en MR12 (maximum 24 rounds par match) ?
Voir les plages de valeurs réalistes
Map: tirer au sort parmi{ "Dust2", "Mirage", "Inferno", "Nuke", "Ancient" }StartSide:"CT"ou"T"(côté de la première mi-temps)Kills: entre 10 et 27 (MR12 = max 24 rounds, une kill par round est déjà très fort)Deaths: entre 6 et 17Assists: entre 0 et 7Mvps: entre 0 et 4Won: aléatoire 50/50
Créer EsportApp/MatchGenerator.cs avec la méthode GenerateCs2 :
public static class MatchGenerator
{
public static DataSeries<Cs2Match> GenerateCs2(string player, int count, int seed = 42)
{
var rng = new Random(seed);
var maps = new[] { "Dust2", "Mirage", /* ... */ };
var sides = new[] { "CT", "T" };
var start = new DateTime(2023, 9, 1); // début de la pré-saison
return DataSeries<Cs2Match>.From(
Enumerable.Range(1, count)
.Select(i => new DataPoint<Cs2Match>(
start.AddDays(i),
new Cs2Match(player, maps[rng.Next(maps.Length)], /* ... */)
))
);
}
}La lambda passée à
Selectutiliserng,maps,sidesetstartdéclarés en dehors d'elle : c'est une closure — la fonction capture les variables de son environnement. → Closures
Voir la solution complète
public static class MatchGenerator
{
public static DataSeries<Cs2Match> GenerateCs2(string player, int count, int seed = 42)
{
var rng = new Random(seed);
var maps = new[] { "Dust2", "Mirage", "Inferno", "Nuke", "Ancient" };
var sides = new[] { "CT", "T" };
var start = new DateTime(2023, 9, 1);
return DataSeries<Cs2Match>.From(
Enumerable.Range(1, count)
.Select(i => new DataPoint<Cs2Match>(
start.AddDays(i),
new Cs2Match(
player,
maps[rng.Next(maps.Length)],
sides[rng.Next(2)],
rng.Next(10, 28), // kills
rng.Next(6, 18), // deaths
rng.Next(0, 8), // assists
rng.Next(0, 5), // mvps
rng.Next(2) == 0 // won
)
))
);
}
}Dans Program.cs :
var raphaelGenerated = MatchGenerator.GenerateCs2("Raphaël", 20);
Console.WriteLine(raphaelGenerated.Count); // 20Ajouter des contraintes réalistes. Quelles combinaisons générées sont physiquement impossibles dans CS2 ?
Voir les contraintes à appliquer
kills + assists > 50: impossible dans un match CS2deaths == 0: n'arrive jamais sur une série complète de matchs
Le prédicat de validation est une valeur : stockée dans une variable, nommée, réutilisable pour les 4 joueurs.
Func<Cs2Match, bool> isValid = m =>
m.Kills + m.Assists <= 50 &&
m.Deaths >= 1;
var raphaelValid = raphaelGenerated.Filter(isValid);
Console.WriteLine($"Avant : {raphaelGenerated.Count}, après : {raphaelValid.Count}");
Filtersera implémenté dans la bibliothèque à l'exercice 03. Pour l'instant, anticiper son usage : il reçoit unFunc<T, bool>et retourne une nouvelleDataSeries<T>.
Observer la perte de données — combien de matchs ont été filtrés ? C'est le comportement attendu : des contraintes métier éliminent des cas impossibles.
Étape 3 — Exporter en CSV
Avant de coder : quel format doit avoir le CSV exporté pour être compatible avec FromCsv de l'étape 1 ? Regarder l'en-tête de data/cs2.csv.
Écrire une fonction ExportCs2 qui génère les lignes CSV et les écrit dans un fichier. Les dates réelles viennent des DataPoint<T> — plus besoin de les reconstruire artificiellement.
void ExportCs2(DataSeries<Cs2Match> matches, string path)
{
var header = "date,player,map,start_side,kills,deaths,assists,mvps,won";
var lines = matches.DataPoints.Select(dp =>
// construire la ligne CSV pour chaque DataPoint<Cs2Match>
// ...
);
File.WriteAllLines(path, lines.Prepend(header));
}Voir la solution
void ExportCs2(DataSeries<Cs2Match> matches, string path)
{
var header = "date,player,map,start_side,kills,deaths,assists,mvps,won";
var lines = matches.DataPoints.Select(dp =>
$"{dp.Timestamp:yyyy-MM-dd},{dp.Value.Player},{dp.Value.Map},{dp.Value.StartSide}," +
$"{dp.Value.Kills},{dp.Value.Deaths},{dp.Value.Assists},{dp.Value.Mvps},{dp.Value.Won.ToString().ToLower()}"
);
File.WriteAllLines(path, lines.Prepend(header));
}
// Générer, filtrer et exporter
ExportCs2(raphaelValid, "raphael_generated.csv");DataPoints donne accès à la fois à dp.Timestamp (la date générée) et dp.Value (le match). Le CSV produit est compatible avec FromCsv — les dates sont lisibles et triables.
Générer également pour Kiara (CS2), Dylan (Valorant) et Noé (LoL) avec des méthodes similaires adaptées à chaque format CSV.
Le paramètre
seedpeut varier par joueur pour obtenir des profils différents :GenerateCs2("Kiara", 20, seed: 7)→ profil AWPer avec plus de kills et plus de variance.
Étape 4 — Interface CLI
Ajouter le flag --generate <joueur|all> pour déclencher la génération depuis la ligne de commande.
Avant de coder : Si --generate all est passé, comment obtenir la liste des quatre joueurs ? Comment structurer le code pour que --generate Raphaël ne génère que ce joueur ? Pourquoi utiliser return après la génération ?
Voir la solution
if (args.Contains("--generate"))
{
var target = args[Array.IndexOf(args, "--generate") + 1];
var players = target == "all"
? new[] { "Raphaël", "Kiara", "Dylan", "Noé" }
: new[] { target };
foreach (var player in players)
{
var series = MatchGenerator.GenerateCs2(player, 20);
ExportCs2(series.Filter(isValid), $"{player.ToLower()}_generated.csv");
Console.WriteLine($"{player} : données générées et exportées");
}
return;
}Le même prédicat isValid sert pour tous les joueurs — une fonction stockée dans une variable se réutilise comme n'importe quelle valeur.
Vérification
- 75 matchs chargés depuis les CSV :
valorant.Count= 25,cs2.Count= 25,lol.Count= 25 valorant.FilterByDate(d => d.Month <= 3).Count: sous-ensemble du premier trimestre- 4 fichiers CSV créés, chacun avec 15 à 20 lignes (quelques matchs filtrés)
- Les CSV exportés contiennent de vraies dates (format
yyyy-MM-dd) et sont importables avecFromCsv - Changer la seed → valeurs différentes ; même seed → résultat identique à chaque exécution
raphaelValid.Count< 20 (au moins quelques matchs filtrés par les contraintes)