11"""
2- Round-trip export test: build a MusicBox entirely in code with every possible
2+ Round-trip export test: build a MusicBox entirely in code with every supported
33option, run it, export to a v1 JSON file via export(), reload with loadJson(),
44run again, and assert the two result DataFrames are exactly equal.
55
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
6+ Covers all five BoxModelOptions fields and one of each supported reaction type:
7+ - Arrhenius (all A/B/C/D/E params)
8+ - Troe (k0_A/B/C, kinf_A/B/C, Fc, N)
9+ - Branched (X, Y, a0, n)
10+ - TernaryChemicalActivation (same params as Troe)
11+ - Tunneling (A, B, C)
12+ - Photolysis (scaling_factor) — requires PHOTO rate parameter
13+ - Emission (scaling_factor) — requires EMIS rate parameter
14+ - FirstOrderLoss (scaling_factor) — requires LOSS rate parameter
15+ - UserDefined (scaling_factor) — requires USER rate parameter
16+ - Surface (reaction_probability) — requires SURF particle concentration
17+ and effective radius rate parameters
18+
19+ Initial conditions at t=0 set temperature, pressure, concentrations for every
20+ species, and all user-defined rate-parameter types (PHOTO, EMIS, LOSS, USER, SURF).
21+ Evolving conditions at t=30 reset temperature, pressure, one species concentration,
22+ and all rate parameters to new values.
1523"""
1624
1725import json
2432
2533def _build_model ():
2634 """Return a fully-configured MusicBox using only the in-code API."""
35+ # Surface-reactive species requires molecular weight and a diffusion
36+ # coefficient on the gas-phase entry.
37+ srf = mc .Species (name = "Srf" , molecular_weight_kg_mol = 0.029 )
2738 A = mc .Species (name = "A" )
2839 B = mc .Species (name = "B" )
2940 C = mc .Species (name = "C" )
3041 D = mc .Species (name = "D" )
3142 E = mc .Species (name = "E" )
32- species_list = [A , B , C , D , E ]
33- gas = mc .Phase (name = "gas" , species = species_list )
43+ F = mc .Species (name = "F" )
44+ G = mc .Species (name = "G" )
45+ H = mc .Species (name = "H" )
46+ I = mc .Species (name = "I" )
47+ J = mc .Species (name = "J" )
48+ all_species = [srf , A , B , C , D , E , F , G , H , I , J ]
3449
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 ,
50+ gas = mc .Phase (
51+ name = "gas" ,
52+ species = [
53+ mc .PhaseSpecies (srf .name , diffusion_coefficient_m2_s = 1e-5 ),
54+ A , B , C , D , E , F , G , H , I , J ,
55+ ],
5856 )
5957
58+ reactions = [
59+ # Arrhenius: all five kinetic parameters
60+ mc .Arrhenius (
61+ name = "arr1" , A = 4.0e-3 , B = 0.5 , C = 50.0 , D = 300.0 , E = 0.1 ,
62+ reactants = [A ], products = [B ], gas_phase = gas ,
63+ ),
64+ # Troe: pressure-dependent falloff
65+ mc .Troe (
66+ name = "troe1" ,
67+ k0_A = 1.0e-28 , k0_B = - 3.0 , k0_C = 0.0 ,
68+ kinf_A = 1.0e-11 , kinf_B = 0.0 , kinf_C = 0.0 ,
69+ Fc = 0.45 , N = 1.0 ,
70+ reactants = [B ], products = [C ], gas_phase = gas ,
71+ ),
72+ # Branched: two product channels; n must be an integer
73+ mc .Branched (
74+ name = "branched1" , X = 1.2e-3 , Y = 1.0 , a0 = 0.15 , n = 9 ,
75+ reactants = [C ],
76+ nitrate_products = [D ],
77+ alkoxy_products = [E ],
78+ gas_phase = gas ,
79+ ),
80+ # TernaryChemicalActivation: three-body activation
81+ mc .TernaryChemicalActivation (
82+ name = "tca1" ,
83+ k0_A = 1.0e-28 , k0_B = - 3.0 , k0_C = 0.0 ,
84+ kinf_A = 1.0e-11 , kinf_B = 0.0 , kinf_C = 0.0 ,
85+ Fc = 0.45 , N = 1.0 ,
86+ reactants = [D ], products = [F ], gas_phase = gas ,
87+ ),
88+ # Tunneling: quantum tunneling correction
89+ mc .Tunneling (
90+ name = "tunnel1" , A = 1.0e-3 , B = 100.0 , C = 200.0 ,
91+ reactants = [E ], products = [G ], gas_phase = gas ,
92+ ),
93+ # Photolysis: rate set by PHOTO.photo1 condition
94+ mc .Photolysis (
95+ name = "photo1" , scaling_factor = 1.0 ,
96+ reactants = [F ], products = [H ], gas_phase = gas ,
97+ ),
98+ # Emission: rate set by EMIS.emis1 condition
99+ mc .Emission (
100+ name = "emis1" , scaling_factor = 1.0 ,
101+ products = [I ], gas_phase = gas ,
102+ ),
103+ # FirstOrderLoss: rate set by LOSS.loss1 condition
104+ mc .FirstOrderLoss (
105+ name = "loss1" , scaling_factor = 1.0 ,
106+ reactants = [H ], gas_phase = gas ,
107+ ),
108+ # UserDefined: rate set by USER.ud1 condition
109+ mc .UserDefined (
110+ name = "ud1" , scaling_factor = 1.0 ,
111+ reactants = [I ], products = [J ], gas_phase = gas ,
112+ ),
113+ # Surface: heterogeneous reaction; rates set by SURF.surf1 conditions
114+ mc .Surface (
115+ name = "surf1" , reaction_probability = 0.1 ,
116+ gas_phase_species = srf , gas_phase_products = [A ], gas_phase = gas ,
117+ ),
118+ ]
119+
60120 mechanism = mc .Mechanism (
61121 name = "all_options_test" ,
62- species = species_list ,
122+ species = all_species ,
63123 phases = [gas ],
64- reactions = [ arr1 , arr2 , photo , emis , loss ] ,
124+ reactions = reactions ,
65125 )
66126
67127 box = MusicBox ()
@@ -74,83 +134,105 @@ def _build_model():
74134 box .box_model_options .simulation_length = 60.0
75135 box .box_model_options .max_iterations = 100
76136
77- # Initial conditions (t=0): temperature, pressure, all species,
78- # and all rate-parameter types
137+ # Initial conditions (t=0): temperature, pressure, all species
138+ # concentrations, and every user-defined rate-parameter type
79139 (box
80140 .set_condition (
81141 time = 0.0 ,
82142 temperature = 298.15 ,
83143 pressure = 101325.0 ,
84- concentrations = {"A" : 1.0 , "B" : 0.0 , "C" : 0.0 , "D" : 0.5 , "E" : 0.0 },
144+ concentrations = {
145+ "Srf" : 1.0 ,
146+ "A" : 1.0 , "B" : 0.0 , "C" : 0.0 , "D" : 0.0 ,
147+ "E" : 0.0 , "F" : 0.5 , "G" : 0.0 , "H" : 0.0 ,
148+ "I" : 0.5 , "J" : 0.0 ,
149+ },
85150 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 ,
151+ "PHOTO.photo1.s-1" : 1.0e-4 ,
152+ "EMIS.emis1.mol m-3 s-1" : 1.0e-8 ,
153+ "LOSS.loss1.s-1" : 1.0e-3 ,
154+ "USER.ud1.s-1" : 1.0e-5 ,
155+ "SURF.surf1.particle number concentration.# m-3" : 1.0e12 ,
156+ "SURF.surf1.effective radius.m" : 1.0e-7 ,
89157 },
90158 )
91- # Evolving conditions (t=30): temperature, pressure, concentration reset ,
92- # and updated rate parameters
159+ # Evolving conditions (t=30): updated temperature, pressure,
160+ # concentration reset, and all rate parameters changed
93161 .set_condition (
94162 time = 30.0 ,
95163 temperature = 300.0 ,
96164 pressure = 101000.0 ,
97- concentrations = {"D " : 0.3 },
165+ concentrations = {"A " : 0.5 },
98166 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 ,
167+ "PHOTO.photo1.s-1" : 2.0e-4 ,
168+ "EMIS.emis1.mol m-3 s-1" : 5.0e-9 ,
169+ "LOSS.loss1.s-1" : 2.0e-3 ,
170+ "USER.ud1.s-1" : 2.0e-5 ,
171+ "SURF.surf1.particle number concentration.# m-3" : 5.0e11 ,
172+ "SURF.surf1.effective radius.m" : 2.0e-7 ,
102173 },
103174 ))
104175
105176 return box
106177
107178
179+ EXPECTED_REACTION_TYPES = {
180+ "ARRHENIUS" , "TROE" , "BRANCHED_NO_RO2" , "TERNARY_CHEMICAL_ACTIVATION" ,
181+ "TUNNELING" , "PHOTOLYSIS" , "EMISSION" , "FIRST_ORDER_LOSS" ,
182+ "USER_DEFINED" , "SURFACE" ,
183+ }
184+
185+
108186class TestExportRoundTrip :
109187
110188 def test_export_file_structure (self , tmp_path ):
111- """export() writes a well-formed v1 JSON file."""
189+ """export() writes a well-formed v1 JSON file with every option ."""
112190 box = _build_model ()
113191 config_file = tmp_path / "config.json"
114192 box .export (str (config_file ))
115193 with open (config_file ) as f :
116194 config = json .load (f )
117195
118- assert "box model options" in config
119- assert "mechanism" in config
120- assert "conditions" in config
121-
196+ # Box model options
122197 opts = config ["box model options" ]
123198 assert opts ["grid" ] == "box"
124199 assert opts ["chemistry time step [sec]" ] == 2.0
125200 assert opts ["output time step [sec]" ] == 6.0
126201 assert opts ["simulation length [sec]" ] == 60.0
127202 assert opts ["max iterations" ] == 100
128203
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" ]
204+ # Mechanism contains every reaction type
205+ assert config ["mechanism" ]["version" ] == "1.0.0"
206+ exported_types = {r ["type" ] for r in config ["mechanism" ]["reactions" ]}
207+ assert exported_types == EXPECTED_REACTION_TYPES
208+
209+ # Conditions: two time-point blocks
210+ blocks = config ["conditions" ]["data" ]
211+ assert len (blocks ) == 2
212+
213+ # t=0 block: all condition types present
214+ t0_headers = set (blocks [0 ]["headers" ])
215+ assert "ENV.temperature.K" in t0_headers
216+ assert "ENV.pressure.Pa" in t0_headers
217+ assert "CONC.Srf.mol m-3" in t0_headers
218+ assert "CONC.A.mol m-3" in t0_headers
219+ assert "PHOTO.photo1.s-1" in t0_headers
220+ assert "EMIS.emis1.mol m-3 s-1" in t0_headers
221+ assert "LOSS.loss1.s-1" in t0_headers
222+ assert "USER.ud1.s-1" in t0_headers
223+ assert "SURF.surf1.particle number concentration.# m-3" in t0_headers
224+ assert "SURF.surf1.effective radius.m" in t0_headers
225+
226+ # t=30 block: all rate-parameter types updated
227+ t30_headers = set (blocks [1 ]["headers" ])
228+ assert "ENV.temperature.K" in t30_headers
229+ assert "CONC.A.mol m-3" in t30_headers
230+ assert "PHOTO.photo1.s-1" in t30_headers
231+ assert "EMIS.emis1.mol m-3 s-1" in t30_headers
232+ assert "LOSS.loss1.s-1" in t30_headers
233+ assert "USER.ud1.s-1" in t30_headers
234+ assert "SURF.surf1.particle number concentration.# m-3" in t30_headers
235+ assert "SURF.surf1.effective radius.m" in t30_headers
154236
155237 def test_round_trip_results_exactly_equal (self , tmp_path ):
156238 """
@@ -175,7 +257,7 @@ def test_round_trip_results_exactly_equal(self, tmp_path):
175257 f"Result shapes differ: { df1 .shape } vs { df2 .shape } "
176258 )
177259
178- # Sort columns so ordering differences don't cause false failures
260+ # Sort columns to handle any ordering differences
179261 df1_sorted = df1 .reindex (sorted (df1 .columns ), axis = 1 )
180262 df2_sorted = df2 .reindex (sorted (df2 .columns ), axis = 1 )
181263
0 commit comments