|
| 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