1+ from __future__ import annotations
2+
13import json
2- import re
4+ import math
5+ import os
36from 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
148139if __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