Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions imas_standard_names/grammar/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions imas_standard_names/grammar/model_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 11 additions & 1 deletion imas_standard_names/grammar/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "_<op>", longest op first. A postfix at the
# tail of an explicit prefix or binary form belongs to that operator's
Expand All @@ -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,
[],
)
Expand Down
19 changes: 18 additions & 1 deletion imas_standard_names/grammar/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,14 +299,31 @@ 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]]:
"""Split leading bare operators from ordinary base qualifiers."""

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

Expand Down
44 changes: 43 additions & 1 deletion imas_standard_names/grammar/vocabularies/locus_registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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_)
# ====================================================================
Expand Down
2 changes: 1 addition & 1 deletion tests/grammar/test_reduction_over_binary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions tests/grammar/test_render_canonical.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,7 @@ def test_render_mechanism_with_locus() -> None:
def test_render_operator_between_projected_base_and_locus() -> None:
"""Render ``<axis>_<qual>_<base>_<op>_<locus>`` 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),
Expand All @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions tests/grammar/test_round_trip.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,6 +40,7 @@
)
from imas_standard_names.grammar.parser import (
Vocabularies,
_reduction_prefix_operators,
load_default_vocabularies,
parse,
)
Expand Down Expand Up @@ -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_<x>``),
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:
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 38 additions & 8 deletions tests/grammar/test_tail_gated_operator_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion tests/name_schema/test_reductions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading