Skip to content

Commit a0031d1

Browse files
results: add scenario PNG figures (O2/Water) via deterministic generator
1 parent e7557b5 commit a0031d1

File tree

1 file changed

+129
-135
lines changed

1 file changed

+129
-135
lines changed

scripts/generate_figures.py

Lines changed: 129 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,143 @@
1+
from __future__ import annotations
2+
13
import json
2-
import re
4+
import math
5+
import os
36
from pathlib import Path
4-
import dataclasses
7+
from typing import Any, Iterable, List, Optional, Tuple
8+
9+
# Headless backend (no GUI)
10+
import matplotlib
11+
matplotlib.use("Agg", force=True)
512

6-
import matplotlib.pyplot as plt
13+
from matplotlib.figure import Figure
14+
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
15+
from matplotlib.ticker import NullLocator
716

8-
from models.model import Scenario, simulate
17+
RESULTS_DIR = Path("results")
918

10-
REPO = Path(__file__).resolve().parents[1]
11-
RESULTS = REPO / "results"
19+
def _is_num(x: Any) -> bool:
20+
if isinstance(x, (int, float)) and not isinstance(x, bool):
21+
return math.isfinite(float(x))
22+
return False
1223

13-
def _is_numeric_series(x):
14-
if not isinstance(x, (list, tuple)):
24+
def _looks_like_series(v: Any) -> bool:
25+
if not isinstance(v, list):
1526
return False
16-
if len(x) < 5:
27+
if len(v) < 3:
1728
return False
18-
# allow ints/floats
19-
for v in x[:5]:
20-
if not isinstance(v, (int, float)):
21-
return False
22-
return True
23-
24-
def _pick_time_axis(res):
25-
# Prefer explicit time arrays if present
26-
for k in ["t_days", "t", "time_days", "days"]:
27-
if k in res and _is_numeric_series(res[k]):
28-
return k, res[k]
29-
# Otherwise infer from any series length
30-
for k, v in res.items():
31-
if _is_numeric_series(v):
32-
return "index_days", list(range(len(v)))
33-
return None, None
34-
35-
def _pick_series(res, kind):
36-
# kind in {"o2","water"}
37-
# Strong preference: keys that contain kind and look like series/stock
38-
cand = []
39-
for k, v in res.items():
40-
if not _is_numeric_series(v):
41-
continue
42-
lk = k.lower()
43-
score = 0
44-
if kind == "o2":
45-
if "o2" in lk or "oxygen" in lk:
46-
score += 5
47-
else:
48-
if "water" in lk or "h2o" in lk:
49-
score += 5
50-
if "series" in lk:
51-
score += 3
52-
if "stock" in lk or "store" in lk:
53-
score += 2
54-
if "days" in lk:
55-
score += 1
56-
if score > 0:
57-
cand.append((score, k, v))
58-
if cand:
59-
cand.sort(reverse=True, key=lambda t: t[0])
60-
return cand[0][1], cand[0][2]
61-
62-
# Fallback: choose any numeric series with semantic hints
63-
for k, v in res.items():
64-
if not _is_numeric_series(v):
65-
continue
66-
lk = k.lower()
67-
if kind == "o2" and ("o2" in lk or "oxygen" in lk):
68-
return k, v
69-
if kind == "water" and ("water" in lk or "h2o" in lk):
70-
return k, v
71-
72-
return None, None
73-
74-
def _scenario_from_json(path):
75-
data = json.loads(path.read_text(encoding="utf-8"))
76-
# allow either {"scenario": {...}} or direct kwargs
77-
if isinstance(data, dict) and "scenario" in data and isinstance(data["scenario"], dict):
78-
kwargs = dict(data["scenario"])
79-
elif isinstance(data, dict):
80-
kwargs = dict(data)
81-
else:
82-
raise ValueError("Scenario JSON is not a dict.")
83-
84-
allowed = {f.name for f in dataclasses.fields(Scenario)}
85-
unknown = sorted([k for k in kwargs.keys() if k not in allowed])
86-
if unknown:
87-
print(f"[generate_figures] Dropping unknown Scenario keys in {path.name}: {unknown}")
88-
kwargs = {k: v for k, v in kwargs.items() if k in allowed}
89-
90-
return Scenario(**kwargs)
91-
92-
def main():
93-
RESULTS.mkdir(parents=True, exist_ok=True)
94-
95-
scenario_files = sorted(RESULTS.glob("S*_*.json"))
96-
if not scenario_files:
97-
raise SystemExit("No scenario JSON files found under results/ (expected S*_*.json).")
98-
99-
made = 0
100-
for f in scenario_files:
101-
sc = _scenario_from_json(f)
102-
res = simulate(sc)
103-
104-
if not isinstance(res, dict):
105-
print(f"[generate_figures] simulate() did not return dict for {f.name}; skipping.")
29+
# must be mostly numeric
30+
nums = sum(1 for x in v if _is_num(x))
31+
return nums >= int(0.9 * len(v))
32+
33+
def _walk(obj: Any, prefix: str = "") -> Iterable[Tuple[str, Any]]:
34+
if isinstance(obj, dict):
35+
for k, v in obj.items():
36+
p = f"{prefix}.{k}" if prefix else str(k)
37+
yield (p, v)
38+
yield from _walk(v, p)
39+
elif isinstance(obj, list):
40+
for i, v in enumerate(obj):
41+
p = f"{prefix}[{i}]"
42+
yield (p, v)
43+
yield from _walk(v, p)
44+
45+
def _pick_series(candidates: List[Tuple[str, List[float]]], prefer_terms: List[str]) -> Optional[Tuple[str, List[float]]]:
46+
# deterministic: stable sort by (priority, -len, key)
47+
scored = []
48+
for k, s in candidates:
49+
k_low = k.lower()
50+
pr = min([prefer_terms.index(t) for t in prefer_terms if t in k_low], default=10_000)
51+
scored.append((pr, -len(s), k, s))
52+
scored.sort(key=lambda x: (x[0], x[1], x[2]))
53+
if not scored:
54+
return None
55+
_, _, k, s = scored[0]
56+
return (k, s)
57+
58+
def _safe_plot_series(out_png: Path, title: str, y: List[float]) -> None:
59+
out_png.parent.mkdir(parents=True, exist_ok=True)
60+
61+
fig = Figure(figsize=(10, 4), dpi=140)
62+
canvas = FigureCanvas(fig)
63+
64+
ax = fig.add_subplot(111)
65+
66+
# CRÍTICO: eliminar ticks/locators para esquivar el bug de deepcopy en Py3.14 + mpl (ticks crean MarkerStyle)
67+
ax.xaxis.set_major_locator(NullLocator())
68+
ax.yaxis.set_major_locator(NullLocator())
69+
ax.tick_params(bottom=False, left=False, labelbottom=False, labelleft=False)
70+
71+
# Plot
72+
x = list(range(len(y)))
73+
ax.plot(x, y)
74+
75+
# Etiquetas "manuales" (sin ticks)
76+
fig.text(0.01, 0.98, title, ha="left", va="top")
77+
fig.text(0.01, 0.02, f"n={len(y)} min={min(y):.3g} max={max(y):.3g}", ha="left", va="bottom")
78+
79+
fig.tight_layout(rect=[0, 0.05, 1, 0.92])
80+
canvas.draw()
81+
fig.savefig(out_png)
82+
83+
def _load_json(path: Path) -> Any:
84+
return json.loads(path.read_text(encoding="utf-8"))
85+
86+
def generate() -> int:
87+
if not RESULTS_DIR.exists():
88+
raise SystemExit("Missing results/ directory. Run: python -m scripts.run_scenarios")
89+
90+
json_files = sorted(RESULTS_DIR.glob("S*.json"))
91+
if not json_files:
92+
raise SystemExit("No scenario JSON files found (results/S*.json).")
93+
94+
wrote = 0
95+
96+
for jf in json_files:
97+
obj = _load_json(jf)
98+
99+
series_candidates: List[Tuple[str, List[float]]] = []
100+
for k, v in _walk(obj):
101+
if _looks_like_series(v):
102+
series_candidates.append((k, [float(x) for x in v]))
103+
104+
# Si NO hay series numéricas, reporta claves reales para debugging (determinista)
105+
if not series_candidates:
106+
keys = []
107+
if isinstance(obj, dict):
108+
keys = sorted(list(obj.keys()))
109+
print(f"[generate_figures] {jf.name}: NO numeric series found. top-level keys={keys}")
106110
continue
107111

108-
tk, t = _pick_time_axis(res)
109-
if t is None:
110-
print(f"[generate_figures] No numeric time axis inferred for {f.name}; skipping.")
112+
# Selección determinista por prioridad textual
113+
o2_pick = _pick_series(series_candidates, prefer_terms=["o2", "oxygen"])
114+
w_pick = _pick_series(series_candidates, prefer_terms=["water", "h2o"])
115+
116+
# Si no hay match semántico, NO adivinamos: reportamos candidatos y seguimos
117+
if o2_pick is None or w_pick is None:
118+
print(f"[generate_figures] {jf.name}: cannot select o2/water deterministically.")
119+
print(" candidates:")
120+
for k, s in sorted(series_candidates, key=lambda x: x[0])[:50]:
121+
print(f" - {k} (len={len(s)})")
111122
continue
112123

113-
o2k, o2 = _pick_series(res, "o2")
114-
wk, w = _pick_series(res, "water")
115-
116-
stem = f.stem # e.g., S1_baseline
117-
if o2 is not None:
118-
out = RESULTS / f"{stem}_o2.png"
119-
plt.figure()
120-
plt.plot(t, o2)
121-
plt.xlabel("Days")
122-
plt.ylabel("O2 stock (model units)")
123-
plt.title(f"{stem} - O2 ({o2k})")
124-
plt.tight_layout()
125-
plt.savefig(out, dpi=200)
126-
plt.close()
127-
made += 1
128-
print(f"[generate_figures] Wrote {out.name}")
129-
130-
if w is not None:
131-
out = RESULTS / f"{stem}_water.png"
132-
plt.figure()
133-
plt.plot(t, w)
134-
plt.xlabel("Days")
135-
plt.ylabel("Water stock (model units)")
136-
plt.title(f"{stem} - Water ({wk})")
137-
plt.tight_layout()
138-
plt.savefig(out, dpi=200)
139-
plt.close()
140-
made += 1
141-
print(f"[generate_figures] Wrote {out.name}")
142-
143-
if o2 is None and w is None:
144-
print(f"[generate_figures] No O2/Water series detected in simulate() output for {f.name} (keys: {list(res.keys())}).")
145-
146-
print(f"[generate_figures] Total figures written: {made}")
124+
base = jf.stem
125+
o2_key, o2_series = o2_pick
126+
w_key, w_series = w_pick
127+
128+
out_o2 = RESULTS_DIR / f"{base}_o2.png"
129+
out_w = RESULTS_DIR / f"{base}_water.png"
130+
131+
_safe_plot_series(out_o2, f"{base} :: O2 (source={o2_key})", o2_series)
132+
_safe_plot_series(out_w, f"{base} :: Water (source={w_key})", w_series)
133+
134+
print(f"[generate_figures] wrote: {out_o2.as_posix()} {out_w.as_posix()}")
135+
wrote += 2
136+
137+
return wrote
147138

148139
if __name__ == "__main__":
149-
main()
140+
n = generate()
141+
if n == 0:
142+
raise SystemExit("No PNGs created. Either no numeric series exist in results/S*.json or selection could not be deterministic.")
143+
print(f"OK: created {n} PNGs")

0 commit comments

Comments
 (0)