From d7b4ad80318ddb71d9631a2fabc990e2bfa400f0 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Mon, 1 Dec 2025 23:41:32 +0100 Subject: [PATCH] =?UTF-8?q?Ajoute=20les=20visualisations=20des=20couleurs?= =?UTF-8?q?=20de=20t=C3=AAtes=20de=20minifigs=20et=20jalons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++ lib/plots/minifig_heads.py | 163 +++++++++++++++++++++++++++++++ lib/rebrickable/minifig_heads.py | 113 +++++++++++++++++++++ scripts/compute_minifig_heads.py | 35 +++++++ scripts/download_parts_data.py | 3 +- scripts/plot_minifig_heads.py | 21 ++++ tests/test_downloader.py | 2 + tests/test_minifig_heads.py | 137 ++++++++++++++++++++++++++ tests/test_minifig_heads_plot.py | 33 +++++++ 9 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 lib/plots/minifig_heads.py create mode 100644 lib/rebrickable/minifig_heads.py create mode 100644 scripts/compute_minifig_heads.py create mode 100644 scripts/plot_minifig_heads.py create mode 100644 tests/test_minifig_heads.py create mode 100644 tests/test_minifig_heads_plot.py diff --git a/README.md b/README.md index a6b7ea5..0f15936 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ En parallèle, le script `python -m scripts.plot_parts_per_set` génère `figure 2. `python -m scripts.download_parts_data` Le script télécharge les fichiers compressés `inventories.csv.gz`, `inventory_parts.csv.gz`, `inventory_minifigs.csv.gz`, `minifigs.csv.gz`, `parts.csv.gz` et `colors.csv.gz` vers `data/raw/`, les décompresse immédiatement en supprimant chaque archive `.gz`, et ne retélécharge pas les fichiers âgés de moins de 7 jours (cache fondé sur les CSV décompressés). Ces données complètent les sets en décrivant leurs inventaires, les pièces individuelles, les minifigs associées et les couleurs disponibles. +Le fichier des catégories de pièces (`part_categories.csv.gz`) est également téléchargé afin d'affiner au besoin la sélection de catégories liées aux têtes de minifigs. ### Étape 9 : assembler l'inventaire des pièces par set @@ -185,3 +186,17 @@ Le script lit `data/intermediate/colors_by_set.csv` et produit deux agrégats : Le script lit les agrégats de l'étape 14 et produit `figures/step15/colors_translucent_share.png` (part des pièces translucides par année et nombre de couleurs distinctes), `figures/step15/colors_heatmap_linear.png` (heatmap année × couleur en quantités brutes) et `figures/step15/colors_heatmap_log.png` (heatmap avec échelle log1p). Une troisième variante normalise les quantités par année : `figures/step15/colors_heatmap_share.png`. Dans cette vue, chaque colonne (année) est ramenée à une part relative (0–1) du total de pièces de l'année. Cela met en évidence la structure de palette indépendamment du volume : deux années restent comparables même si leur nombre total de pièces diffère fortement, mais l'information de volume absolu n'apparaît plus (à privilégier pour les comparaisons de proportions, pas pour mesurer la rareté volumique). + +### Étape 16 : couleurs de peau des minifigs + +1. `source .venv/bin/activate` +2. `python -m scripts.compute_minifig_heads` + +Le script identifie les têtes de minifigs via la catégorie Rebrickable dédiée (part_cat_id 59 dans `data/raw/parts.csv`), filtre les pièces de rechange, puis agrège leurs couleurs depuis `data/intermediate/parts_filtered.csv`. Les sorties sont `data/intermediate/minifig_heads_by_set.csv` (quantités de têtes par set, couleur et année) et `data/intermediate/minifig_heads_by_year.csv` (agrégées par année). Ces fichiers serviront de base pour analyser l'évolution des teintes de peau (ou assimilées) des minifigs. + +### Étape 17 : visualiser les couleurs de peau des minifigs + +1. `source .venv/bin/activate` +2. `python -m scripts.plot_minifig_heads` + +Le script lit `data/intermediate/minifig_heads_by_year.csv` et produit `figures/step16/minifig_heads_shares.png` (répartition annuelle des couleurs de têtes, en parts empilées) et `figures/step16/minifig_heads_global.png` (donut global des parts cumulées). Les couleurs sont limitées aux plus fréquentes (avec regroupement des autres). diff --git a/lib/plots/minifig_heads.py b/lib/plots/minifig_heads.py new file mode 100644 index 0000000..dbb35ae --- /dev/null +++ b/lib/plots/minifig_heads.py @@ -0,0 +1,163 @@ +"""Visualisations des couleurs de têtes de minifigs.""" + +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +import matplotlib.pyplot as plt + +from lib.filesystem import ensure_parent_dir +from lib.milestones import load_milestones +from lib.rebrickable.stats import read_rows + + +def load_heads_by_year(path: Path) -> List[dict]: + """Charge l'agrégat des têtes par année.""" + return read_rows(path) + + +def select_top_colors(rows: Iterable[dict], limit: int = 10) -> List[Tuple[str, str, str]]: + """Retourne les couleurs les plus fréquentes (color_name, color_rgb, is_translucent).""" + totals: Dict[Tuple[str, str, str], int] = {} + for row in rows: + key = (row["color_name"], row["color_rgb"], row["is_translucent"]) + totals[key] = totals.get(key, 0) + int(row["quantity"]) + sorted_colors = sorted(totals.items(), key=lambda item: (-item[1], item[0][0], item[0][1])) + return [color for color, _ in sorted_colors[:limit]] + + +def build_share_matrix(rows: Iterable[dict], top_colors: List[Tuple[str, str, str]]) -> Tuple[List[int], List[Tuple[str, str, str]], List[Dict[str, float]]]: + """Construit les parts par année, en agrégeant les couleurs hors top dans 'Autres'.""" + years = sorted({int(row["year"]) for row in rows}) + colors = top_colors + [("Autres", "444444", "false")] + shares_by_year: List[Dict[str, float]] = [] + rows_by_year: Dict[int, List[dict]] = {year: [] for year in years} + for row in rows: + rows_by_year[int(row["year"])].append(row) + for year in years: + year_rows = rows_by_year[year] + total = sum(int(r["quantity"]) for r in year_rows) + shares: Dict[str, float] = {color[0]: 0.0 for color in colors} + for r in year_rows: + key = (r["color_name"], r["color_rgb"], r["is_translucent"]) + quantity = int(r["quantity"]) + target = "Autres" if key not in top_colors else r["color_name"] + shares[target] = shares.get(target, 0.0) + quantity / total + shares_by_year.append(shares) + return years, colors, shares_by_year + + +def plot_shares_by_year( + heads_path: Path, + destination_path: Path, + top_limit: int = 10, + milestones_path: Path | None = None, +) -> None: + """Trace les parts des couleurs de têtes par année (stacked) avec jalons optionnels.""" + rows = load_heads_by_year(heads_path) + top_colors = select_top_colors(rows, limit=top_limit) + years, colors, shares_by_year = build_share_matrix(rows, top_colors) + milestones = load_milestones(milestones_path) if milestones_path else [] + + fig, ax = plt.subplots(figsize=(14, 6)) + bottoms = [0.0] * len(years) + for name, color_rgb, is_trans in colors: + values = [shares[name] for shares in shares_by_year] + edge = "#f2f2f2" if is_trans == "true" else "#0d0d0d" + ax.bar( + years, + values, + bottom=bottoms, + color=f"#{color_rgb}", + edgecolor=edge, + label=name, + linewidth=0.7, + ) + bottoms = [b + v for b, v in zip(bottoms, values)] + ax.set_ylim(0, 1.05) + ax.set_ylabel("Part des couleurs (têtes de minifigs)") + ax.set_xlabel("Année") + ax.set_xticks(years) + ax.set_title("Répartition des couleurs de peau (têtes de minifigs) par année") + ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1), frameon=False) + ax.grid(True, axis="y", linestyle="--", alpha=0.25) + if milestones: + min_year = min(years) + max_year = max(years) + milestones_in_range = sorted( + [m for m in milestones if min_year <= m["year"] <= max_year], + key=lambda m: (m["year"], m["description"]), + ) + offset_step = 0.3 + offset_map: Dict[int, int] = {} + top_limit = ax.get_ylim()[1] * 2 + for milestone in milestones_in_range: + year = milestone["year"] + count_for_year = offset_map.get(year, 0) + offset_map[year] = count_for_year + 1 + horizontal_offset = offset_step * (count_for_year // 2 + 1) + if count_for_year % 2 == 1: + horizontal_offset *= -1 + text_x = year + horizontal_offset + ax.axvline(year, color="#d62728", linestyle="--", linewidth=1, alpha=0.65, zorder=1) + ax.text( + text_x, + top_limit, + milestone["description"], + rotation=90, + verticalalignment="top", + horizontalalignment="center", + fontsize=8, + color="#d62728", + ) + ax.set_ylim(ax.get_ylim()[0], top_limit * (1 + max(offset_map.values(), default=0) * 0.02)) + ensure_parent_dir(destination_path) + fig.tight_layout() + fig.savefig(destination_path, dpi=170) + plt.close(fig) + + +def plot_global_shares(heads_path: Path, destination_path: Path, top_limit: int = 10) -> None: + """Trace une vue globale des parts de couleurs de têtes (donut).""" + rows = load_heads_by_year(heads_path) + top_colors = select_top_colors(rows, limit=top_limit) + totals: Dict[Tuple[str, str, str], int] = {} + for row in rows: + key = (row["color_name"], row["color_rgb"], row["is_translucent"]) + totals[key] = totals.get(key, 0) + int(row["quantity"]) + other_total = sum(value for color, value in totals.items() if color not in top_colors) + labels: List[str] = [] + colors_hex: List[str] = [] + edgecolors: List[str] = [] + sizes: List[int] = [] + for name, color_rgb, is_trans in top_colors: + labels.append(name) + colors_hex.append(f"#{color_rgb}") + edgecolors.append("#f2f2f2" if is_trans == "true" else "#0d0d0d") + sizes.append(totals[(name, color_rgb, is_trans)]) + if other_total > 0: + labels.append("Autres") + colors_hex.append("#444444") + edgecolors.append("#0d0d0d") + sizes.append(other_total) + + fig, ax = plt.subplots(figsize=(7, 7)) + wedges, _ = ax.pie( + sizes, + labels=[""] * len(labels), + colors=colors_hex, + startangle=90, + counterclock=False, + wedgeprops={"linewidth": 1.0, "edgecolor": "#0d0d0d"}, + ) + for wedge, edge in zip(wedges, edgecolors): + wedge.set_edgecolor(edge) + total = sum(sizes) + legend_labels = [f"{name} ({size/total:.1%})" for name, size in zip(labels, sizes)] + ax.legend(wedges, legend_labels, loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False) + centre_circle = plt.Circle((0, 0), 0.55, fc="white") + ax.add_artist(centre_circle) + ax.set_title("Répartition globale des couleurs de têtes de minifigs") + ensure_parent_dir(destination_path) + fig.tight_layout() + fig.savefig(destination_path, dpi=170) + plt.close(fig) diff --git a/lib/rebrickable/minifig_heads.py b/lib/rebrickable/minifig_heads.py new file mode 100644 index 0000000..8e79957 --- /dev/null +++ b/lib/rebrickable/minifig_heads.py @@ -0,0 +1,113 @@ +"""Extraction des couleurs de têtes de minifigs.""" + +import csv +from pathlib import Path +from typing import Dict, Iterable, List, Set, Tuple + +from lib.rebrickable.colors_by_set import build_colors_lookup +from lib.rebrickable.stats import read_rows + + +HEAD_CATEGORIES = {"59"} + + +def load_parts_filtered(path: Path) -> List[dict]: + """Charge parts_filtered.csv en mémoire.""" + return read_rows(path) + + +def build_head_part_set(parts_catalog_path: Path) -> Set[str]: + """Sélectionne les références de têtes via leur catégorie.""" + head_parts: Set[str] = set() + with parts_catalog_path.open() as parts_file: + reader = csv.DictReader(parts_file) + for row in reader: + if row["part_cat_id"] in HEAD_CATEGORIES: + head_parts.add(row["part_num"]) + return head_parts + + +def aggregate_head_colors_by_set( + parts_rows: Iterable[dict], + head_parts: Set[str], + colors_lookup: Dict[Tuple[str, str], str], +) -> List[dict]: + """Agrège les quantités de têtes par set et par couleur (hors rechanges).""" + aggregates: Dict[Tuple[str, str, str, str], dict] = {} + for row in parts_rows: + if row["part_num"] not in head_parts: + continue + if row["is_spare"] == "true": + continue + key = (row["set_num"], row["set_id"], row["year"], row["color_rgb"]) + existing = aggregates.get(key) + if existing is None: + aggregates[key] = { + "set_num": row["set_num"], + "set_id": row["set_id"], + "year": row["year"], + "color_rgb": row["color_rgb"], + "is_translucent": row["is_translucent"], + "color_name": colors_lookup[(row["color_rgb"], row["is_translucent"])], + "quantity": 0, + } + existing = aggregates[key] + existing["quantity"] += int(row["quantity_in_set"]) + results = list(aggregates.values()) + results.sort(key=lambda r: (r["set_num"], r["color_name"], r["is_translucent"])) + return results + + +def aggregate_head_colors_by_year(rows: Iterable[dict]) -> List[dict]: + """Regroupe les têtes par année et par couleur.""" + aggregates: Dict[Tuple[str, str, str], dict] = {} + for row in rows: + key = (row["year"], row["color_rgb"], row["is_translucent"]) + existing = aggregates.get(key) + if existing is None: + aggregates[key] = { + "year": row["year"], + "color_rgb": row["color_rgb"], + "is_translucent": row["is_translucent"], + "color_name": row["color_name"], + "quantity": 0, + } + existing = aggregates[key] + existing["quantity"] += int(row["quantity"]) + results = list(aggregates.values()) + results.sort(key=lambda r: (int(r["year"]), r["color_name"], r["is_translucent"])) + return results + + +def write_head_colors_by_set(path: Path, rows: Iterable[dict]) -> None: + """Écrit l'agrégat par set.""" + fieldnames = [ + "set_num", + "set_id", + "year", + "color_rgb", + "is_translucent", + "color_name", + "quantity", + ] + with path.open("w", newline="") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def write_head_colors_by_year(path: Path, rows: Iterable[dict]) -> None: + """Écrit l'agrégat par année.""" + fieldnames = [ + "year", + "color_rgb", + "is_translucent", + "color_name", + "quantity", + ] + with path.open("w", newline="") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) diff --git a/scripts/compute_minifig_heads.py b/scripts/compute_minifig_heads.py new file mode 100644 index 0000000..7708a60 --- /dev/null +++ b/scripts/compute_minifig_heads.py @@ -0,0 +1,35 @@ +"""Analyse des couleurs de têtes de minifigs.""" + +from pathlib import Path + +from lib.rebrickable.colors_by_set import build_colors_lookup +from lib.rebrickable.minifig_heads import ( + aggregate_head_colors_by_set, + aggregate_head_colors_by_year, + build_head_part_set, + load_parts_filtered, + write_head_colors_by_set, + write_head_colors_by_year, +) + + +PARTS_FILTERED_PATH = Path("data/intermediate/parts_filtered.csv") +PARTS_CATALOG_PATH = Path("data/raw/parts.csv") +COLORS_PATH = Path("data/raw/colors.csv") +HEADS_BY_SET_PATH = Path("data/intermediate/minifig_heads_by_set.csv") +HEADS_BY_YEAR_PATH = Path("data/intermediate/minifig_heads_by_year.csv") + + +def main() -> None: + """Construit les agrégats des couleurs de têtes.""" + parts_rows = load_parts_filtered(PARTS_FILTERED_PATH) + head_parts = build_head_part_set(PARTS_CATALOG_PATH) + colors_lookup = build_colors_lookup(COLORS_PATH) + heads_by_set = aggregate_head_colors_by_set(parts_rows, head_parts, colors_lookup) + heads_by_year = aggregate_head_colors_by_year(heads_by_set) + write_head_colors_by_set(HEADS_BY_SET_PATH, heads_by_set) + write_head_colors_by_year(HEADS_BY_YEAR_PATH, heads_by_year) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_parts_data.py b/scripts/download_parts_data.py index 0deb702..ffb7c83 100644 --- a/scripts/download_parts_data.py +++ b/scripts/download_parts_data.py @@ -11,7 +11,8 @@ FILES_TO_DOWNLOAD = [ "parts.csv.gz", "colors.csv.gz", "inventory_minifigs.csv.gz", - "minifigs.csv.gz" + "minifigs.csv.gz", + "part_categories.csv.gz", ] DESTINATION_DIR = Path("data/raw") diff --git a/scripts/plot_minifig_heads.py b/scripts/plot_minifig_heads.py new file mode 100644 index 0000000..c989b90 --- /dev/null +++ b/scripts/plot_minifig_heads.py @@ -0,0 +1,21 @@ +"""Visualisations des couleurs de têtes de minifigs.""" + +from pathlib import Path + +from lib.plots.minifig_heads import plot_global_shares, plot_shares_by_year + + +HEADS_BY_YEAR_PATH = Path("data/intermediate/minifig_heads_by_year.csv") +MILESTONES_PATH = Path("config/milestones.csv") +SHARES_DESTINATION = Path("figures/step16/minifig_heads_shares.png") +GLOBAL_DESTINATION = Path("figures/step16/minifig_heads_global.png") + + +def main() -> None: + """Construit les visuels des couleurs de têtes de minifigs.""" + plot_shares_by_year(HEADS_BY_YEAR_PATH, SHARES_DESTINATION, milestones_path=MILESTONES_PATH) + plot_global_shares(HEADS_BY_YEAR_PATH, GLOBAL_DESTINATION) + + +if __name__ == "__main__": + main() diff --git a/tests/test_downloader.py b/tests/test_downloader.py index 378a9f0..860ed9a 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -62,6 +62,7 @@ def test_download_multiple_rebrickable_files(tmp_path: Path) -> None: "inventory_parts.csv.gz", "parts.csv.gz", "colors.csv.gz", + "part_categories.csv.gz", ] compressed_bodies = {} for file_name in file_names: @@ -82,6 +83,7 @@ def test_download_multiple_rebrickable_files(tmp_path: Path) -> None: tmp_path / "inventory_parts.csv", tmp_path / "parts.csv", tmp_path / "colors.csv", + tmp_path / "part_categories.csv", ] assert len(responses.calls) == len(file_names) for file_name in file_names: diff --git a/tests/test_minifig_heads.py b/tests/test_minifig_heads.py new file mode 100644 index 0000000..15ac236 --- /dev/null +++ b/tests/test_minifig_heads.py @@ -0,0 +1,137 @@ +"""Tests d'extraction des couleurs de têtes de minifigs.""" + +import csv +from pathlib import Path + +from lib.rebrickable.colors_by_set import build_colors_lookup +from lib.rebrickable.minifig_heads import ( + aggregate_head_colors_by_set, + aggregate_head_colors_by_year, + build_head_part_set, + load_parts_filtered, + write_head_colors_by_set, + write_head_colors_by_year, +) + + +def write_csv(path: Path, headers: list[str], rows: list[list[str]]) -> None: + """Écrit un CSV simple pour les besoins des tests.""" + with path.open("w", newline="") as csv_file: + writer = csv.writer(csv_file) + writer.writerow(headers) + writer.writerows(rows) + + +def test_extract_minifig_heads_and_colors(tmp_path: Path) -> None: + """Filtre les têtes de minifigs, agrège par set puis par année.""" + parts_filtered = tmp_path / "parts_filtered.csv" + parts_catalog = tmp_path / "parts.csv" + colors_path = tmp_path / "colors.csv" + heads_by_set_path = tmp_path / "heads_by_set.csv" + heads_by_year_path = tmp_path / "heads_by_year.csv" + + write_csv( + parts_filtered, + [ + "part_num", + "color_rgb", + "is_translucent", + "set_num", + "set_id", + "year", + "quantity_in_set", + "is_spare", + "is_minifig_part", + ], + [ + ["3626b", "FFE1BD", "false", "1000-1", "1000", "2020", "1", "false", "true"], + ["3626bpr1", "E7B68F", "false", "1000-1", "1000", "2020", "1", "false", "true"], + ["3001", "FFFFFF", "false", "1000-1", "1000", "2020", "2", "false", "false"], + ["3626b", "FFE1BD", "false", "2000-1", "2000", "2021", "2", "false", "true"], + ["3626b", "FFE1BD", "false", "2000-1", "2000", "2021", "1", "true", "true"], + ], + ) + write_csv( + parts_catalog, + ["part_num", "name", "part_cat_id", "part_material"], + [ + ["3626b", "Minifig Head", "59", "Plastic"], + ["3626bpr1", "Minifig Head with Print", "59", "Plastic"], + ["3001", "Brick 2 x 4", "11", "Plastic"], + ], + ) + write_csv( + colors_path, + ["id", "name", "rgb", "is_trans", "num_parts", "num_sets", "y1", "y2"], + [ + ["1", "Light Flesh", "FFE1BD", "False", "0", "0", "0", "0"], + ["2", "Medium Dark Flesh", "E7B68F", "False", "0", "0", "0", "0"], + ["3", "White", "FFFFFF", "False", "0", "0", "0", "0"], + ], + ) + + parts_rows = load_parts_filtered(parts_filtered) + head_parts = build_head_part_set(parts_catalog) + colors_lookup = build_colors_lookup(colors_path) + heads_by_set = aggregate_head_colors_by_set(parts_rows, head_parts, colors_lookup) + heads_by_year = aggregate_head_colors_by_year(heads_by_set) + write_head_colors_by_set(heads_by_set_path, heads_by_set) + write_head_colors_by_year(heads_by_year_path, heads_by_year) + + with heads_by_set_path.open() as csv_file: + rows_by_set = list(csv.DictReader(csv_file)) + with heads_by_year_path.open() as csv_file: + rows_by_year = list(csv.DictReader(csv_file)) + + assert rows_by_set == [ + { + "set_num": "1000-1", + "set_id": "1000", + "year": "2020", + "color_rgb": "FFE1BD", + "is_translucent": "false", + "color_name": "Light Flesh", + "quantity": "1", + }, + { + "set_num": "1000-1", + "set_id": "1000", + "year": "2020", + "color_rgb": "E7B68F", + "is_translucent": "false", + "color_name": "Medium Dark Flesh", + "quantity": "1", + }, + { + "set_num": "2000-1", + "set_id": "2000", + "year": "2021", + "color_rgb": "FFE1BD", + "is_translucent": "false", + "color_name": "Light Flesh", + "quantity": "2", + }, + ] + assert rows_by_year == [ + { + "year": "2020", + "color_rgb": "FFE1BD", + "is_translucent": "false", + "color_name": "Light Flesh", + "quantity": "1", + }, + { + "year": "2020", + "color_rgb": "E7B68F", + "is_translucent": "false", + "color_name": "Medium Dark Flesh", + "quantity": "1", + }, + { + "year": "2021", + "color_rgb": "FFE1BD", + "is_translucent": "false", + "color_name": "Light Flesh", + "quantity": "2", + }, + ] diff --git a/tests/test_minifig_heads_plot.py b/tests/test_minifig_heads_plot.py new file mode 100644 index 0000000..0d261cd --- /dev/null +++ b/tests/test_minifig_heads_plot.py @@ -0,0 +1,33 @@ +"""Tests des visualisations des couleurs de têtes de minifigs.""" + +import matplotlib +from pathlib import Path + +from lib.plots.minifig_heads import plot_global_shares, plot_shares_by_year + + +matplotlib.use("Agg") + + +def test_plot_minifig_heads(tmp_path: Path) -> None: + """Produit les graphes part par année et global.""" + heads_path = tmp_path / "minifig_heads_by_year.csv" + milestones_path = tmp_path / "milestones.csv" + shares_dest = tmp_path / "figures" / "step16" / "minifig_heads_shares.png" + global_dest = tmp_path / "figures" / "step16" / "minifig_heads_global.png" + heads_path.write_text( + "year,color_rgb,is_translucent,color_name,quantity\n" + "2020,FFE1BD,false,Light Flesh,2\n" + "2020,FFFF00,false,Yellow,1\n" + "2021,FFE1BD,false,Light Flesh,1\n" + "2021,E7B68F,false,Medium Dark Flesh,3\n" + ) + milestones_path.write_text("year,description\n2020,Jalon Test\n") + + plot_shares_by_year(heads_path, shares_dest, milestones_path=milestones_path) + plot_global_shares(heads_path, global_dest) + + assert shares_dest.exists() + assert shares_dest.stat().st_size > 0 + assert global_dest.exists() + assert global_dest.stat().st_size > 0