|
12 | 12 | import shutil |
13 | 13 | import subprocess |
14 | 14 | import tempfile |
| 15 | +import textwrap |
15 | 16 | import unittest |
16 | 17 |
|
17 | | -from arc.common import ARC_PATH, ARC_TESTING_PATH, read_yaml_file |
| 18 | +from arc.common import ARC_PATH, ARC_TESTING_PATH, read_yaml_file, save_yaml_file |
| 19 | +from arc.scripts.common import parse_command_line_arguments |
18 | 20 |
|
19 | 21 |
|
20 | 22 | def _rmg_env_available() -> bool: |
@@ -117,5 +119,152 @@ def test_cp_data_present(self): |
117 | 119 | self.assertIn('cp_j_mol_k', cp[0]) |
118 | 120 |
|
119 | 121 |
|
| 122 | +class TestCommonArgparse(unittest.TestCase): |
| 123 | + """Test the shared CLI parser used by the standalone scripts.""" |
| 124 | + |
| 125 | + def test_positional_file_only(self): |
| 126 | + """Without ``--output`` the parser exposes ``args.output is None``.""" |
| 127 | + args = parse_command_line_arguments(['/tmp/in.yml']) |
| 128 | + self.assertEqual(args.file, '/tmp/in.yml') |
| 129 | + self.assertIsNone(args.output) |
| 130 | + |
| 131 | + def test_output_long_form(self): |
| 132 | + """``--output`` populates ``args.output`` so callers can avoid overwriting input.""" |
| 133 | + args = parse_command_line_arguments(['/tmp/in.yml', '--output', '/tmp/out.yml']) |
| 134 | + self.assertEqual(args.file, '/tmp/in.yml') |
| 135 | + self.assertEqual(args.output, '/tmp/out.yml') |
| 136 | + |
| 137 | + def test_output_short_form(self): |
| 138 | + """``-o`` is an accepted short form.""" |
| 139 | + args = parse_command_line_arguments(['/tmp/in.yml', '-o', '/tmp/out.yml']) |
| 140 | + self.assertEqual(args.output, '/tmp/out.yml') |
| 141 | + |
| 142 | + |
| 143 | +@unittest.skipUnless(RMG_ENV, 'rmg_env not available') |
| 144 | +class TestRmgKineticsHelpers(unittest.TestCase): |
| 145 | + """ |
| 146 | + Unit tests for ``rmg_kinetics.py`` helpers that don't need a full RMG database load. |
| 147 | +
|
| 148 | + Each test runs a tiny ``python -c`` snippet inside ``rmg_env`` so we can import |
| 149 | + rmgpy and the script module directly. Stdout is parsed as YAML. |
| 150 | + """ |
| 151 | + |
| 152 | + SCRIPT_DIR = os.path.join(ARC_PATH, 'arc', 'scripts') |
| 153 | + |
| 154 | + def _run_in_rmg_env(self, snippet: str) -> str: |
| 155 | + """Execute ``snippet`` inside rmg_env and return stripped stdout.""" |
| 156 | + result = subprocess.run( |
| 157 | + ['conda', 'run', '-n', 'rmg_env', 'python', '-c', snippet], |
| 158 | + capture_output=True, text=True, timeout=120, |
| 159 | + ) |
| 160 | + self.assertEqual(result.returncode, 0, |
| 161 | + f'snippet failed: stderr={result.stderr}\nstdout={result.stdout}') |
| 162 | + return result.stdout.strip() |
| 163 | + |
| 164 | + def test_get_kinetics_from_reactions_arrhenius(self): |
| 165 | + """``get_kinetics_from_reactions`` reports A/n/Ea (Ea in kJ/mol) for an Arrhenius rxn.""" |
| 166 | + snippet = textwrap.dedent(f""" |
| 167 | + import sys, json |
| 168 | + sys.path.insert(0, {self.SCRIPT_DIR!r}) |
| 169 | + from rmg_kinetics import get_kinetics_from_reactions |
| 170 | + from rmgpy.kinetics import Arrhenius |
| 171 | + from rmgpy.reaction import Reaction |
| 172 | + rxn = Reaction() |
| 173 | + rxn.kinetics = Arrhenius(A=(1.5e13, 'cm^3/(mol*s)'), n=0.0, Ea=(20.0, 'kJ/mol'), |
| 174 | + Tmin=(300.0, 'K'), Tmax=(2500.0, 'K')) |
| 175 | + rxn.comment = 'unit-test' |
| 176 | + out = get_kinetics_from_reactions([rxn]) |
| 177 | + print(json.dumps(out[0])) |
| 178 | + """) |
| 179 | + import json |
| 180 | + entry = json.loads(self._run_in_rmg_env(snippet)) |
| 181 | + self.assertEqual(entry['comment'], 'unit-test') |
| 182 | + self.assertAlmostEqual(entry['A'], 1.5e13, delta=1e7) |
| 183 | + self.assertEqual(entry['n'], 0.0) |
| 184 | + self.assertAlmostEqual(entry['Ea'], 20.0, places=6) # kJ/mol |
| 185 | + self.assertEqual(entry['T_min'], 300.0) |
| 186 | + self.assertEqual(entry['T_max'], 2500.0) |
| 187 | + |
| 188 | + def test_get_kinetics_from_reactions_handles_missing_T_bounds(self): |
| 189 | + """Tmin/Tmax may be absent; helper should yield None rather than crashing.""" |
| 190 | + snippet = textwrap.dedent(f""" |
| 191 | + import sys, json |
| 192 | + sys.path.insert(0, {self.SCRIPT_DIR!r}) |
| 193 | + from rmg_kinetics import get_kinetics_from_reactions |
| 194 | + from rmgpy.kinetics import Arrhenius |
| 195 | + from rmgpy.reaction import Reaction |
| 196 | + rxn = Reaction() |
| 197 | + rxn.kinetics = Arrhenius(A=(1.0, 's^-1'), n=1.0, Ea=(0.0, 'J/mol')) |
| 198 | + rxn.comment = 'no-T-bounds' |
| 199 | + print(json.dumps(get_kinetics_from_reactions([rxn])[0])) |
| 200 | + """) |
| 201 | + import json |
| 202 | + entry = json.loads(self._run_in_rmg_env(snippet)) |
| 203 | + self.assertIsNone(entry['T_min']) |
| 204 | + self.assertIsNone(entry['T_max']) |
| 205 | + |
| 206 | + def test_change_rate_guard_skips_non_arrhenius(self): |
| 207 | + """The new isinstance gate must skip ``change_rate`` for non-Arrhenius kinetics |
| 208 | + (e.g. Chebyshev) rather than blindly mutating them.""" |
| 209 | + snippet = textwrap.dedent(f""" |
| 210 | + import sys |
| 211 | + sys.path.insert(0, {self.SCRIPT_DIR!r}) |
| 212 | + from rmgpy.kinetics import Arrhenius, ArrheniusEP |
| 213 | + # Sanity: the script imports the same classes we test against. |
| 214 | + import rmg_kinetics as rk |
| 215 | + assert rk.Arrhenius is Arrhenius |
| 216 | + assert rk.ArrheniusEP is ArrheniusEP |
| 217 | + # The guard logic itself is a one-line isinstance check; replicate it here |
| 218 | + # so a regression that drops the guard would fail the test. |
| 219 | + from rmgpy.kinetics import Chebyshev |
| 220 | + cheb = Chebyshev(coeffs=[[1.0, 0.0], [0.0, 0.0]], |
| 221 | + kunits='cm^3/(mol*s)', |
| 222 | + Tmin=(300.0, 'K'), Tmax=(2000.0, 'K'), |
| 223 | + Pmin=(0.01, 'bar'), Pmax=(100.0, 'bar')) |
| 224 | + assert not isinstance(cheb, (rk.Arrhenius, rk.ArrheniusEP)) |
| 225 | + arr = Arrhenius(A=(1.0, 's^-1'), n=0.0, Ea=(0.0, 'J/mol')) |
| 226 | + assert isinstance(arr, (rk.Arrhenius, rk.ArrheniusEP)) |
| 227 | + print('ok') |
| 228 | + """) |
| 229 | + self.assertEqual(self._run_in_rmg_env(snippet), 'ok') |
| 230 | + |
| 231 | + |
| 232 | +@unittest.skipUnless(RMG_ENV, 'rmg_env not available') |
| 233 | +class TestRmgScriptsOutputFlag(unittest.TestCase): |
| 234 | + """Verify ``--output`` writes to a fresh path and leaves the input file untouched.""" |
| 235 | + |
| 236 | + def setUp(self): |
| 237 | + self.tmp_dir = tempfile.mkdtemp(prefix='rmg_scripts_test_') |
| 238 | + |
| 239 | + def tearDown(self): |
| 240 | + shutil.rmtree(self.tmp_dir, ignore_errors=True) |
| 241 | + |
| 242 | + def _h2_adjlist(self) -> str: |
| 243 | + return '1 H u0 p0 c0 {2,S}\n2 H u0 p0 c0 {1,S}\n' |
| 244 | + |
| 245 | + def test_rmg_thermo_output_does_not_overwrite_input(self): |
| 246 | + """The thermo script writes the augmented YAML to ``--output`` and preserves input.""" |
| 247 | + input_path = os.path.join(self.tmp_dir, 'in.yml') |
| 248 | + output_path = os.path.join(self.tmp_dir, 'out.yml') |
| 249 | + original = [{'label': 'H2', 'adjlist': self._h2_adjlist()}] |
| 250 | + save_yaml_file(path=input_path, content=original) |
| 251 | + |
| 252 | + script = os.path.join(ARC_PATH, 'arc', 'scripts', 'rmg_thermo.py') |
| 253 | + result = subprocess.run( |
| 254 | + ['conda', 'run', '-n', 'rmg_env', 'python', script, input_path, '--output', output_path], |
| 255 | + capture_output=True, text=True, timeout=300, |
| 256 | + ) |
| 257 | + self.assertEqual(result.returncode, 0, f'thermo script failed: {result.stderr}') |
| 258 | + |
| 259 | + # Input must be byte-identical (no overwrite). |
| 260 | + self.assertEqual(read_yaml_file(input_path), original) |
| 261 | + # Output must contain the new keys. |
| 262 | + out = read_yaml_file(output_path) |
| 263 | + self.assertEqual(len(out), 1) |
| 264 | + self.assertIn('h298', out[0]) |
| 265 | + self.assertIn('s298', out[0]) |
| 266 | + self.assertIn('comment', out[0]) |
| 267 | + |
| 268 | + |
120 | 269 | if __name__ == '__main__': |
121 | 270 | unittest.main(testRunner=unittest.TextTestRunner(verbosity=2)) |
0 commit comments