diff --git a/t3/pdep/parser.py b/t3/pdep/parser.py index 8cf3eaebd..0e0bffbe1 100644 --- a/t3/pdep/parser.py +++ b/t3/pdep/parser.py @@ -1320,6 +1320,21 @@ def _call_keywords(call: ast.Call, path: str = '', call_name: Optional[str] = No # these calls. Mapping positions to names would mean duplicating Arkane's signatures here and # keeping them in step with it forever, which is a bigger promise than refusing a spelling nothing # writes. + # + # THE ONE EXCEPTION -- ``transitionState(label, path)``. Arkane's ``transitionState(label, *args, + # **kwargs)`` (arkane/input.py:241) loads a stat-mech file from a path ONLY through its + # ``len(args) == 1 and len(kwargs) == 0`` branch, i.e. the two-positional-argument spelling + # ``transitionState('TS2', 'qm/TS2.py')``; there is NO keyword spelling for it + # (``transitionState(label='TS2', path='qm/TS2.py')`` silently stops loading the file). T3's own + # hybrid writer (``t3/pdep/hybrid.py``) must therefore emit exactly that positional form when it + # vendors a QM-adopted transition state, and this reader -- the same file's other consumer -- has to + # read it back, so refusing it crashes T3 on a file T3 itself wrote. Map that single form (label, + # path) to keywords and nothing wider: any OTHER positional shape (one, or three-plus, positionals; + # positionals mixed with keywords) and this same two-positional form for any OTHER call name still + # fall through to the refusal below. The corpus survey the refusal rests on never contained T3's own + # writer's output, which is why this producer/consumer disagreement was invisible until a real run. + if call_name == 'transitionState' and len(call.args) == 2 and not call.keywords: + return {'label': call.args[0], 'path': call.args[1]} if call.args: raise ValueError(f"The pdep network file at '{path}' calls " f"'{call_name or _get_call_name(call) or ''}(...)' with " diff --git a/tests/data/pdep_hybrid/positional_ts/README.md b/tests/data/pdep_hybrid/positional_ts/README.md new file mode 100644 index 000000000..bdd686b7b --- /dev/null +++ b/tests/data/pdep_hybrid/positional_ts/README.md @@ -0,0 +1,21 @@ +# `positional_ts/network0_reduced.py` + +A byte-for-byte copy (sha256 +`46c152d162e789fcb003d4a79af57fa96eec6a07ca5e0df69757855ea4f43d96`) of the hybrid network file the +PES exploration loop wrote in round 0 of the `r001_m1-cho2-pilot` run and then crashed reading in +round 1. T3's own hybrid writer (`t3/pdep/hybrid.py`) produced it; the copy is vendored here because +the run tree under `/home/alon/runs/` is scratch and will be pruned. + +Its load-bearing line is: + +```python +transitionState('TS2', 'qm/TS2.py') +``` + +TS2 was adopted from quantum chemistry, so the writer rewrites it to the two-positional +`transitionState(label, path)` spelling — the ONLY form Arkane (`arkane/input.py:241`, +`if len(args) == 1 and len(kwargs) == 0`) accepts for loading a stat-mech file from a path; there is +no keyword spelling for it. The pdep parser's `_call_keywords` refused every positional argument and +crashed on this file. `tests/test_pdep/test_parser.py::TestPositionalTransitionState` drives this +file through `parse_pdep_network_file` and asserts the parser recovers both TS2's label and its path. +Do not regenerate or "clean up" this file: its value is that it is exactly what a real run emitted. diff --git a/tests/data/pdep_hybrid/positional_ts/network0_reduced.py b/tests/data/pdep_hybrid/positional_ts/network0_reduced.py new file mode 100644 index 000000000..2d26cfead --- /dev/null +++ b/tests/data/pdep_hybrid/positional_ts/network0_reduced.py @@ -0,0 +1,166 @@ +modelChemistry = LevelOfTheory(method='wb97xd2023',basis='def2tzvp',software='gaussian') +frequencyScaleFactor = 0.988 +useHinderedRotors = True +useBondCorrections = False +atomEnergies = {'Br': -2574.174533595486, 'C': -37.84706210301937, 'Cl': -460.1467876783656, 'F': -99.73955550924293, 'H': -0.5006557872395249, 'N': -54.584995947182875, 'O': -75.07252406126821, 'S': -398.1105530401693} +useAtomCorrections = True + +species( + label = 'O=[C]O(8)', + structure = adjacencyList("""multiplicity 2 +1 O u0 p2 c0 {3,S} {4,S} +2 O u0 p2 c0 {3,D} +3 C u1 p0 c0 {1,S} {2,D} +4 H u0 p0 c0 {1,S} +"""), + E0 = (-184.487,'kJ/mol'), + modes = [ + HarmonicOscillator(frequencies=([1855,455,950,607.278,1696.14],'cm^-1')), + HinderedRotor(inertia=(0.000457043,'amu*angstrom^2'), symmetry=1, barrier=(0.119627,'kJ/mol'), semiclassical=False), + ], + spinMultiplicity = 2, + opticalIsomers = 1, + molecularWeight = (45.0174,'amu'), + collisionModel = TransportData(shapeIndex=2, epsilon=(4140.61,'J/mol'), sigma=(3.59,'angstroms'), dipoleMoment=(0,'De'), polarizability=(0,'angstroms^3'), rotrelaxcollnum=2.0, comment="""NOx2018"""), + energyTransferModel = SingleExponentialDown(alpha0=(3.5886,'kJ/mol'), T0=(300,'K'), n=0.85), + thermo = NASA(polynomials=[NASAPolynomial(coeffs=[3.65821,0.00206077,2.03288e-05,-2.91283e-08,1.14311e-11,-22171.5,8.26011], Tmin=(100,'K'), Tmax=(964.009,'K')), NASAPolynomial(coeffs=[7.09694,0.0016118,-4.75818e-07,1.29874e-10,-1.30513e-14,-23476.6,-11.5343], Tmin=(964.009,'K'), Tmax=(5000,'K'))], Tmin=(100,'K'), Tmax=(5000,'K'), E0=(-184.487,'kJ/mol'), Cp0=(33.2579,'J/(mol*K)'), CpInf=(78.9875,'J/(mol*K)'), comment="""Thermo group additivity estimation: group(O2s-(Cds-O2d)H) + group(Cds-OdOsH) + radical((O)CJOH)"""), +) + +species( + label = '[O]C=O(1)', + structure = adjacencyList("""multiplicity 2 +1 O u1 p2 c0 {3,S} +2 O u0 p2 c0 {3,D} +3 C u0 p0 c0 {1,S} {2,D} {4,S} +4 H u0 p0 c0 {3,S} +"""), + E0 = (-169.935,'kJ/mol'), + modes = [ + HarmonicOscillator(frequencies=([2782.5,750,1395,475,1775,1000],'cm^-1')), + ], + spinMultiplicity = 2, + opticalIsomers = 1, + molecularWeight = (45.0174,'amu'), + collisionModel = TransportData(shapeIndex=2, epsilon=(4140.61,'J/mol'), sigma=(3.59,'angstroms'), dipoleMoment=(0,'De'), polarizability=(0,'angstroms^3'), rotrelaxcollnum=2.0, comment="""NOx2018"""), + energyTransferModel = SingleExponentialDown(alpha0=(3.5886,'kJ/mol'), T0=(300,'K'), n=0.85), + thermo = NASA(polynomials=[NASAPolynomial(coeffs=[3.9649,-0.00518829,3.73952e-05,-4.21919e-08,1.47224e-11,-20431.8,7.33591], Tmin=(100,'K'), Tmax=(994.515,'K')), NASAPolynomial(coeffs=[5.26528,0.00541264,-2.4716e-06,5.38764e-10,-4.28526e-14,-21473.4,-2.86661], Tmin=(994.515,'K'), Tmax=(5000,'K'))], Tmin=(100,'K'), Tmax=(5000,'K'), E0=(-169.935,'kJ/mol'), Cp0=(33.2579,'J/(mol*K)'), CpInf=(83.1447,'J/(mol*K)'), comment="""Thermo group additivity estimation: group(O2s-(Cds-O2d)H) + group(Cds-OdOsH) + radical(OJC=O)"""), +) + +species( + label = '[H](6)', + structure = adjacencyList("""multiplicity 2 +1 H u1 p0 c0 +"""), + E0 = (211.805,'kJ/mol'), + spinMultiplicity = 1, + opticalIsomers = 1, + molecularWeight = (1.00797,'amu'), + energyTransferModel = SingleExponentialDown(alpha0=(3.5886,'kJ/mol'), T0=(300,'K'), n=0.85), + thermo = NASA(polynomials=[NASAPolynomial(coeffs=[2.5,-3.01681e-12,3.74582e-15,-1.50857e-18,1.86626e-22,25474.2,-0.444973], Tmin=(100,'K'), Tmax=(4879.8,'K')), NASAPolynomial(coeffs=[4.28461,-0.00145495,4.44804e-07,-6.0436e-11,3.07922e-15,23723.1,-11.8931], Tmin=(4879.8,'K'), Tmax=(5000,'K'))], Tmin=(100,'K'), Tmax=(5000,'K'), E0=(211.805,'kJ/mol'), Cp0=(20.7862,'J/(mol*K)'), CpInf=(20.7862,'J/(mol*K)'), label="""H""", comment="""Thermo library: primaryThermoLibrary"""), +) + +species( + label = 'O=C=O(5)', + structure = adjacencyList("""1 O u0 p2 c0 {3,D} +2 O u0 p2 c0 {3,D} +3 C u0 p0 c0 {1,D} {2,D} +"""), + E0 = (-403.087,'kJ/mol'), + modes = [ + HarmonicOscillator(frequencies=([459.922,1087.69,1087.69,2296.71],'cm^-1')), + ], + spinMultiplicity = 1, + opticalIsomers = 1, + molecularWeight = (44.0094,'amu'), + energyTransferModel = SingleExponentialDown(alpha0=(3.5886,'kJ/mol'), T0=(300,'K'), n=0.85), + thermo = NASA(polynomials=[NASAPolynomial(coeffs=[3.27862,0.00274142,7.16109e-06,-1.08032e-08,4.14302e-12,-48470.3,5.97934], Tmin=(100,'K'), Tmax=(988.879,'K')), NASAPolynomial(coeffs=[4.54606,0.00291919,-1.15487e-06,2.27661e-10,-1.70916e-14,-48980.3,-1.43257], Tmin=(988.879,'K'), Tmax=(5000,'K'))], Tmin=(100,'K'), Tmax=(5000,'K'), E0=(-403.087,'kJ/mol'), Cp0=(29.1007,'J/(mol*K)'), CpInf=(62.3585,'J/(mol*K)'), comment="""Thermo group additivity estimation: missing(O2d-Cdd) + missing(O2d-Cdd) + group(Cdd-OdOd)"""), +) + +species( + label = 'Ar', + structure = adjacencyList("""1 Ar u0 p4 c0 +"""), + molecularWeight = (39.8775,'amu'), + collisionModel = TransportData(shapeIndex=0, epsilon=(1134.93,'J/mol'), sigma=(3.33,'angstroms'), dipoleMoment=(0,'De'), polarizability=(0,'angstroms^3'), rotrelaxcollnum=0.0, comment="""NOx2018"""), + energyTransferModel = SingleExponentialDown(alpha0=(3.5886,'kJ/mol'), T0=(300,'K'), n=0.85), + thermo = NASA(polynomials=[NASAPolynomial(coeffs=[2.5,0,0,0,0,-745.375,4.37967], Tmin=(200,'K'), Tmax=(1000,'K')), NASAPolynomial(coeffs=[2.5,0,0,0,0,-745.375,4.37967], Tmin=(1000,'K'), Tmax=(6000,'K'))], Tmin=(200,'K'), Tmax=(6000,'K'), E0=(-6.19738,'kJ/mol'), Cp0=(20.7862,'J/(mol*K)'), CpInf=(20.7862,'J/(mol*K)'), label="""Ar""", comment="""Thermo library: primaryThermoLibrary"""), +) + +transitionState( + label = 'TS1', + E0 = (21.347 - 191.282, "kJ/mol"), # removing the applied energy_correction + spinMultiplicity = 1, + opticalIsomers = 1, +) + +transitionState('TS2', 'qm/TS2.py') + +transitionState( + label = 'TS3', + E0 = (30.706 - 191.282, "kJ/mol"), # removing the applied energy_correction + spinMultiplicity = 1, + opticalIsomers = 1, +) + +reaction( + label = 'reaction3', + reactants = ['[H](6)', 'O=C=O(5)'], + products = ['[O]C=O(1)'], + transitionState = 'TS1', + kinetics = Arrhenius(A=(7699.21,'m^3/(mol*s)'), n=1.33054, Ea=(21.3474,'kJ/mol'), T0=(1,'K'), comment="""Estimated using template [Cdd_Od;HJ] for rate rule [CO2;HJ] +Euclidian distance = 1.0 +family: R_Addition_MultipleBond +Ea raised from 17.5 to 21.3 kJ/mol to match endothermicity of reaction."""), +) + +reaction( + label = 'reaction5', + reactants = ['[O]C=O(1)'], + products = ['O=[C]O(8)'], + transitionState = 'TS2', + tunneling = 'Eckart', +) + +reaction( + label = 'reaction8', + reactants = ['[H](6)', 'O=C=O(5)'], + products = ['O=[C]O(8)'], + transitionState = 'TS3', + kinetics = Arrhenius(A=(2.37e+08,'cm^3/(mol*s)'), n=1.63, Ea=(30.7064,'kJ/mol'), T0=(1,'K'), Tmin=(300,'K'), Tmax=(1700,'K'), comment="""Estimated using template [Od_R;HJ] for rate rule [Od_Cdd-O2d;HJ] +Euclidian distance = 2.0 +Multiplied by reaction path degeneracy 2.0 +family: R_Addition_MultipleBond"""), +) + +network( + label = 'PDepNetwork #1', + isomers = [ + 'O=[C]O(8)', + '[O]C=O(1)', + ], + reactants = [ + ], + bathGas = { + 'Ar': 1, + }, +) + +pressureDependence( + label = 'PDepNetwork #1', + Tmin = (700,'K'), + Tmax = (3200,'K'), + Tcount = 10, + Tlist = ([3200,2290.91,1784.07,1460.87,1236.81,1072.34,946.479,847.059,766.54,700],'K'), + Pmin = (0.1,'bar'), + Pmax = (100,'bar'), + Pcount = 10, + Plist = ([0.1,0.215443,0.464159,1,2.15443,4.64159,10,21.5443,46.4159,100],'bar'), + maximumGrainSize = (2,'kJ/mol'), + minimumGrainCount = 250, + method = 'modified strong collision', + interpolationModel = ('pdeparrhenius',), + activeKRotor = True, + activeJRotor = True, + rmgmode = True, +) + diff --git a/tests/test_pdep/test_parser.py b/tests/test_pdep/test_parser.py index 252a0cb6e..3e039b0ec 100644 --- a/tests/test_pdep/test_parser.py +++ b/tests/test_pdep/test_parser.py @@ -1348,3 +1348,76 @@ def test_end_to_end_a_real_energy_corrected_ts_yields_a_relative_e0(self): assert ts.label == 'TS1' assert ts.relative_e0 is not None assert ts.relative_e0 == pytest.approx((264.497 - 411.735) - 0.0) + + +# The hybrid network file the PES loop wrote in round 0 of the r001_m1-cho2-pilot run and then +# crashed reading in round 1. Vendored byte-for-byte because /home/alon/runs/ is scratch; see +# tests/data/pdep_hybrid/positional_ts/README.md for provenance and the sha256. +HYBRID_POSITIONAL_TS_FILE = os.path.join( + TEST_DATA_BASE_PATH, 'pdep_hybrid', 'positional_ts', 'network0_reduced.py' +) + + +def _transition_state_call(text: str, label: str) -> ast.Call: + """Return the top-level ``transitionState(...)`` call whose FIRST positional argument is the + string literal ``label``, from real pdep-network file text. Used to hand ``_call_keywords`` the + exact AST node a real file produced, rather than one a test hand-built.""" + for node in ast.parse(text).body: + if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)): + continue + call = node.value + if (isinstance(call.func, ast.Name) and call.func.id == 'transitionState' + and call.args and isinstance(call.args[0], ast.Constant) + and call.args[0].value == label): + return call + raise AssertionError(f"no positional transitionState({label!r}, ...) call in the text") + + +class TestPositionalTransitionState: + """The producer/consumer bug I-016: T3's hybrid writer emits ``transitionState('TS2', + 'qm/TS2.py')`` -- the two-positional form Arkane (arkane/input.py:241) requires to load a + stat-mech file from a path, and the ONLY form it offers for that -- while the pdep reader + refused every positional argument and crashed on the file T3 itself had just written.""" + + def test_real_hybrid_file_parses(self): + """Verifier 1: the real crashed artifact drives through the pdep parser and parses.""" + network = parse_pdep_network_file(HYBRID_POSITIONAL_TS_FILE) + # Verifier 2a -- the label is recovered through the full public parse, not merely + # "no exception raised": TS2 (the QM-adopted, positionally-written state) is present + # alongside the keyword-written TS1 and TS3. + assert 'TS2' in network.transition_state_labels + assert set(network.transition_state_labels) == {'TS1', 'TS2', 'TS3'} + + def test_real_hybrid_file_recovers_ts2_label_and_path(self): + """Verifier 2b: the parse recovers BOTH the transition state's label AND its path from the + positional call, rather than silently discarding the path a keyword reader never mapped.""" + with open(HYBRID_POSITIONAL_TS_FILE, 'r') as f: + text = f.read() + call = _transition_state_call(text, 'TS2') + kwargs = _call_keywords(call, path=HYBRID_POSITIONAL_TS_FILE, call_name='transitionState') + assert set(kwargs) == {'label', 'path'} + assert ast.literal_eval(kwargs['label']) == 'TS2' + assert ast.literal_eval(kwargs['path']) == 'qm/TS2.py' + + def test_positional_exception_is_narrow_other_call_names_still_refused(self): + """Non-goal guard: the widening is exactly ``transitionState(label, path)``. The same + two-positional shape for any OTHER call name still falls through to the refusal.""" + (call,) = [n.value for n in ast.parse("species('X', 'qm/X.py')").body] + with pytest.raises(ValueError, match='POSITIONAL'): + _call_keywords(call, call_name='species') + + def test_positional_exception_is_narrow_other_arities_still_refused(self): + """Non-goal guard: only two positionals map. One, or three, positional argument(s) to a + ``transitionState(...)`` call is not the Arkane path form and is still refused.""" + for src in ("transitionState('TS2')", + "transitionState('TS2', 'qm/TS2.py', 'extra')"): + (call,) = [n.value for n in ast.parse(src).body] + with pytest.raises(ValueError, match='POSITIONAL'): + _call_keywords(call, call_name='transitionState') + + def test_positional_exception_is_narrow_mixed_positional_keyword_refused(self): + """Non-goal guard: a positional label mixed with a keyword is NOT the Arkane path form + (Arkane loads a path only when ``len(kwargs) == 0``) and is still refused.""" + (call,) = [n.value for n in ast.parse("transitionState('TS2', path='qm/TS2.py')").body] + with pytest.raises(ValueError, match='POSITIONAL'): + _call_keywords(call, call_name='transitionState')