"""Ring feature domain object and cross-feature date-overlap validation.
This module is the core of the ring domain model. It defines:
- ``RingFeature``: An immutable domain object representing a single ring gap
or ringlet. It owns backplane-based rendering via ``render(context)`` and
provides query methods used by the filter and orchestrator.
- ``validate_no_date_overlaps()``: A cross-feature validation function that
detects authoring errors in the YAML config where two features cover the
same radial region with overlapping date ranges.
**Validation philosophy**: ``from_config()`` raises ``ValueError`` on any
malformed per-feature data (bad types, missing fields, out-of-range values).
``validate_no_date_overlaps()`` raises ``ValueError`` on cross-feature date
conflicts. Both are hard errors because bad config is an authoring mistake that
should be caught immediately, not silently degraded at render time. The
``RingFeatureFilter`` handles *valid* features that are not relevant to a
particular observation.
**Immutability**: ``RingFeature`` is a frozen dataclass. All attributes are set
at construction and never mutated. Derived cached fields (``_start_et``,
``_end_et``) are set in ``__post_init__`` using ``object.__setattr__()``, the
standard Python pattern for frozen dataclasses that need computed cached
values -- ``__post_init__`` is the only place this bypass is appropriate. After
construction completes, all fields are truly frozen.
**Rendering dispatch**: ``render()`` calls ``_compute_edge_radii()`` which uses
``RingEdgeData.parsed_modes_for_backplane()`` to get the mode tuples for the
``oops.ext_bp.radial_mode()`` backplane calls. The rendering path branches based
on feature type and edge availability:
- RINGLET with both edges -> ``_render_full_ringlet()`` -> one ``RingRenderResult``
- GAP with either or both edges, or single-edge RINGLET -> ``_render_single_edge()``
per present edge -> one ``RingRenderResult`` per edge
"""
import math
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from nav.support.time import utc_to_et
from nav.support.types import NDArrayBoolType
from .ring_math import compute_antialiasing, compute_edge_fade
from .ring_render_context import RingsRenderContext
from .ring_render_result import RingRenderResult
from .ring_types import RingBaseOrbitMode, RingEdgeData, RingFeatureType, RingPerturbationMode
# ``compute_antialiasing`` varies only for |radius - edge| <= 0.5 * resolution;
# beyond that it is clamped to 0 or 1. Applying those plateaus on every
# ``not_solid`` pixel would paint a half-plane of constant model and a nearly
# all-True mask; we keep contributions only in the edge band.
_AA_EDGE_BAND_HALF_WIDTH = 0.5
[docs]
@dataclass(frozen=True)
class RingFeature:
"""A single ring feature (gap or ringlet) with backplane rendering capability.
Frozen dataclass: all attributes are set at construction and never mutated.
``render()`` takes context and returns results without modifying feature state.
This immutability guarantees that feature data loaded from YAML config remains
consistent throughout the pipeline: multiple filter passes, render calls, and
annotation creation all see the same data.
Owns backplane-based rendering for real observations. For simulated
observations, data access and annotations are used but rendering is
delegated to ``sim_ring.render_ring()`` in ``NavModelRingsSimulated``.
"""
key: str
name: str | None
feature_type: RingFeatureType
inner_edge: RingEdgeData | None
outer_edge: RingEdgeData | None
start_date: str | None = None
end_date: str | None = None
# Cached ET values computed in __post_init__ via object.__setattr__
_start_et: float | None = field(init=False, repr=False, default=None)
_end_et: float | None = field(init=False, repr=False, default=None)
[docs]
def __post_init__(self) -> None:
"""Validate and cache date conversions.
Uses ``object.__setattr__()`` to set derived fields on a frozen
dataclass. This is the standard Python pattern for frozen dataclasses
that need computed cached values -- ``__post_init__`` is the only place
where this bypass is appropriate. After construction completes, all
fields are truly frozen.
Raises:
ValueError: If both inner_edge and outer_edge are None, or if both
dates convert to ET and ``start_date`` does not strictly precede
``end_date``.
"""
if self.inner_edge is None and self.outer_edge is None:
raise ValueError(
f'RingFeature {self.key!r}: at least one edge (inner or outer) is required'
)
start_et: float | None = None
end_et: float | None = None
if self.start_date is not None:
start_et = utc_to_et(self.start_date)
if self.end_date is not None:
end_et = utc_to_et(self.end_date)
if start_et is not None and end_et is not None and start_et >= end_et:
raise ValueError(
f'RingFeature {self.key!r}: start_date must be strictly before end_date '
f'([start_date, end_date) is half-open)'
)
object.__setattr__(self, '_start_et', start_et)
object.__setattr__(self, '_end_et', end_et)
# ------------------------------------------------------------------
# Query methods (used by orchestrator and filter)
# ------------------------------------------------------------------
[docs]
def is_visible_at(self, obs_time_et: float) -> bool:
"""Return True if this feature is valid at the given observation time.
A feature with no date range is always visible. If only ``start_date``
is set, the feature is visible at and after that date. If only
``end_date`` is set, the feature is visible before that date.
The range is half-open: [start_et, end_et).
Parameters:
obs_time_et: Observation time in TDB seconds (from ``utc_to_et``).
Returns:
True if the feature is active at ``obs_time_et``.
"""
if self._start_et is not None and obs_time_et < self._start_et:
return False
return not (self._end_et is not None and obs_time_et >= self._end_et)
[docs]
def is_in_radius_range(self, min_r: float, max_r: float) -> bool:
"""Return True if at least one edge is within the given radius range.
A feature is kept if ANY of its edges falls in ``[min_r, max_r]``.
This enables partial visibility: a ringlet with one edge in range
and one out of range is rendered as a single-edge feature by
``render()``.
Parameters:
min_r: Minimum ring radius in km (inclusive).
max_r: Maximum ring radius in km (inclusive).
Returns:
True if at least one edge base radius is in ``[min_r, max_r]``.
"""
if self.inner_edge is not None:
a = self.inner_edge.base_radius
if min_r <= a <= max_r:
return True
if self.outer_edge is not None:
a = self.outer_edge.base_radius
if min_r <= a <= max_r:
return True
return False
@property
def max_extent_radius(self) -> float:
"""Maximum possible radius (a + ae) across all present edges.
Returns the outermost radius the feature could occupy at any longitude,
accounting for eccentricity. Used for a fast pre-filter check: if the
minimum observed ring radius in the FOV exceeds this value, none of the
feature can appear in the image regardless of orientation.
Returns:
Maximum of (base_orbit.a + base_orbit.ae) for all present edges.
Raises:
ValueError: If both ``inner_edge`` and ``outer_edge`` are ``None``,
so ``max_extent_radius`` cannot be computed.
"""
extents: list[float] = []
if self.inner_edge is not None:
bo = self.inner_edge.base_orbit
extents.append(bo.a + bo.ae)
if self.outer_edge is not None:
bo = self.outer_edge.base_orbit
extents.append(bo.a + bo.ae)
if len(extents) == 0:
raise ValueError(
'RingFeature has no edges; cannot compute max_extent_radius '
'(both inner_edge and outer_edge are None)'
)
return max(extents)
[docs]
def all_base_radii(self) -> list[tuple[float, str]]:
"""Return (radius_km, edge_label) pairs for all present edges.
Edge labels follow the convention:
- RINGLET inner edge: 'IER' (Inner Edge Ringlet)
- RINGLET outer edge: 'OER' (Outer Edge Ringlet)
- GAP inner edge: 'IEG' (Inner Edge Gap)
- GAP outer edge: 'OEG' (Outer Edge Gap)
Returns:
List of (radius_km, label) tuples for present edges.
"""
result: list[tuple[float, str]] = []
labels = self.edge_labels
if self.inner_edge is not None:
result.append((self.inner_edge.base_radius, labels['inner']))
if self.outer_edge is not None:
result.append((self.outer_edge.base_radius, labels['outer']))
return result
[docs]
def uses_fade_for_edge(self, edge_type: str) -> bool:
"""Return True if the given edge uses fade rendering by structure.
Structural check based on feature configuration:
- GAP edges always use fade (gaps render a fading gradient from each
known edge; they do not fill solid between edges).
- RINGLET edges use fade only when the feature has a single edge (the
other is None).
This is a structural check. The ``RingFeatureFilter`` augments this with
partial-visibility awareness: if a RINGLET has both edges but one is
out of the visible radius range, the filter treats the in-range edge as
fade-using even though this method returns False.
Parameters:
edge_type: 'inner' or 'outer'.
Returns:
True if this edge uses fade rendering by structure.
Raises:
ValueError: If ``edge_type`` is not exactly ``'inner'`` or ``'outer'``.
"""
if edge_type not in ('inner', 'outer'):
raise ValueError(
f"edge_type must be 'inner' or 'outer' (case-sensitive), got {edge_type!r}"
)
if self.feature_type is RingFeatureType.GAP:
return True
# RINGLET: uses fade only when this is a single-edge feature
if edge_type == 'inner':
return self.outer_edge is None
return self.inner_edge is None
@property
def uncertainty(self) -> float:
"""Maximum RMS across all present edges (km).
Used to populate ``NavModelResult.uncertainty``. The maximum (rather
than minimum or average) is conservative: the overall uncertainty of
a feature is dominated by its least well-characterized edge.
Returns:
Max of inner and outer edge RMS values, or the single edge RMS
if only one edge is present.
"""
inner_rms = self.inner_edge.rms if self.inner_edge is not None else None
outer_rms = self.outer_edge.rms if self.outer_edge is not None else None
if inner_rms is not None and outer_rms is not None:
return max(inner_rms, outer_rms)
if inner_rms is not None:
return inner_rms
if outer_rms is not None:
return outer_rms
return 0.0 # unreachable: __post_init__ ensures at least one edge
@property
def edge_labels(self) -> dict[str, str]:
"""Map of 'inner'/'outer' to edge label string.
Returns:
Dict with keys 'inner' and 'outer' mapping to label strings
('IER'/'OER' for ringlets, 'IEG'/'OEG' for gaps).
"""
if self.feature_type is RingFeatureType.GAP:
return {'inner': 'IEG', 'outer': 'OEG'}
return {'inner': 'IER', 'outer': 'OER'}
# ------------------------------------------------------------------
# Backplane rendering
# ------------------------------------------------------------------
[docs]
def render(self, context: RingsRenderContext) -> list[RingRenderResult]:
"""Render this feature using backplane data.
Dispatch logic:
- RINGLET with both edges visible -> ``_render_full_ringlet()`` -> one result
- GAP with any edges, or single-edge RINGLET -> ``_render_single_edge()``
per edge -> one result per edge
For RINGLETs with one edge out of the visible radius range (partial
visibility), ``RingFeatureFilter`` trims the out-of-range edge to ``None``
before this method is called, so ``render()`` naturally takes the
single-edge path for the remaining in-range edge (fade rendering).
Parameters:
context: Immutable rendering context with obs, ring_target, epoch,
per-pixel resolutions, fade config, and all_edge_radii.
Returns:
List of ``RingRenderResult`` objects. Typically one result for full
ringlets and one per edge for gaps and single-edge features.
"""
inner_radii_bp = None
outer_radii_bp = None
if self.inner_edge is not None:
inner_radii_bp = self._compute_edge_radii(context, self.inner_edge)
if self.outer_edge is not None:
outer_radii_bp = self._compute_edge_radii(context, self.outer_edge)
labels = self.edge_labels
results: list[RingRenderResult] = []
if (
self.feature_type is RingFeatureType.RINGLET
and inner_radii_bp is not None
and outer_radii_bp is not None
):
context.logger.debug(
'RingFeature %r: rendering full ringlet (solid band + edge anti-aliasing)',
self.key,
)
result = self._render_full_ringlet(context, inner_radii_bp, outer_radii_bp, labels)
if result is not None:
results.append(result)
else:
context.logger.debug(
'RingFeature %r: rendering per-edge (GAP or partial/single-edge RINGLET)',
self.key,
)
# GAP or single-edge: one result per edge
for edge_data, radii_bp, edge_type in [
(self.inner_edge, inner_radii_bp, 'inner'),
(self.outer_edge, outer_radii_bp, 'outer'),
]:
if edge_data is None or radii_bp is None:
continue
result = self._render_single_edge(context, edge_data, radii_bp, edge_type, labels)
if result is not None:
results.append(result)
return results
def _compute_edge_radii(self, context: RingsRenderContext, edge: RingEdgeData) -> Any:
"""Compute multi-mode edge radius backplane.
Uses ``RingEdgeData.parsed_modes_for_backplane()`` to get the mode
tuples, then applies them sequentially via
``context.obs.ext_bp.radial_mode()``.
The first mode (mode 1, base orbit) calls ``radial_mode`` with the
backplane key, mode number, epoch, ``ae``, longitude and rate of
periapsis (radians / rad/s), plus ``a0=<semi-major axis>`` as a keyword
argument (six positional values plus ``a0``). Subsequent modes use the
four-argument perturbation form.
Parameters:
context: Current rendering context.
edge: Edge data including base orbit and perturbations.
Returns:
Backplane object with computed per-pixel radii.
"""
parsed = edge.parsed_modes_for_backplane()
radii_bp = context.obs.ext_bp.ring_radius(context.ring_target)
for mode_info in parsed:
if len(mode_info) == 5:
mode, a, ae, long_peri_rad, rate_peri_rad_per_sec = mode_info
radii_bp = context.obs.ext_bp.radial_mode(
radii_bp.key,
mode,
context.epoch,
ae,
long_peri_rad,
rate_peri_rad_per_sec,
a0=a,
)
else:
mode, amplitude, phase_rad, speed_rad_per_sec = mode_info
radii_bp = context.obs.ext_bp.radial_mode(
radii_bp.key,
mode,
context.epoch,
amplitude,
phase_rad,
speed_rad_per_sec,
)
return radii_bp
def _render_full_ringlet(
self,
context: RingsRenderContext,
inner_radii_bp: Any,
outer_radii_bp: Any,
labels: dict[str, str],
) -> RingRenderResult | None:
"""Render a complete ringlet with solid fill and anti-aliasing.
Fills the region between inner and outer edges with value 1.0, then
applies anti-aliasing at both edges. ``compute_antialiasing`` is clipped
to 0 or 1 outside half a resolution of each edge; only the transitional
band contributes on ``not_solid`` pixels so the model does not fill a
half-plane with a constant plateau. The returned mask matches pixels
with positive model value.
Edge info for annotations is computed here using the already-computed
backplanes, avoiding a second backplane computation pass.
Parameters:
context: Rendering context.
inner_radii_bp: Pre-computed inner edge radius backplane.
outer_radii_bp: Pre-computed outer edge radius backplane.
labels: Edge label dict from ``edge_labels``.
Returns:
``RingRenderResult`` or None if edge radii could not be determined.
"""
if self.inner_edge is None:
raise ValueError(f'RingFeature {self.key!r}: _render_full_ringlet requires inner_edge')
if self.outer_edge is None:
raise ValueError(f'RingFeature {self.key!r}: _render_full_ringlet requires outer_edge')
inner_a = self.inner_edge.base_radius
outer_a = self.outer_edge.base_radius
resolutions = context.resolutions
context.logger.debug(
'RingFeature %r: full ringlet inner_a=%.3f km outer_a=%.3f km (solid + AA)',
self.key,
inner_a,
outer_a,
)
inner_radii = inner_radii_bp.mvals.filled(0.0)
outer_radii = outer_radii_bp.mvals.filled(0.0)
shape = resolutions.shape
model = np.zeros(shape, dtype=np.float64)
mask = np.zeros(shape, dtype=bool)
# Solid region between edges
inner_above = inner_radii - resolutions / 2.0 >= inner_a
outer_below = outer_radii + resolutions / 2.0 <= outer_a
solid = np.logical_and(inner_above, outer_below)
if hasattr(solid, 'filled'):
solid = solid.filled(False)
solid = np.asarray(solid, dtype=bool)
model[solid] += 1.0
mask[solid] = True
# Anti-aliasing at inner and outer edges
inner_shade = compute_antialiasing(
radii=inner_radii,
edge_radius=inner_a,
shade_above=False,
resolutions=resolutions,
)
outer_shade = compute_antialiasing(
radii=outer_radii,
edge_radius=outer_a,
shade_above=True,
resolutions=resolutions,
)
half_w = _AA_EDGE_BAND_HALF_WIDTH * resolutions
inner_band = np.abs(inner_radii - inner_a) <= half_w + 1e-12
outer_band = np.abs(outer_radii - outer_a) <= half_w + 1e-12
inner_shade = np.where(inner_band, inner_shade, 0.0)
outer_shade = np.where(outer_band, outer_shade, 0.0)
not_solid = ~solid
model[not_solid] += inner_shade[not_solid]
model[not_solid] += outer_shade[not_solid]
mask[not_solid] |= inner_shade[not_solid] > 0.0
mask[not_solid] |= outer_shade[not_solid] > 0.0
model = np.maximum(model, 0.0)
mask = model > 0.0
model = np.where(mask, model, 0.0)
# Build annotation edge masks using already-computed backplanes
edge_info_list: list[tuple[NDArrayBoolType, str, str]] = []
for edge_bp, a, edge_type in [
(inner_radii_bp, inner_a, 'inner'),
(outer_radii_bp, outer_a, 'outer'),
]:
label = labels[edge_type]
feature_name = self.name or 'UNNAMED'
edge_mask = (
context.obs.ext_bp.border_atop(edge_bp.key, a).mvals.astype('bool').filled(False)
)
edge_info_list.append((edge_mask, f'{feature_name} {label}', label))
return RingRenderResult(
model_img=model,
model_mask=mask,
uncertainty=self.uncertainty,
edge_info_list=edge_info_list,
)
def _render_single_edge(
self,
context: RingsRenderContext,
edge_data: RingEdgeData,
radii_bp: Any,
edge_type: str,
labels: dict[str, str],
) -> RingRenderResult | None:
"""Render a single edge with a linear fade gradient.
Used for:
- GAP features (all edges use fade)
- RINGLET features with only one edge defined
- RINGLET features where the other edge is out of the visible radius range
The fade direction is determined by feature type and edge type:
- RINGLET inner edge: shade_above=True (fade away from planet)
- RINGLET outer edge: shade_above=False (fade toward planet)
- GAP inner edge: shade_above=False (fade toward planet / into gap)
- GAP outer edge: shade_above=True (fade away from planet / into gap)
Parameters:
context: Rendering context.
edge_data: Data for this specific edge.
radii_bp: Pre-computed radius backplane for this edge.
edge_type: 'inner' or 'outer'.
labels: Edge label dict.
Returns:
``RingRenderResult`` or None if rendering failed.
"""
edge_a = edge_data.base_radius
edge_radii = radii_bp.mvals.filled(0.0)
resolutions = context.resolutions
shape = resolutions.shape
# Shade direction: ringlet inner fades outward; gap outer fades outward
if self.feature_type is RingFeatureType.RINGLET:
shade_above = edge_type == 'inner'
else:
shade_above = edge_type == 'outer'
context.logger.debug(
'RingFeature %r: single-edge %s fade (edge_a=%.3f km, shade_above=%s, '
'fade_width_pix=%.2f)',
self.key,
edge_type,
edge_a,
shade_above,
context.fade_width_pix,
)
model = np.zeros(shape, dtype=np.float64)
new_model = compute_edge_fade(
model=model,
radii=edge_radii,
edge_radius=edge_a,
shade_above=shade_above,
fade_width_pix=context.fade_width_pix,
resolutions=resolutions,
all_edge_radii=context.all_edge_radii,
logger=context.logger,
)
fade_mask = (new_model - model) > 0.0
mask = np.zeros(shape, dtype=bool)
mask[fade_mask] = True
label = labels[edge_type]
feature_name = self.name or 'UNNAMED'
edge_mask: NDArrayBoolType = (
context.obs.ext_bp.border_atop(radii_bp.key, edge_a).mvals.astype('bool').filled(False)
)
edge_info_list: list[tuple[NDArrayBoolType, str, str]] = [
(edge_mask, f'{feature_name} {label}', label)
]
return RingRenderResult(
model_img=new_model,
model_mask=mask,
uncertainty=self.uncertainty,
edge_info_list=edge_info_list,
)
# ------------------------------------------------------------------
# Factory
# ------------------------------------------------------------------
[docs]
@classmethod
def from_config(cls, key: str, data: dict[str, Any]) -> 'RingFeature':
"""Construct a RingFeature from a YAML feature dictionary.
Validates all fields at construction time. This follows the principle
that bad config is an authoring error that should fail loudly and
immediately, not silently degrade at render time.
Parameters:
key: Feature key (YAML dict key used as identifier).
data: Feature dictionary with keys:
- ``feature_type``: 'GAP' or 'RINGLET' (required)
- ``name``: Human-readable name (optional)
- ``inner_data``: List of mode dicts (optional)
- ``outer_data``: List of mode dicts (optional)
- ``start_date``: ISO date string (optional)
- ``end_date``: ISO date string (optional)
Returns:
Constructed ``RingFeature`` instance.
Raises:
TypeError: If ``key`` is not a ``str`` or ``data`` is not a ``dict``.
ValueError: On any structural or value error:
- feature_type not 'GAP' or 'RINGLET'
- neither inner_data nor outer_data present
- mode data is not a non-empty list
- mode-1 data missing or has non-positive 'a'
- rms < 0
- perturbation mode missing required fields
"""
if not isinstance(key, str):
raise TypeError(f'Feature key must be str, got {type(key).__name__}')
if not isinstance(data, dict):
raise TypeError(f'Feature {key!r}: data must be a dict, got {type(data).__name__}')
# Validate feature_type
raw_type = data.get('feature_type')
try:
feature_type = RingFeatureType(raw_type)
except ValueError as exc:
raise ValueError(
f'Feature {key!r}: invalid feature_type {raw_type!r}; expected "GAP" or "RINGLET"'
) from exc
name: str | None = data.get('name')
start_date: str | None = data.get('start_date')
end_date: str | None = data.get('end_date')
raw_inner = data.get('inner_data')
raw_outer = data.get('outer_data')
if raw_inner is None and raw_outer is None:
raise ValueError(
f'Feature {key!r}: at least one edge (inner_data or outer_data) is required'
)
inner_edge: RingEdgeData | None = None
outer_edge: RingEdgeData | None = None
if raw_inner is not None:
inner_edge = _parse_edge_data(key, 'inner', raw_inner)
if raw_outer is not None:
outer_edge = _parse_edge_data(key, 'outer', raw_outer)
return cls(
key=key,
name=name,
feature_type=feature_type,
inner_edge=inner_edge,
outer_edge=outer_edge,
start_date=start_date,
end_date=end_date,
)
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
def _integral_mode_num(feature_key: str, edge_type: str, index: int, mode_num: Any) -> int:
"""Parse YAML ``mode`` as ``int``; reject ``bool`` and non-integral floats."""
if isinstance(mode_num, bool):
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{index}]: '
f'mode_num must be int (not bool), got {mode_num!r}'
)
if isinstance(mode_num, int):
return mode_num
if isinstance(mode_num, float) and math.isfinite(mode_num) and mode_num == math.floor(mode_num):
return int(mode_num)
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{index}]: '
f'mode_num must be an integer, got {mode_num!r}'
)
def _parse_edge_data(feature_key: str, edge_type: str, mode_list: Any) -> RingEdgeData:
"""Parse a list of mode dicts into a RingEdgeData.
Parameters:
feature_key: Feature key for error messages.
edge_type: 'inner' or 'outer' for error messages.
mode_list: Should be a non-empty list of mode dicts.
Returns:
Parsed ``RingEdgeData``.
Raises:
ValueError: On any structural or value error, including perturbation mode
dicts that omit ``amplitude``, ``phase``, or ``pattern_speed``.
"""
if not isinstance(mode_list, list) or len(mode_list) == 0:
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data: '
f'expected a non-empty list of mode dicts, got {type(mode_list).__name__!r}'
)
base_orbit: RingBaseOrbitMode | None = None
perturbations: list[RingPerturbationMode] = []
for i, mode in enumerate(mode_list):
if not isinstance(mode, dict):
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{i}]: '
f'expected dict, got {type(mode).__name__!r}'
)
mode_num = mode.get('mode')
if mode_num is None:
raise ValueError(f'Feature {feature_key!r} {edge_type}_data[{i}]: missing "mode" field')
if 'a' in mode:
# Base-orbit entry: must be mode 1 (semi-major axis and related fields).
mode_n_base = _integral_mode_num(feature_key, edge_type, i, mode_num)
if mode_n_base != 1:
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{i}]: entry with "a" must use '
f'mode 1 (base orbit), got mode {mode_num!r}'
)
if base_orbit is not None:
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{i}]: duplicate base-orbit entry '
f'(only one mode dict with "a" allowed per edge); '
f'earlier entry already defined the mode-1 base orbit'
)
base_orbit = RingBaseOrbitMode(
a=mode['a'],
ae=mode.get('ae', 0.0),
long_peri=mode.get('long_peri', 0.0),
rate_peri=mode.get('rate_peri', 0.0),
rms=mode.get('rms', 0.0),
)
else:
# Perturbation mode: require explicit YAML keys; validation by
# ``RingPerturbationMode.__post_init__`` (no silent 0.0 defaults).
mode_n = _integral_mode_num(feature_key, edge_type, i, mode_num)
for field_name in ('amplitude', 'phase', 'pattern_speed'):
if field_name not in mode:
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data[{i}]: '
f'perturbation mode dict missing required key {field_name!r}'
)
perturbations.append(
RingPerturbationMode(
mode_num=mode_n,
amplitude=mode['amplitude'],
phase=mode['phase'],
pattern_speed=mode['pattern_speed'],
)
)
if base_orbit is None:
raise ValueError(
f'Feature {feature_key!r} {edge_type}_data: missing mode 1 (base orbit with "a" field)'
)
return RingEdgeData(base_orbit=base_orbit, perturbations=tuple(perturbations))
# ---------------------------------------------------------------------------
# Cross-feature date-overlap validation
# ---------------------------------------------------------------------------
[docs]
def validate_no_date_overlaps(features: Sequence[RingFeature]) -> None:
"""Cross-feature validation: detect date-range overlaps in the same radial region.
Two features "overlap" if their date ranges intersect AND their radial
extents intersect. This catches authoring errors in the YAML config where
the same ring edge is defined twice with overlapping validity periods.
This function runs after all features are loaded via ``from_config()`` and
before the runtime filter. It is a hard error (``ValueError``) because
overlapping dates for the same radial region is a config authoring mistake,
not an observation-dependent condition. The filter handles *valid* features
that are not relevant for a particular observation.
Parameters:
features: All features loaded from one planet's config.
Raises:
ValueError: If any pair of features has overlapping dates AND overlapping
radial extents.
"""
feature_list = list(features)
for i, feat_a in enumerate(feature_list):
for feat_b in feature_list[i + 1 :]:
if _radial_extents_overlap(feat_a, feat_b) and _dates_overlap(feat_a, feat_b):
raise ValueError(
f'Ring config error: features {feat_a.key!r} and {feat_b.key!r} '
f'have overlapping date ranges and overlapping radial extents. '
f'Check the YAML config -- features should have non-overlapping '
f'date ranges if they cover the same radial region.'
)
def _radial_extents_overlap(a: RingFeature, b: RingFeature) -> bool:
"""Return True if the radial extents of two features overlap."""
radii_a = [r for r, _ in a.all_base_radii()]
radii_b = [r for r, _ in b.all_base_radii()]
if not radii_a or not radii_b:
return False
min_a, max_a = min(radii_a), max(radii_a)
min_b, max_b = min(radii_b), max(radii_b)
# Two intervals [min_a, max_a] and [min_b, max_b] overlap iff
# one doesn't entirely lie below the other
return max_a >= min_b and max_b >= min_a
def _dates_overlap(a: RingFeature, b: RingFeature) -> bool:
"""Return True if two features with explicit date ranges overlap.
Only checks features that BOTH have explicit date ranges. If either feature
has no dates (always active), it is not considered a date-range conflict --
having a timeless feature alongside a dated feature is intentional
(e.g., a feature that exists throughout the mission alongside a dated
variant that replaces it for a specific period would be caught by the
radial overlap check alone, but both-active is a normal pattern in config).
The half-open interval check: [a_start, a_end) and [b_start, b_end) overlap
if a_start < b_end AND b_start < a_end.
"""
# If either feature has no dates, skip the overlap check
if a._start_et is None and a._end_et is None:
return False
if b._start_et is None and b._end_et is None:
return False
# Both have at least one explicit date; treat None as infinite extent
a_start = a._start_et if a._start_et is not None else float('-inf')
a_end = a._end_et if a._end_et is not None else float('inf')
b_start = b._start_et if b._start_et is not None else float('-inf')
b_end = b._end_et if b._end_et is not None else float('inf')
return a_start < b_end and b_start < a_end