diff --git a/imas_standard_names/grammar/ir.py b/imas_standard_names/grammar/ir.py index 59289f41..d53f2d00 100644 --- a/imas_standard_names/grammar/ir.py +++ b/imas_standard_names/grammar/ir.py @@ -172,6 +172,7 @@ class LocusType(StrEnum): "per_toroidal_and_poloidal_mode_number", "per_toroidal_mode", "perturbed", + "root_mean_square", "surface_integrated", "time_averaged", "volume_averaged", diff --git a/imas_standard_names/grammar/model_types.py b/imas_standard_names/grammar/model_types.py index 4da0bdd6..d9160dbe 100644 --- a/imas_standard_names/grammar/model_types.py +++ b/imas_standard_names/grammar/model_types.py @@ -600,6 +600,11 @@ class Position(StrEnum): RECTANGLE_CENTER = "rectangle_center" ANNULUS_CENTER = "annulus_center" ARC_OF_CIRCLE_CENTER = "arc_of_circle_center" + REFLECTOMETER_CUTOFF_POSITION = "reflectometer_cutoff_position" + CHARGE_EXCHANGE_CHANNEL = "charge_exchange_channel" + THOMSON_SCATTERING_CHANNEL_POSITION = "thomson_scattering_channel_position" + LANGMUIR_PROBE_POSITION = "langmuir_probe_position" + CALORIMETRY_COMPONENT = "calorimetry_component" ACTIVE_SPATIAL_RESOLUTION_ZONE = "active_spatial_resolution_zone" ACTIVE_WALL_POINT = "active_wall_point" BEAM_PATH = "beam_path" diff --git a/imas_standard_names/grammar/parser.py b/imas_standard_names/grammar/parser.py index ae693c88..d6c0deee 100644 --- a/imas_standard_names/grammar/parser.py +++ b/imas_standard_names/grammar/parser.py @@ -675,6 +675,7 @@ def _peel_outer_operator( for name, meta in v.operators.items() if meta.get("kind") == OperatorKind.BINARY.value } + reductions = _reduction_prefix_operators(v) # a) unary postfix: s ends with "_", longest op first. A postfix at the # tail of an explicit prefix or binary form belongs to that operator's @@ -701,8 +702,17 @@ def _peel_outer_operator( if prefix_match is not None: new_s = s[len(prefix_match) + len("_of_") :] if new_s: + # A _of_-authored domain reduction converges on the bare leading + # spelling: the reduction collapses the whole operand, so the + # joiner would state the same scope under a second canonical + # string. Carry the bare flag so every spelling of one quantity + # shares a single representation and one canonical form. return ( - OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op=prefix_match), + OperatorApplication( + kind=OperatorKind.UNARY_PREFIX, + op=prefix_match, + bare_prefix=prefix_match in reductions, + ), new_s, [], ) diff --git a/imas_standard_names/grammar/render.py b/imas_standard_names/grammar/render.py index 3998e6d5..70a6da99 100644 --- a/imas_standard_names/grammar/render.py +++ b/imas_standard_names/grammar/render.py @@ -299,6 +299,19 @@ def _has_repositioning_tail(ir: StandardNameIR) -> bool: return ir.locus is not None and ir.locus.relation.value in {"of", "at"} +# The accumulation family (accumulated, cumulative, cumulative_inside_flux_surface) +# are bare-prefix tokens that modify the quantity as a whole and lead their name. +# They are NOT relocated between base and locus the way a joiner-taking operator +# is: the relocated spelling (``neutral_count_accumulated_at_wall``) would fold +# the aggregation into the base group and contradict the leading canonical form. +# Sibling reductions already lead via the operator stack; these stay qualifiers +# and simply keep their leading seat, so ``_leading_bare_operators`` must not +# extract them into the relocatable operator-qualifier bucket. +_ACCUMULATION_LEADING_QUALIFIERS: frozenset[str] = frozenset( + {"accumulated", "cumulative", "cumulative_inside_flux_surface"} +) + + def _leading_bare_operators( ir: StandardNameIR, ) -> tuple[list[Qualifier], list[Qualifier]]: @@ -306,7 +319,11 @@ def _leading_bare_operators( operator_qualifiers: list[Qualifier] = [] ordinary_qualifiers = list(ir.qualifiers) - while ordinary_qualifiers and ordinary_qualifiers[0].token in BARE_PREFIX_OPERATORS: + while ( + ordinary_qualifiers + and ordinary_qualifiers[0].token in BARE_PREFIX_OPERATORS + and ordinary_qualifiers[0].token not in _ACCUMULATION_LEADING_QUALIFIERS + ): operator_qualifiers.append(ordinary_qualifiers.pop(0)) return operator_qualifiers, ordinary_qualifiers diff --git a/imas_standard_names/grammar/vocabularies/locus_registry.yml b/imas_standard_names/grammar/vocabularies/locus_registry.yml index 11dc7270..78113672 100644 --- a/imas_standard_names/grammar/vocabularies/locus_registry.yml +++ b/imas_standard_names/grammar/vocabularies/locus_registry.yml @@ -646,7 +646,10 @@ loci: measurement_position: type: position allowed_relations: [at, of] - definition: The spatial location identified as the measurement position in the relevant plasma, diagnostic, or plant context. + definition: >- + The position in the cylindrical frame at which a diagnostic channel + samples, used for the coordinates of that position where the coordinate + serves many instrument families. midplane: type: position allowed_relations: [at, of] @@ -842,6 +845,45 @@ loci: allowed_relations: [at, of] definition: The center of curvature of a circular arc. + # ── diagnostic measurement-destination loci ───────────────────────── + # Per-instrument sampling positions: the locations at which individual + # diagnostic channels measure. Distinct from the generic + # measurement_position locus, which survives only for the shared coordinate + # triple that many instrument families evaluate; a quantity whose + # instrument is known carries that instrument's own locus instead. Each + # accepts both _of_ (a claim about the location as an object) and _at_ + # (a field value evaluated there). + reflectometer_cutoff_position: + type: position + allowed_relations: [of, at] + definition: >- + The plasma layer at which a reflectometer channel samples: where the + probing frequency meets the local cutoff, or for Doppler reflectometry + the backscatter resonance condition. + charge_exchange_channel: + type: position + allowed_relations: [of, at] + definition: >- + The sample volume of a charge-exchange recombination spectroscopy + channel, where beam-induced charge-exchange emission is collected. + thomson_scattering_channel_position: + type: position + allowed_relations: [of, at] + definition: The scattering volume assigned to one Thomson-scattering channel. + langmuir_probe_position: + type: position + allowed_relations: [of, at] + definition: >- + The location at which a Langmuir probe tip samples the local plasma, + embedded or reciprocating. Distinct from langmuir_probe, the probe + itself as an entity. + calorimetry_component: + type: position + allowed_relations: [of, at] + definition: >- + A discrete element of a calorimetry group whose extracted thermal + energy and power are measured. + # ==================================================================== # Geometry-typed loci (shape/region boundary: _at_, _of_, _over_) # ==================================================================== diff --git a/tests/grammar/test_reduction_over_binary.py b/tests/grammar/test_reduction_over_binary.py index 19f930dc..66910500 100644 --- a/tests/grammar/test_reduction_over_binary.py +++ b/tests/grammar/test_reduction_over_binary.py @@ -217,6 +217,7 @@ def test_every_flux_surface_reduction_wraps_a_binary_form(reduction: str) -> Non "volume_averaged", "line_averaged", "time_averaged", + "root_mean_square", "normalized", "perturbed", "change_in", @@ -225,7 +226,6 @@ def test_every_flux_surface_reduction_wraps_a_binary_form(reduction: str) -> Non "inverse_of", "time_derivative_of", "maximum_of", - "root_mean_square_of", ], ) def test_no_other_transformation_wraps_a_binary_form(prefix: str) -> None: diff --git a/tests/grammar/test_render_canonical.py b/tests/grammar/test_render_canonical.py index b457344d..a3d712fa 100644 --- a/tests/grammar/test_render_canonical.py +++ b/tests/grammar/test_render_canonical.py @@ -329,9 +329,7 @@ def test_render_mechanism_with_locus() -> None: def test_render_operator_between_projected_base_and_locus() -> None: """Render ``____`` canonically.""" ir = StandardNameIR( - operators=[ - OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="root_mean_square") - ], + operators=[OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op="maximum")], projection=AxisProjection(axis="radial", shape=ProjectionShape.COMPONENT), qualifiers=[Qualifier(token="electron")], base=QuantityOrCarrier(token="pressure", kind=BaseKind.QUANTITY), @@ -341,9 +339,7 @@ def test_render_operator_between_projected_base_and_locus() -> None: type=LocusType.POSITION, ), ) - assert compose(ir) == ( - "radial_electron_pressure_root_mean_square_at_plasma_boundary" - ) + assert compose(ir) == ("radial_electron_pressure_maximum_at_plasma_boundary") def test_render_nested_operators_outer_first() -> None: diff --git a/tests/grammar/test_round_trip.py b/tests/grammar/test_round_trip.py index 76739ad5..a0be0b87 100644 --- a/tests/grammar/test_round_trip.py +++ b/tests/grammar/test_round_trip.py @@ -1,4 +1,4 @@ -"""Round-trip test battery for grammar (plan 38 §A10, item 1). +"""Round-trip test battery for the grammar compose invariant. Synthesises ≥ 5 000 valid IR instances via seeded random, composes each to a canonical string, parses the string back, and asserts the re-parsed IR is @@ -40,6 +40,7 @@ ) from imas_standard_names.grammar.parser import ( Vocabularies, + _reduction_prefix_operators, load_default_vocabularies, parse, ) @@ -138,7 +139,16 @@ def _make_base_ir( def _prefix_op(op: str) -> OperatorApplication: - return OperatorApplication(kind=OperatorKind.UNARY_PREFIX, op=op) + """Build a canonical prefix application. + + A domain reduction carries the bare flag (``flux_surface_averaged_``), + so its composed spelling re-parses to the same IR; every other prefix + operator keeps the ``_of_`` joiner and the default bare_prefix=False. + """ + reductions = _reduction_prefix_operators(load_default_vocabularies()) + return OperatorApplication( + kind=OperatorKind.UNARY_PREFIX, op=op, bare_prefix=op in reductions + ) def _postfix_op(op: str) -> OperatorApplication: @@ -484,7 +494,7 @@ def test_round_trip_mechanisms(vocabs: Vocabularies) -> None: def test_round_trip_combined_large(vocabs: Vocabularies) -> None: """Combinatorial sweep targeting ≥ 5 000 unique IR instances. - Validates that the A10 target of 5 000 synthetic round-trips is met. + Validates that 5 000 synthetic round-trips are exercised. Uses a seeded RNG for reproducibility. """ rng = random.Random(1234) diff --git a/tests/grammar/test_tail_gated_operator_order.py b/tests/grammar/test_tail_gated_operator_order.py index 69252392..8f75ed2a 100644 --- a/tests/grammar/test_tail_gated_operator_order.py +++ b/tests/grammar/test_tail_gated_operator_order.py @@ -9,10 +9,6 @@ @pytest.mark.parametrize( ("current", "canonical"), [ - ( - "root_mean_square_of_wave_current_of_antenna_strap", - "wave_current_root_mean_square_of_antenna_strap", - ), ( "voltage_of_ion_cyclotron_heating_antenna_amplitude", "voltage_amplitude_of_ion_cyclotron_heating_antenna", @@ -61,10 +57,6 @@ "maximum_of_inverse_of_electron_density_at_magnetic_axis", "electron_density_inverse_maximum_at_magnetic_axis", ), - ( - "root_mean_square_of_radial_electron_pressure_at_plasma_boundary", - "radial_electron_pressure_root_mean_square_at_plasma_boundary", - ), ( "maximum_of_derivative_of_pressure_at_pedestal", "pressure_derivative_maximum_at_pedestal", @@ -98,6 +90,14 @@ def test_tailed_operator_moves_between_base_and_locus( "toroidal_argon_velocity_flux_surface_averaged_at_plasma_boundary", "flux_surface_averaged_toroidal_argon_velocity_at_plasma_boundary", ), + ( + "wave_current_root_mean_square_of_antenna_strap", + "root_mean_square_wave_current_of_antenna_strap", + ), + ( + "radial_electron_pressure_root_mean_square_at_plasma_boundary", + "root_mean_square_radial_electron_pressure_at_plasma_boundary", + ), ], ) def test_bare_operator_leads_instead_of_moving_before_the_locus( @@ -111,6 +111,36 @@ def test_bare_operator_leads_instead_of_moving_before_the_locus( assert canonical in str(rejection.value) +@pytest.mark.parametrize( + ("authored", "canonical"), + [ + ( + "root_mean_square_of_wave_current_of_antenna_strap", + "root_mean_square_wave_current_of_antenna_strap", + ), + ( + "spectral_width_root_mean_square_of_spectrometer_channel", + "root_mean_square_spectral_width_of_spectrometer_channel", + ), + ], +) +def test_reduction_of_form_converges_on_the_leading_spelling( + authored: str, canonical: str +) -> None: + """An ``_of_``-authored domain reduction renders to the bare leading form. + + root_mean_square is a precedence-30 reduction: it collapses the whole + operand, so the joiner states the same scope under a second string. The + authored ``_of_`` spelling must share one representation with the canonical + bare form and draw the same rendered string. + """ + ir = parse(authored).ir + assert parse(canonical, strict=True).ir == ir + assert compose(ir) == canonical + with pytest.raises(ValueError): + parse(authored, strict=True) + + def test_an_outer_operator_cannot_move_past_a_leading_reduction() -> None: """A joiner-taking operator stays put when a reduction leads its operand. diff --git a/tests/name_schema/test_reductions.py b/tests/name_schema/test_reductions.py index 12111154..2f3f852c 100644 --- a/tests/name_schema/test_reductions.py +++ b/tests/name_schema/test_reductions.py @@ -33,7 +33,7 @@ def test_rms_reduction_scalar(): { "kind": "scalar", "physics_domain": "core_plasma_physics", - "name": "root_mean_square_of_electron_temperature", + "name": "root_mean_square_electron_temperature", "description": "RMS", "documentation": "Root mean square of electron temperature.", "unit": "eV",