Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/op_system/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ def compile_spec( # noqa: RUF067

Returns:
CompiledRhs: Runnable RHS callable container.

Examples:
>>> import numpy as np
>>> compiled = compile_spec({
... "kind": "expr",
... "state": ["x"],
... "equations": {"x": "-x"},
... })
>>> compiled.eval_fn(0.0, np.array([1.0]))
array([-1.])
Comment on lines +88 to +97
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nice, the doctest provides a clear example that is great for users/developers to quickly get an idea of what the function does either before/after reading the docstring itself.

"""
if xp is not None or backend != DEFAULT_ARRAY_BACKEND:
warnings.warn(
Expand Down
85 changes: 85 additions & 0 deletions src/op_system/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ def _normalize_bracket_key(key: str) -> str:


def _normalize_axis_name(ax_map: Mapping[str, Any], *, idx: int, seen: set[str]) -> str:
"""Validate and return the ``name`` field of one axis definition.

Args:
ax_map: Raw axis mapping.
idx: Position in the surrounding ``axes`` list (for diagnostics).
seen: Mutable set of already-registered axis names; updated in place.

Returns:
The validated, stripped axis name.

Raises:
InvalidRhsSpecError: If ``name`` is missing, not a string, empty, or
duplicates an earlier axis.
"""
name_val = ax_map.get("name")
if not isinstance(name_val, str) or not name_val.strip():
raise InvalidRhsSpecError(detail=f"axes[{idx}].name must be a non-empty string")
Expand All @@ -64,6 +78,18 @@ def _normalize_axis_name(ax_map: Mapping[str, Any], *, idx: int, seen: set[str])


def _normalize_axis_type(ax_map: Mapping[str, Any], *, idx: int) -> str:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _normalize_axis_type(ax_map: Mapping[str, Any], *, idx: int) -> str:
def _normalize_axis_type(ax_map: Mapping[str, Any], *, idx: int) -> Literal["categorical", "continuous", "ordinal"]:

Per the return type. May also make sense to convert this to a StrEnum in the future depending on use.

"""Validate and return the ``type`` field of one axis (default categorical).

Args:
ax_map: Raw axis mapping.
idx: Position in the surrounding ``axes`` list (for diagnostics).

Returns:
One of ``"categorical"``, ``"ordinal"``, or ``"continuous"``.

Raises:
InvalidRhsSpecError: If ``type`` is set to anything else.
"""
ax_type = str(ax_map.get("type", "categorical")).strip().lower()
if ax_type not in {"categorical", "ordinal", "continuous"}:
raise InvalidRhsSpecError(
Expand All @@ -75,6 +101,19 @@ def _normalize_axis_type(ax_map: Mapping[str, Any], *, idx: int) -> str:


def _normalize_axis_units(ax_map: Mapping[str, Any], *, idx: int) -> str | None:
"""Validate and return the optional ``units`` field of one axis.

Args:
ax_map: Raw axis mapping.
idx: Position in the surrounding ``axes`` list (for diagnostics).

Returns:
Stripped units string or ``None`` when absent.

Raises:
InvalidRhsSpecError: If ``units`` is provided but is not a non-empty
string.
"""
units_obj = ax_map.get("units")
if units_obj is None:
return None
Expand All @@ -91,6 +130,23 @@ def _normalize_axis_coords(
idx: int,
ax_type: str,
) -> tuple[list[Any], int]:
"""Validate explicit ``coords`` for one axis.

Categorical and ordinal axes must have non-empty unique string coords;
continuous axes coerce values to numbers and require monotonic
non-decreasing order.

Args:
coords_obj: Raw value of ``coords``.
idx: Position in the surrounding ``axes`` list (for diagnostics).
ax_type: Already-validated axis type.

Returns:
``(coords, size)`` pair.

Raises:
InvalidRhsSpecError: If validation fails.
"""
if not isinstance(coords_obj, (list, tuple)) or not coords_obj:
raise InvalidRhsSpecError(detail=f"axes[{idx}].coords must be a non-empty list")
coords = list(coords_obj)
Expand Down Expand Up @@ -164,6 +220,20 @@ def _compute_axis_deltas(coords: list[float], *, idx: int) -> list[float]:
def _generate_continuous_coords(
*, domain: object, size_obj: object, spacing: str, idx: int
) -> tuple[list[float], int]:
"""Generate ``coords`` for a continuous axis from ``domain``/``size``/``spacing``.

Args:
domain: Raw ``domain`` mapping with ``lb``/``ub``.
size_obj: Raw ``size`` value (must be an integer >= 2).
spacing: One of ``"linear"``, ``"log"``, ``"geom"``.
idx: Position in the surrounding ``axes`` list (for diagnostics).

Returns:
``(coords, size)`` pair where ``coords`` has length ``size``.

Raises:
InvalidRhsSpecError: On invalid bounds, size, or spacing.
"""
domain_map = (
_ensure_mapping(domain, name=f"axes[{idx}].domain")
if domain is not None
Expand Down Expand Up @@ -221,6 +291,21 @@ def _generate_continuous_coords(
def _normalize_single_axis(
ax_map: Mapping[str, Any], *, idx: int, seen: set[str]
) -> dict[str, Any]:
"""Normalize one axis mapping into the canonical record.

Args:
ax_map: Raw axis mapping.
idx: Position in the surrounding ``axes`` list (for diagnostics).
seen: Mutable set of already-registered axis names; updated in place.

Returns:
Canonical axis dict (``name``, ``type``, ``coords``, ``size``,
and optionally ``deltas``, ``domain``, ``spacing``, ``units``).

Raises:
InvalidRhsSpecError: If the axis is categorical or ordinal but
has no ``coords`` field.
"""
name = _normalize_axis_name(ax_map, idx=idx, seen=seen)
ax_type = _normalize_axis_type(ax_map, idx=idx)
spacing = str(ax_map.get("spacing", "linear")).strip().lower()
Expand Down
19 changes: 19 additions & 0 deletions src/op_system/_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ def __init__(
missing: list[str] | None = None,
detail: str | None = None,
) -> None:
"""Initialize the error.

Args:
missing: Optional list of missing required field names.
detail: Optional human-readable detail describing the
violation.
"""
self.missing = list(missing) if missing else None
self.detail = detail
parts: list[str] = [_INVALID_RHS_SPEC_PREFIX]
Expand All @@ -47,6 +54,12 @@ class InvalidExpressionError(ValueError):
"""

def __init__(self, *, detail: str) -> None:
"""Initialize the error.

Args:
detail: Human-readable detail describing the parse or
validation failure.
"""
self.detail = detail
super().__init__(f"{_INVALID_EXPRESSION_PREFIX} Detail: {detail}")

Expand All @@ -60,6 +73,12 @@ class UnsupportedFeatureError(NotImplementedError):
"""

def __init__(self, *, feature: str, detail: str | None = None) -> None:
"""Initialize the error.

Args:
feature: Identifier for the unsupported feature.
detail: Optional additional detail.
"""
self.feature = feature
self.detail = detail
msg = f"{_UNSUPPORTED_FEATURE_PREFIX} Feature {feature!r} is not supported."
Expand Down
Loading
Loading