Exercice 06 — Dashboard ESL
Partie 5 — Extensions fluentes +
ToCsv()+WithFallback()+PairWith()+ DSL
Concepts théoriques
- Thématique 05 — Extensions et DSL fluent
- Méthodes d'extension et chaînage
- Composition de fonctions f ∘ g
- DSL — Domain Specific Language
- Zip — combiner deux séquences en parallèle
Contexte
L'équipe présente ses stats à un tournoi ESL. Le data analyst prépare un rapport hebdomadaire : export CSV pour l'organisateur, gestion des données manquantes, comparaison kills vs assists côte à côte.
Un pipeline fluent lisible comme du langage naturel est l'objectif :
kdaLea
.Smooth(windowSize: 3)
.WithFallback(0.0, v => double.IsNaN(v))
.PairWith(kdaDylan)
.ToCsv("report.csv");Avant de coder :
Normalize(),Smooth()(exercice 04) etStatistics()(exercice 05) étaient des fonctionsstaticisolées dans un utilitaire provisoireMathHelpers, appelées explicitement (MathHelpers.Normalize(series)) faute de mécanisme pour les rattacher àDataSeries<double>. Quel mot-clé C# permet d'ajouter une méthode à un type existant sans modifier sa classe — et pourquoiDataSeries<T>(générique) en a besoin plutôt que d'écrire des méthodes d'instance ?- Quel contrat chaque méthode doit-elle respecter pour que le chaînage reste possible ?
Indice
Le contrat de la composition : chaque méthode retourne le même type qu'elle reçoit (ou un type compatible). C'est ce qui permet x.g().f() — l'équivalent C# de (f ∘ g)(x). → Le contrat de la composition
Étape 1 — ToCsv() dans DataSeriesExtensions.cs
Renommer le fichier provisoire de l'exercice 04 (DataSeries/MathHelpers.cs) en DataSeries/DataSeriesExtensions.cs, et sa classe MathHelpers en DataSeriesExtensions. Ajouter le mot-clé this devant le premier paramètre de Normalize, Smooth et Statistics (exercice 05) — elles deviennent enfin de vraies méthodes d'extension, appelables en series.Normalize(), series.Smooth(windowSize) et series.Statistics() plutôt que via MathHelpers.Normalize(series). Ajouter ToCsv à côté, directement comme méthode d'extension :
public static class DataSeriesExtensions
{
public static void ToCsv(this DataSeries<double> series, string path)
{
// générer les lignes "date,valeur" depuis DataPoints et écrire dans le fichier
// ...
}
}Voir la solution
public static void ToCsv(this DataSeries<double> series, string path)
{
var lines = series.DataPoints.Select(dp => $"{dp.Timestamp:yyyy-MM-dd},{dp.Value:F4}");
File.WriteAllLines(path, lines.Prepend("date,value"));
}Chaque ligne porte la vraie date du match — le fichier est importable et triable dans Excel.
Étape 2 — WithFallback(fallback, isMissing)
WithFallback n'est qu'un Transform conditionnel :
public static DataSeries<T> WithFallback<T>(
this DataSeries<T> series, T fallback, Func<T, bool> isMissing)
{
// ...
}Voir la solution
public static DataSeries<T> WithFallback<T>(
this DataSeries<T> series, T fallback, Func<T, bool> isMissing)
=> series.Transform(v => isMissing(v) ? fallback : v);Étape 3 — PairWith() — kills vs assists côte à côte
Zip (ici PairWith) combine deux listes élément par élément — pattern fondamental pour traiter des séries corrélées (kills + assists, température + humidité...). → Zip — combiner deux séquences en parallèle
public static DataSeries<(double Left, double Right)> PairWith(
this DataSeries<double> left, DataSeries<double> right)
{
// Hint : Zip combine deux séquences élément par élément
// ...
}Voir la solution
public static DataSeries<(double Left, double Right)> PairWith(
this DataSeries<double> left, DataSeries<double> right)
=> DataSeries<(double, double)>.From(
left.DataPoints.Zip(right.DataPoints, (l, r) =>
new DataPoint<(double, double)>(l.Timestamp, (l.Value, r.Value)))
);Les timestamps de la série gauche sont préservés dans le résultat — le CSV exporté via ToCsv reste daté.
Comparer kills normalisés et assists normalisés de Léa :
var kills = valorant.Filter(m => m.Player == "Léa").Transform(m => (double)m.Kills);
var assists = valorant.Filter(m => m.Player == "Léa").Transform(m => (double)m.Assists);
var report = kills.Normalize().PairWith(assists.Normalize());
foreach (var (k, a) in report.Values)
Console.WriteLine($"kills={k:F2} assists={a:F2}");DSL final — rapport hebdomadaire en une expression
Les extensions définissent le vocabulaire du domaine. Un bon DSL se lit sans avoir besoin de connaître l'implémentation : → DSL
valorant
.Filter(m => m.Player == "Léa")
.Transform(m => (double)m.Kills)
.Normalize()
.Smooth(3)
.WithFallback(0.0, v => double.IsNaN(v))
.ToCsv("lea_kills_smoothed.csv");Un pipeline est aussi une valeur. La préparation commune (lisser puis combler les trous) se répète pour chaque série du rapport — la stocker dans une variable :
// Préparation commune : lisser puis combler les trous
Func<DataSeries<double>, DataSeries<double>> prepare =
s => s.Smooth(3).WithFallback(0.0, v => double.IsNaN(v));
// Réutilisé sur chaque série du rapport hebdomadaire
prepare(killsLea).ToCsv("lea_kills.csv");
prepare(assistsLea).ToCsv("lea_assists.csv");
prepare(kdaRaphael).ToCsv("raphael_kda.csv");Composer deux pipelines stockés donne un nouveau pipeline — c'est exactement f ∘ g : → Composition explicite avec Func · Fonctions comme valeurs
Étape 4 — Interface CLI
Ajouter --export <fichier> pour déclencher l'export CSV depuis la ligne de commande.
Avant de coder : À quel endroit du pipeline appeler ToCsv — avant ou après Smooth ? Comment récupérer le nom du fichier cible depuis args ?
dotnet run -- --game valorant --player Léa --stat kda --export lea_kda.csvVoir la solution
if (args.Contains("--export"))
{
var file = args[Array.IndexOf(args, "--export") + 1];
values.ToCsv(file);
Console.WriteLine($"Exporté : {file}");
}Vérification
ToCsvproduit un fichier importable dans ExcelWithFallbacksur des données propres ne modifie aucun élémentPairWithsur deux séries de longueur différente → Zip s'arrête à la plus courte- Le pipeline DSL compile et produit un résultat identique aux étapes intermédiaires