diff --git a/docs/examples.md b/docs/examples.md index d3c0cf98..533a4d29 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -245,9 +245,10 @@ must be no greater than 10 s, independent of the time-axis origin. History-driven RCM points require separate `initial_temperature` and `initial_pressure` quantities, using the existing temperature/pressure units. -This additive shape preserves the meaning of `temperature` and `pressure` in -every existing file: for RCM they still describe the measured compressed state, -not the state at the beginning of compression. The ideal-gas reactor instead +Their `temperature` and `pressure` fields are optional stated labels for the +measured compressed state; when supplied, they retain that meaning rather than +describing the state at the beginning of compression. Both fields remain +required for every point without `volume_history`. The ideal-gas reactor instead starts at the explicitly supplied initial state and the first history volume. A unit-area wall drives the exact forward slope of the linearly interpolated volume, with integration boundaries at every history point. After the history diff --git a/docs/input_reference.md b/docs/input_reference.md index b2b00048..820eb350 100644 --- a/docs/input_reference.md +++ b/docs/input_reference.md @@ -130,10 +130,13 @@ t3: # An unversioned experimental file retains the legacy citation + data[{T, P, phi, idt}] # format and nearest-grid comparison under idt_criterion. A version: 1 file instead contains - # points with explicit temperature/pressure/IDT units, a normalized list of SMILES mole + # points with explicit IDT units, a normalized list of SMILES mole # fractions, apparatus ('shock tube' or 'rapid compression machine'), ignition_definition, # optional uncertainty, and source {doi, record}. Each point is simulated directly at its - # own state. Supported targets: pressure, temperature, OH, OH*, CH, CH*. Supported types: + # own state. Temperature and pressure are required unless a point has volume_history, where + # initial_temperature and initial_pressure are required and temperature/pressure are optional + # stated compressed-state labels. Supported targets: pressure, temperature, OH, OH*, CH, CH*. + # Supported types: # 'd/dt max', 'max', '1/2 max', 'd/dt max extrapolated'. Missing target species are refused; # no ground-state or radical proxy is substituted. A target event that has not resolved by # the integration horizon is also refused rather than scored from a truncated trace. See diff --git a/t3/schema.py b/t3/schema.py index aaad8db7..6e23fb11 100644 --- a/t3/schema.py +++ b/t3/schema.py @@ -347,8 +347,8 @@ class ExperimentalIDTPoint(BaseModel): History-driven integration ends no later than 10 s after end of compression, so its total elapsed duration is bounded by 20 s. History-free points are unchanged. """ - temperature: ExperimentalTemperature - pressure: ExperimentalPressure + temperature: ExperimentalTemperature | None = None + pressure: ExperimentalPressure | None = None composition: list[ExperimentalCompositionEntry] apparatus: ExperimentalApparatusEnum ignition_definition: ExperimentalIgnitionDefinition @@ -374,6 +374,8 @@ def volume_history_horizon(self) -> float: def validate_volume_history_state(self): """Keep existing post-compression state fields unchanged for all RCM points.""" if self.volume_history is None: + if self.temperature is None or self.pressure is None: + raise ValueError('temperature and pressure are required without a volume_history') if self.initial_temperature is not None or self.initial_pressure is not None: raise ValueError('initial_temperature and initial_pressure require a volume_history') return self diff --git a/t3/simulate/cantera_idt.py b/t3/simulate/cantera_idt.py index 1d831b6d..53e6cbb9 100644 --- a/t3/simulate/cantera_idt.py +++ b/t3/simulate/cantera_idt.py @@ -918,8 +918,8 @@ def _compare_versioned_experiment(self, exp: dict) -> dict: criterion = raw_point['ignition_definition'] experimental_idt = convert_time_to_seconds(point.idt.value, point.idt.units) comparison = { - 'temperature': raw_point['temperature'], - 'pressure': raw_point['pressure'], + 'temperature': raw_point.get('temperature'), + 'pressure': raw_point.get('pressure'), 'composition': raw_point['composition'], 'apparatus': raw_point['apparatus'], 'idt_exp': experimental_idt, diff --git a/tests/test_simulate/test_experimental_idt.py b/tests/test_simulate/test_experimental_idt.py index fd6e02c7..3cf3fa23 100644 --- a/tests/test_simulate/test_experimental_idt.py +++ b/tests/test_simulate/test_experimental_idt.py @@ -86,6 +86,26 @@ def test_rcm_volume_history_schema_accepts_units_and_initial_states(): assert parsed.temperature.value == 1400.0 +def test_rcm_volume_history_schema_allows_absent_compressed_state_labels(): + point = _rcm_history_point() + del point['temperature'] + del point['pressure'] + + parsed = _parsed_rcm_point(point) + + assert parsed.temperature is None + assert parsed.pressure is None + + +@pytest.mark.parametrize('field', ['temperature', 'pressure']) +def test_history_free_point_requires_compressed_state_labels(field): + point = _point() + del point[field] + + with pytest.raises(ValidationError, match='temperature and pressure are required without a volume_history'): + ExperimentalIDTFile.model_validate({'version': 1, 'points': [point]}) + + @pytest.mark.parametrize('mutation, message', [ ({'time': {'values': [0.0], 'units': 's'}, 'volume': {'values': [1.0], 'units': 'm3'}}, 'at least two'), ({'time': {'values': [0.0, 1.0], 'units': 's'}}, 'equal length'), @@ -260,10 +280,26 @@ def test_rcm_volume_history_reactive_ignition_is_relative_to_compression(): result = adapter._compare_versioned_experiment({'version': 1, 'points': [_rcm_history_point()]}) assert result['n_matched'] == 1 assert result['points'][0]['idt_sim'] == pytest.approx(expected, rel=1e-9) + assert result['points'][0]['temperature'] == {'value': 1400.0, 'units': 'K'} + assert result['points'][0]['pressure'] == {'value': 5.0, 'units': 'bar'} assert expected > 0 assert np.max(history.T) > 2000 +def test_rcm_volume_history_simulates_without_compressed_state_labels(): + adapter = _adapter() + point = _rcm_history_point() + del point['temperature'] + del point['pressure'] + + result = adapter._compare_versioned_experiment({'version': 1, 'points': [point]}) + + assert result['n_matched'] == 1 + assert result['points'][0]['idt_sim'] > 0 + assert result['points'][0]['temperature'] is None + assert result['points'][0]['pressure'] is None + + def test_rcm_volume_history_ignition_does_not_depend_on_time_origin(): adapter = _adapter() point = _rcm_history_point()