Skip to content

Commit b385eba

Browse files
K20shoresclaude
andcommitted
Add MusicBox.to_config() export and round-trip integration test (#355)
Adds to_config() to serialize an in-memory MusicBox to a v1 JSON-compatible dict so configurations built programmatically can be exported, stored, and reloaded with loadJson() producing bit-for-bit identical results. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent fc780ef commit b385eba

2 files changed

Lines changed: 258 additions & 0 deletions

File tree

python/acom_music_box/music_box.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,64 @@ def mechanism(self):
437437
if self.__mechanism is None:
438438
raise ValueError("Mechanism is not loaded.")
439439
return self.__mechanism
440+
441+
def to_config(self) -> dict:
442+
"""
443+
Export the current box model state as a v1 JSON-compatible dict.
444+
445+
Returns:
446+
dict: A music-box v1 config with 'box model options', 'mechanism',
447+
and 'conditions' sections. Conditions are exported as inline
448+
data blocks (one per time point) so the result is self-contained.
449+
450+
Raises:
451+
ValueError: If the mechanism has not been loaded.
452+
"""
453+
config = {}
454+
455+
# Box model options (all times in seconds for unambiguous round-trip)
456+
config['box model options'] = {
457+
'grid': self.box_model_options.grid,
458+
'chemistry time step [sec]': self.box_model_options.chem_step_time,
459+
'output time step [sec]': self.box_model_options.output_step_time,
460+
'simulation length [sec]': self.box_model_options.simulation_length,
461+
'max iterations': self.box_model_options.max_iterations,
462+
}
463+
464+
# Mechanism — musica serialize() returns a v1-compatible dict
465+
mech_dict = self.mechanism.serialize()
466+
mech_dict['version'] = '1.0.0'
467+
config['mechanism'] = mech_dict
468+
469+
# Conditions — one data block per time point avoids null/sparse issues
470+
raw_df = self._conditions_manager.raw
471+
conc_events = self._conditions_manager.concentration_events
472+
all_times = self._conditions_manager.get_times()
473+
474+
data_blocks = []
475+
for t in all_times:
476+
headers = ['time.s']
477+
values = [float(t)]
478+
479+
# ENV and rate parameters from the sparse DataFrame
480+
time_mask = raw_df['time.s'] == t
481+
if time_mask.any():
482+
row = raw_df[time_mask].iloc[0]
483+
for col in raw_df.columns:
484+
if col == 'time.s':
485+
continue
486+
val = row[col]
487+
if pd.notna(val):
488+
headers.append(col)
489+
values.append(float(val))
490+
491+
# Concentration events (applied at exact time, not interpolated)
492+
if t in conc_events:
493+
for species, value in sorted(conc_events[t].items()):
494+
headers.append(f'CONC.{species}.mol m-3')
495+
values.append(float(value))
496+
497+
data_blocks.append({'headers': headers, 'rows': [values]})
498+
499+
config['conditions'] = {'data': data_blocks}
500+
return config
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
"""
2+
Round-trip export test: build a MusicBox entirely in code with every possible
3+
option, run it, export to v1 JSON via to_config(), reload with loadJson(), run
4+
again, and assert the two result DataFrames are exactly equal.
5+
6+
"Every possible option" covers:
7+
- All five BoxModelOptions fields (grid, chem_step_time, output_step_time,
8+
simulation_length, max_iterations)
9+
- Four reaction types: Arrhenius (all A/B/C/D/E params), Photolysis, Emission,
10+
FirstOrderLoss
11+
- Initial conditions at t=0: temperature, pressure, concentrations for all
12+
species, and all rate-parameter types (PHOTO, EMIS, LOSS)
13+
- Evolving conditions at t=30: temperature, pressure, concentration reset,
14+
and updated rate parameters
15+
"""
16+
17+
import json
18+
import os
19+
import tempfile
20+
21+
import pandas as pd
22+
import pytest
23+
24+
import musica.mechanism_configuration as mc
25+
from acom_music_box import MusicBox
26+
27+
28+
def _build_model():
29+
"""Return a fully-configured MusicBox using only the in-code API."""
30+
A = mc.Species(name="A")
31+
B = mc.Species(name="B")
32+
C = mc.Species(name="C")
33+
D = mc.Species(name="D")
34+
E = mc.Species(name="E")
35+
species_list = [A, B, C, D, E]
36+
gas = mc.Phase(name="gas", species=species_list)
37+
38+
# Arrhenius: all five parameters set
39+
arr1 = mc.Arrhenius(
40+
name="A->B", A=4.0e-3, B=0.0, C=50.0, D=300.0, E=0.0,
41+
reactants=[A], products=[B], gas_phase=gas,
42+
)
43+
arr2 = mc.Arrhenius(
44+
name="B->C", A=1.2e-4, B=2.5, C=75.0, D=50.0, E=0.5,
45+
reactants=[B], products=[C], gas_phase=gas,
46+
)
47+
# Photolysis: requires PHOTO rate parameter
48+
photo = mc.Photolysis(
49+
name="photo_D", scaling_factor=1.0,
50+
reactants=[D], products=[E], gas_phase=gas,
51+
)
52+
# Emission: requires EMIS rate parameter
53+
emis = mc.Emission(
54+
name="emis_A", scaling_factor=1.0,
55+
products=[A], gas_phase=gas,
56+
)
57+
# FirstOrderLoss: requires LOSS rate parameter
58+
loss = mc.FirstOrderLoss(
59+
name="loss_E", scaling_factor=1.0,
60+
reactants=[E], gas_phase=gas,
61+
)
62+
63+
mechanism = mc.Mechanism(
64+
name="all_options_test",
65+
species=species_list,
66+
phases=[gas],
67+
reactions=[arr1, arr2, photo, emis, loss],
68+
)
69+
70+
box = MusicBox()
71+
box.load_mechanism(mechanism)
72+
73+
# All five BoxModelOptions fields
74+
box.box_model_options.grid = "box"
75+
box.box_model_options.chem_step_time = 2.0
76+
box.box_model_options.output_step_time = 6.0
77+
box.box_model_options.simulation_length = 60.0
78+
box.box_model_options.max_iterations = 100
79+
80+
# Initial conditions (t=0): temperature, pressure, all species,
81+
# and all rate-parameter types
82+
(box
83+
.set_condition(
84+
time=0.0,
85+
temperature=298.15,
86+
pressure=101325.0,
87+
concentrations={"A": 1.0, "B": 0.0, "C": 0.0, "D": 0.5, "E": 0.0},
88+
rate_parameters={
89+
"PHOTO.photo_D.s-1": 1.0e-4,
90+
"EMIS.emis_A.mol m-3 s-1": 1.0e-8,
91+
"LOSS.loss_E.s-1": 1.0e-3,
92+
},
93+
)
94+
# Evolving conditions (t=30): temperature, pressure, concentration reset,
95+
# and updated rate parameters
96+
.set_condition(
97+
time=30.0,
98+
temperature=300.0,
99+
pressure=101000.0,
100+
concentrations={"D": 0.3},
101+
rate_parameters={
102+
"PHOTO.photo_D.s-1": 2.0e-4,
103+
"EMIS.emis_A.mol m-3 s-1": 5.0e-9,
104+
"LOSS.loss_E.s-1": 2.0e-3,
105+
},
106+
))
107+
108+
return box
109+
110+
111+
class TestExportRoundTrip:
112+
113+
def test_to_config_structure(self):
114+
"""to_config() produces a well-formed v1 config dict."""
115+
box = _build_model()
116+
config = box.to_config()
117+
118+
assert "box model options" in config
119+
assert "mechanism" in config
120+
assert "conditions" in config
121+
122+
opts = config["box model options"]
123+
assert opts["grid"] == "box"
124+
assert opts["chemistry time step [sec]"] == 2.0
125+
assert opts["output time step [sec]"] == 6.0
126+
assert opts["simulation length [sec]"] == 60.0
127+
assert opts["max iterations"] == 100
128+
129+
mech = config["mechanism"]
130+
assert mech["version"] == "1.0.0"
131+
reaction_types = {r["type"] for r in mech["reactions"]}
132+
assert reaction_types == {"ARRHENIUS", "PHOTOLYSIS", "EMISSION", "FIRST_ORDER_LOSS"}
133+
134+
conds = config["conditions"]
135+
assert "data" in conds
136+
# Two time points → two data blocks
137+
assert len(conds["data"]) == 2
138+
139+
# t=0 block must contain all condition types
140+
t0_block = conds["data"][0]
141+
assert "CONC.A.mol m-3" in t0_block["headers"]
142+
assert "CONC.D.mol m-3" in t0_block["headers"]
143+
assert "ENV.temperature.K" in t0_block["headers"]
144+
assert "ENV.pressure.Pa" in t0_block["headers"]
145+
assert "PHOTO.photo_D.s-1" in t0_block["headers"]
146+
assert "EMIS.emis_A.mol m-3 s-1" in t0_block["headers"]
147+
assert "LOSS.loss_E.s-1" in t0_block["headers"]
148+
149+
# t=30 block must contain updated evolving conditions
150+
t30_block = conds["data"][1]
151+
assert "ENV.temperature.K" in t30_block["headers"]
152+
assert "CONC.D.mol m-3" in t30_block["headers"]
153+
assert "PHOTO.photo_D.s-1" in t30_block["headers"]
154+
155+
def test_round_trip_results_exactly_equal(self):
156+
"""
157+
Solve in code, export, reload via loadJson, solve again.
158+
Both result DataFrames must be exactly equal (bit-for-bit).
159+
"""
160+
# --- Step 1: build and solve in code ---
161+
box1 = _build_model()
162+
df1 = box1.solve()
163+
164+
# --- Step 2: export to v1 JSON dict ---
165+
config = box1.to_config()
166+
167+
# Verify it is JSON-serializable (no NaN/inf that would break JSON)
168+
config_json = json.dumps(config)
169+
170+
# --- Step 3: reload from exported JSON ---
171+
with tempfile.NamedTemporaryFile(
172+
mode="w", suffix=".json", delete=False
173+
) as f:
174+
f.write(config_json)
175+
tmp_path = f.name
176+
177+
try:
178+
box2 = MusicBox()
179+
box2.loadJson(tmp_path)
180+
df2 = box2.solve()
181+
finally:
182+
os.unlink(tmp_path)
183+
184+
# --- Step 4: compare exactly equal ---
185+
assert df1.shape == df2.shape, (
186+
f"Result shapes differ: {df1.shape} vs {df2.shape}"
187+
)
188+
189+
# Sort columns so ordering differences don't cause false failures
190+
df1_sorted = df1.reindex(sorted(df1.columns), axis=1)
191+
df2_sorted = df2.reindex(sorted(df2.columns), axis=1)
192+
193+
pd.testing.assert_frame_equal(
194+
df1_sorted, df2_sorted,
195+
check_exact=True,
196+
obj="solve() results after export/reload round-trip",
197+
)

0 commit comments

Comments
 (0)