Skip to content

Commit 01b97c1

Browse files
K20shoresclaude
andcommitted
Add MusicBox.export() and integration test (#355)
Adds export() to serialize an in-memory MusicBox to a v1 JSON-compatible file 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 01b97c1

2 files changed

Lines changed: 251 additions & 0 deletions

File tree

python/acom_music_box/music_box.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,68 @@ def mechanism(self):
437437
if self.__mechanism is None:
438438
raise ValueError("Mechanism is not loaded.")
439439
return self.__mechanism
440+
441+
def export(self, path_to_json: str):
442+
"""
443+
Export the current box model state to a v1 JSON file.
444+
445+
The exported file is self-contained: conditions are written as inline
446+
data blocks (one per time point) so no external CSV files are needed.
447+
The file can be reloaded with loadJson() to reproduce identical results.
448+
449+
Args:
450+
path_to_json: Path to write the JSON file.
451+
452+
Raises:
453+
ValueError: If the mechanism has not been loaded.
454+
"""
455+
config = {}
456+
457+
# Box model options (all times in seconds for unambiguous round-trip)
458+
config['box model options'] = {
459+
'grid': self.box_model_options.grid,
460+
'chemistry time step [sec]': self.box_model_options.chem_step_time,
461+
'output time step [sec]': self.box_model_options.output_step_time,
462+
'simulation length [sec]': self.box_model_options.simulation_length,
463+
'max iterations': self.box_model_options.max_iterations,
464+
}
465+
466+
# Mechanism — musica serialize() returns a v1-compatible dict
467+
mech_dict = self.mechanism.serialize()
468+
mech_dict['version'] = '1.0.0'
469+
config['mechanism'] = mech_dict
470+
471+
# Conditions — one data block per time point avoids null/sparse issues
472+
raw_df = self._conditions_manager.raw
473+
conc_events = self._conditions_manager.concentration_events
474+
all_times = self._conditions_manager.get_times()
475+
476+
data_blocks = []
477+
for t in all_times:
478+
headers = ['time.s']
479+
values = [float(t)]
480+
481+
# ENV and rate parameters from the sparse DataFrame
482+
time_mask = raw_df['time.s'] == t
483+
if time_mask.any():
484+
row = raw_df[time_mask].iloc[0]
485+
for col in raw_df.columns:
486+
if col == 'time.s':
487+
continue
488+
val = row[col]
489+
if pd.notna(val):
490+
headers.append(col)
491+
values.append(float(val))
492+
493+
# Concentration events (applied at exact time, not interpolated)
494+
if t in conc_events:
495+
for species, value in sorted(conc_events[t].items()):
496+
headers.append(f'CONC.{species}.mol m-3')
497+
values.append(float(value))
498+
499+
data_blocks.append({'headers': headers, 'rows': [values]})
500+
501+
config['conditions'] = {'data': data_blocks}
502+
503+
with open(path_to_json, 'w') as f:
504+
json.dump(config, f, indent=2)
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
Round-trip export test: build a MusicBox entirely in code with every possible
3+
option, run it, export to a v1 JSON file via export(), reload with loadJson(),
4+
run 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+
19+
import pandas as pd
20+
21+
import musica.mechanism_configuration as mc
22+
from acom_music_box import MusicBox
23+
24+
25+
def _build_model():
26+
"""Return a fully-configured MusicBox using only the in-code API."""
27+
A = mc.Species(name="A")
28+
B = mc.Species(name="B")
29+
C = mc.Species(name="C")
30+
D = mc.Species(name="D")
31+
E = mc.Species(name="E")
32+
species_list = [A, B, C, D, E]
33+
gas = mc.Phase(name="gas", species=species_list)
34+
35+
# Arrhenius: all five parameters set
36+
arr1 = mc.Arrhenius(
37+
name="A->B", A=4.0e-3, B=0.0, C=50.0, D=300.0, E=0.0,
38+
reactants=[A], products=[B], gas_phase=gas,
39+
)
40+
arr2 = mc.Arrhenius(
41+
name="B->C", A=1.2e-4, B=2.5, C=75.0, D=50.0, E=0.5,
42+
reactants=[B], products=[C], gas_phase=gas,
43+
)
44+
# Photolysis: requires PHOTO rate parameter
45+
photo = mc.Photolysis(
46+
name="photo_D", scaling_factor=1.0,
47+
reactants=[D], products=[E], gas_phase=gas,
48+
)
49+
# Emission: requires EMIS rate parameter
50+
emis = mc.Emission(
51+
name="emis_A", scaling_factor=1.0,
52+
products=[A], gas_phase=gas,
53+
)
54+
# FirstOrderLoss: requires LOSS rate parameter
55+
loss = mc.FirstOrderLoss(
56+
name="loss_E", scaling_factor=1.0,
57+
reactants=[E], gas_phase=gas,
58+
)
59+
60+
mechanism = mc.Mechanism(
61+
name="all_options_test",
62+
species=species_list,
63+
phases=[gas],
64+
reactions=[arr1, arr2, photo, emis, loss],
65+
)
66+
67+
box = MusicBox()
68+
box.load_mechanism(mechanism)
69+
70+
# All five BoxModelOptions fields
71+
box.box_model_options.grid = "box"
72+
box.box_model_options.chem_step_time = 2.0
73+
box.box_model_options.output_step_time = 6.0
74+
box.box_model_options.simulation_length = 60.0
75+
box.box_model_options.max_iterations = 100
76+
77+
# Initial conditions (t=0): temperature, pressure, all species,
78+
# and all rate-parameter types
79+
(box
80+
.set_condition(
81+
time=0.0,
82+
temperature=298.15,
83+
pressure=101325.0,
84+
concentrations={"A": 1.0, "B": 0.0, "C": 0.0, "D": 0.5, "E": 0.0},
85+
rate_parameters={
86+
"PHOTO.photo_D.s-1": 1.0e-4,
87+
"EMIS.emis_A.mol m-3 s-1": 1.0e-8,
88+
"LOSS.loss_E.s-1": 1.0e-3,
89+
},
90+
)
91+
# Evolving conditions (t=30): temperature, pressure, concentration reset,
92+
# and updated rate parameters
93+
.set_condition(
94+
time=30.0,
95+
temperature=300.0,
96+
pressure=101000.0,
97+
concentrations={"D": 0.3},
98+
rate_parameters={
99+
"PHOTO.photo_D.s-1": 2.0e-4,
100+
"EMIS.emis_A.mol m-3 s-1": 5.0e-9,
101+
"LOSS.loss_E.s-1": 2.0e-3,
102+
},
103+
))
104+
105+
return box
106+
107+
108+
class TestExportRoundTrip:
109+
110+
def test_export_file_structure(self, tmp_path):
111+
"""export() writes a well-formed v1 JSON file."""
112+
box = _build_model()
113+
config_file = tmp_path / "config.json"
114+
box.export(str(config_file))
115+
with open(config_file) as f:
116+
config = json.load(f)
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, tmp_path):
156+
"""
157+
Solve in code, export to file, 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 JSON file ---
165+
config_file = tmp_path / "config.json"
166+
box1.export(str(config_file))
167+
168+
# --- Step 3: reload from exported file ---
169+
box2 = MusicBox()
170+
box2.loadJson(str(config_file))
171+
df2 = box2.solve()
172+
173+
# --- Step 4: compare exactly equal ---
174+
assert df1.shape == df2.shape, (
175+
f"Result shapes differ: {df1.shape} vs {df2.shape}"
176+
)
177+
178+
# Sort columns so ordering differences don't cause false failures
179+
df1_sorted = df1.reindex(sorted(df1.columns), axis=1)
180+
df2_sorted = df2.reindex(sorted(df2.columns), axis=1)
181+
182+
pd.testing.assert_frame_equal(
183+
df1_sorted, df2_sorted,
184+
check_exact=True,
185+
obj="solve() results after export/reload round-trip",
186+
)

0 commit comments

Comments
 (0)