diff --git a/flepimop2-op_engine/pyproject.toml b/flepimop2-op_engine/pyproject.toml index f7e4035..7a8613b 100644 --- a/flepimop2-op_engine/pyproject.toml +++ b/flepimop2-op_engine/pyproject.toml @@ -7,11 +7,11 @@ authors = [ { name = "Joshua Macdonald", email = "jmacdo16@jh.edu" }, ] dependencies = [ - # Declare the dependency normally... - "op-engine>=0.1.0", + # op-engine is not in a registry yet, so keep it as a direct reference. + "op-engine @ git+https://github.com/ACCIDDA/op_engine.git@main", # flepimop2 is not in a registry, so keep it as a direct reference. - "flepimop2 @ git+https://github.com/ACCIDDA/flepimop2.git@main", + "flepimop2 @ git+https://github.com/ACCIDDA/flepimop2.git@43143a652480f3db4480884a436dba9d2ffb31d3", "pydantic>=2.0,<3", "numpy>=1.26", @@ -44,10 +44,6 @@ allow-direct-references = true [tool.hatch.build.targets.wheel] packages = ["src/flepimop2"] -# --- uv monorepo wiring: satisfy op-engine from the sibling directory --- -[tool.uv.sources] -op-engine = { path = ".." } - [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-q" @@ -69,6 +65,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**/*" = ["INP001", "S101"] +"src/flepimop2/engine/op_engine/__init__.py" = ["C901", "DOC201", "DOC501", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "RUF067"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py b/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py index 3a319b0..1e4eda2 100644 --- a/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py +++ b/flepimop2-op_engine/src/flepimop2/engine/op_engine/__init__.py @@ -1,21 +1,183 @@ -"""flepimop2 engine integration for op_engine. +"""flepimop2 Engine integration for op_engine (thin, single-file).""" -This package intentionally defines the public Engine class in this module so that -flepimop2's dynamic loader can auto-inject a default `build()` function. +from __future__ import annotations -Why: -- flepimop2 resolves `module: op_engine` to `flepimop2.engine.op_engine` -- if that module has no `build`, it looks for a pydantic BaseModel subclass - defined *in this module* and generates `build()` automatically. -""" +from dataclasses import replace +from typing import TYPE_CHECKING, Literal -from __future__ import annotations +import numpy as np +from flepimop2.configuration import IdentifierString, ModuleModel +from flepimop2.engine.abc import EngineABC +from flepimop2.exceptions import ValidationIssue +from flepimop2.typing import StateChangeEnum # noqa: TC002 +from pydantic import Field + +from op_engine.core_solver import ( + CoreSolver, +) +from op_engine.model_core import ModelCore, ModelCoreOptions + +from .config import OpEngineEngineConfig, _coerce_operator_specs, _has_operator_specs + +if TYPE_CHECKING: + from collections.abc import Callable + + from flepimop2.system.abc import SystemABC, SystemProtocol + + +def _as_float64_1d(x: object, *, name: str) -> np.ndarray: + arr = np.asarray(x, dtype=np.float64) + if arr.ndim != 1: + msg = f"{name} must be a 1D array" + raise ValueError(msg) + return np.ascontiguousarray(arr) + + +def _ensure_strictly_increasing(times: np.ndarray, *, name: str) -> None: + if times.size <= 1: + return + if np.any(np.diff(times) <= 0.0): + msg = f"{name} must be strictly increasing" + raise ValueError(msg) + + +def _rhs_from_stepper( + stepper: SystemProtocol, + *, + params: dict[IdentifierString, object], + n_state: int, +) -> Callable[[float, np.ndarray], np.ndarray]: + def rhs(time: float, state: np.ndarray) -> np.ndarray: + state_arr = np.asarray(state, dtype=np.float64) + if state_arr.shape == (n_state,): + state_arr = state_arr.reshape((n_state, 1)) + expected_shape = (n_state, 1) + if state_arr.shape != expected_shape: + msg = ( + f"RHS received unexpected state shape {state_arr.shape}; " + f"expected {expected_shape}." + ) + raise ValueError(msg) + out = np.asarray( + stepper(np.float64(time), state_arr[:, 0], **params), dtype=np.float64 + ) + if out.shape != (n_state,): + msg = f"Stepper returned shape {out.shape}; expected {(n_state,)}." + raise ValueError(msg) + return out.reshape(expected_shape) + + return rhs + + +def _extract_states_2d(core: ModelCore, *, n_state: int) -> np.ndarray: + state_array = getattr(core, "state_array", None) + if state_array is None: + msg = "ModelCore does not expose state_array; store_history must be enabled." + raise RuntimeError(msg) + arr = np.asarray(state_array, dtype=np.float64) + if arr.ndim == 3 and arr.shape[1] == n_state and arr.shape[2] == 1: + return arr[:, :, 0] + if arr.ndim == 2 and arr.shape[1] == n_state: + return arr + msg = ( + f"Unexpected state shape {arr.shape}; " + f"expected (T, {n_state}, 1) or (T, {n_state})." + ) + raise RuntimeError(msg) + + +def _make_core(times: np.ndarray, y0: np.ndarray) -> ModelCore: + n_states = int(y0.size) + core = ModelCore( + n_states, + 1, + np.asarray(times, dtype=np.float64), + options=ModelCoreOptions(other_axes=(), store_history=True, dtype=np.float64), + ) + core.set_initial_state(y0.reshape(n_states, 1)) + return core + + +class OpEngineFlepimop2Engine(ModuleModel, EngineABC): + """flepimop2 engine adapter backed by op_engine.CoreSolver.""" + + module: Literal["flepimop2.engine.op_engine"] = "flepimop2.engine.op_engine" + state_change: StateChangeEnum + config: OpEngineEngineConfig = Field(default_factory=OpEngineEngineConfig) + + def validate_system(self, system: SystemABC) -> list[ValidationIssue] | None: + """Validate system compatibility against the engine state-change mode.""" + if system.state_change != self.state_change: + return [ + ValidationIssue( + msg=( + f"Engine state change type, '{self.state_change}', is not " + "compatible with system state change type " + f"'{system.state_change}'." + ), + kind="incompatible_system", + ) + ] + return None + + def run( + self, + system: SystemABC, + eval_times: np.ndarray, + initial_state: np.ndarray, + params: dict[IdentifierString, object], + **kwargs: object, + ) -> np.ndarray: + """Execute simulation using op_engine and return `(time, state...)` output.""" + del kwargs + + times = _as_float64_1d(eval_times, name="eval_times") + _ensure_strictly_increasing(times, name="eval_times") + y0 = _as_float64_1d(initial_state, name="initial_state") + n_state = int(y0.size) + + run_cfg = self.config.to_run_config() + is_imex = run_cfg.method.startswith("imex-") + operators = run_cfg.operators + + if is_imex and not _has_operator_specs(operators): + operators = ( + _coerce_operator_specs(system.option("operators", None)) or operators + ) + run_cfg = replace(run_cfg, operators=operators) + + if is_imex and not _has_operator_specs(operators): + msg = ( + f"IMEX method '{run_cfg.method}' requires operators from engine config " + "or system option 'operators'." + ) + raise ValueError(msg) + + operator_axis = self.config.operator_axis + if operator_axis == "state": + system_axis = system.option("operator_axis", None) + if isinstance(system_axis, str | int): + operator_axis = system_axis + + stepper: SystemProtocol = system._stepper # noqa: SLF001 -from .engine import _OpEngineFlepimop2EngineImpl + mixing_kernels = system.option("mixing_kernels", None) + merged_params = { + **(mixing_kernels if isinstance(mixing_kernels, dict) else {}), + **params, + } + rhs = _rhs_from_stepper(stepper, params=merged_params, n_state=n_state) + core = _make_core(times, y0) + solver = CoreSolver( + core, + operators=operators.default if is_imex else None, + operator_axis=operator_axis, + ) + solver.run(rhs, config=run_cfg) -class OpEngineFlepimop2Engine(_OpEngineFlepimop2EngineImpl): # noqa: RUF067 - """Public op_engine-backed flepimop2 Engine (default-build enabled).""" + states = _extract_states_2d(core, n_state=n_state) + return np.asarray(np.column_stack((times, states)), dtype=np.float64) -__all__ = ["OpEngineFlepimop2Engine"] +__all__ = ["OpEngineEngineConfig", "OpEngineFlepimop2Engine"] diff --git a/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py b/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py index af15fb8..b7233ec 100644 --- a/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py +++ b/flepimop2-op_engine/src/flepimop2/engine/op_engine/config.py @@ -1,4 +1,4 @@ -"""Connector between op_engine and flepimop2 config structure.""" +"""Configuration model for op_engine provider integration.""" from __future__ import annotations @@ -13,13 +13,23 @@ RunConfig, ) -MethodName = Literal[ - "euler", - "heun", - "imex-euler", - "imex-heun-tr", - "imex-trbdf2", -] + +def _has_operator_specs(specs: OperatorSpecs | None) -> bool: + if specs is None: + return False + return any(getattr(specs, key) is not None for key in ("default", "tr", "bdf2")) + + +def _coerce_operator_specs(specs: object) -> OperatorSpecs | None: + if isinstance(specs, OperatorSpecs): + return specs + if isinstance(specs, dict): + return OperatorSpecs( + default=specs.get("default"), + tr=specs.get("tr"), + bdf2=specs.get("bdf2"), + ) + return None class OpEngineEngineConfig(BaseModel): @@ -27,104 +37,58 @@ class OpEngineEngineConfig(BaseModel): model_config = ConfigDict(extra="allow") - method: MethodName = Field(default="heun", description="Time integration method") - adaptive: bool = Field( - default=False, - description="Enable adaptive substepping between output times", - ) - strict: bool = Field( - default=True, description="Fail fast on invalid configurations" + method: Literal["euler", "heun", "imex-euler", "imex-heun-tr", "imex-trbdf2"] = ( + "heun" ) - - # tolerances + adaptive: bool = False + strict: bool = True rtol: float = Field(default=1e-6, ge=0.0) atol: float = Field(default=1e-9, ge=0.0) - - # controller dt_min: float = Field(default=0.0, ge=0.0) dt_max: float = Field(default=float("inf"), gt=0.0) safety: float = Field(default=0.9, gt=0.0) fac_min: float = Field(default=0.2, gt=0.0) fac_max: float = Field(default=5.0, gt=0.0) - gamma: float | None = Field(default=None, gt=0.0, lt=1.0) - - # Operator specs (default/tr/bdf2) for IMEX methods. - operators: dict[str, Any] | None = Field( - default=None, - description=( - "Operator specifications for IMEX methods. " - "Required when method is an IMEX variant." - ), - ) - - operator_axis: str | int = Field( - default="state", - description="Axis along which implicit operators act (name or index).", - ) + operator_axis: str | int = "state" + operators: dict[str, Any] = Field(default_factory=dict) @model_validator(mode="after") - def _validate_imex_requirements(self) -> OpEngineEngineConfig: - method = str(self.method) - if method.startswith("imex-") and not self._has_any_operator_specs( - self.operators + def _validate_explicit_empty_operators(self) -> OpEngineEngineConfig: + if ( + self.method.startswith("imex-") + and "operators" in self.model_fields_set + and not _has_operator_specs(_coerce_operator_specs(self.operators)) ): msg = ( - f"IMEX method '{method}' requires operator specifications, " - "but no operators were provided in the engine config." + f"IMEX method '{self.method}' received operators, " + "but none were populated. Provide at least one stage " + "or omit operators to use system options." ) raise ValueError(msg) return self - @staticmethod - def _has_any_operator_specs(operators: dict[str, Any] | None) -> bool: - """Return True if any operator spec is provided.""" - if operators is None: - return False - return any( - operators.get(name) is not None for name in ("default", "tr", "bdf2") - ) - def to_run_config(self) -> RunConfig: - """ - Convert to op_engine RunConfig. + """Convert this provider config to an op_engine `RunConfig`. Returns: - RunConfig instance reflecting this configuration. + `RunConfig` derived from this provider configuration. """ - adaptive_cfg = AdaptiveConfig(rtol=self.rtol, atol=self.atol) - dt_controller = DtControllerConfig( - dt_min=self.dt_min, - dt_max=self.dt_max, - safety=self.safety, - fac_min=self.fac_min, - fac_max=self.fac_max, - ) - - op_specs = self._coerce_operator_specs(self.operators) - return RunConfig( method=self.method, adaptive=self.adaptive, strict=self.strict, - adaptive_cfg=adaptive_cfg, - dt_controller=dt_controller, - operators=op_specs, + adaptive_cfg=AdaptiveConfig(rtol=self.rtol, atol=self.atol), + dt_controller=DtControllerConfig( + dt_min=self.dt_min, + dt_max=self.dt_max, + safety=self.safety, + fac_min=self.fac_min, + fac_max=self.fac_max, + ), + operators=_coerce_operator_specs(self.operators) or OperatorSpecs(), gamma=self.gamma, ) - @staticmethod - def _coerce_operator_specs(operators: dict[str, Any] | None) -> OperatorSpecs: - """ - Normalize operator inputs into OperatorSpecs. - Returns: - OperatorSpecs with default/tr/bdf2 fields populated when provided. - """ - if operators is None: - return OperatorSpecs() - return OperatorSpecs( - default=operators.get("default"), - tr=operators.get("tr"), - bdf2=operators.get("bdf2"), - ) +__all__ = ["OpEngineEngineConfig", "_coerce_operator_specs", "_has_operator_specs"] diff --git a/flepimop2-op_engine/src/flepimop2/engine/op_engine/engine.py b/flepimop2-op_engine/src/flepimop2/engine/op_engine/engine.py deleted file mode 100644 index 81cddaa..0000000 --- a/flepimop2-op_engine/src/flepimop2/engine/op_engine/engine.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -flepimop2 Engine adapter backed by op_engine.CoreSolver. - -This module provides a flepimop2-compatible Engine implementation that runs -op_engine explicit methods ("euler", "heun") and supports IMEX methods only -when operator specifications are provided by configuration (validated at parse -time by OpEngineEngineConfig). - -Contract: -- Accepts a flepimop2 System stepper: stepper(t, state_1d, **params) -> dstate/dt (1D) -- Accepts 1D eval-times and 1D initial-state arrays from flepimop2 -- Internally uses ModelCore with state_shape (n_states, 1) -- Returns a (T, 1 + n_states) float64 array with time in the first column -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Final, Literal - -if TYPE_CHECKING: - from collections.abc import Callable - -import numpy as np -from flepimop2.configuration import IdentifierString, ModuleModel -from flepimop2.engine.abc import EngineABC -from flepimop2.system.abc import SystemABC, SystemProtocol -from pydantic import Field - -from op_engine.core_solver import CoreSolver -from op_engine.model_core import ModelCore, ModelCoreOptions - -from .config import OpEngineEngineConfig -from .types import ( - Float64Array, - Float64Array2D, - as_float64_1d, - ensure_strictly_increasing_times, -) - -_RHS_BAD_SHAPE_MSG: Final[str] = ( - "RHS received unexpected state shape {actual}; expected {expected}." -) - -_STEPPER_BAD_SHAPE_MSG: Final[str] = ( - "Stepper returned shape {actual}; expected {expected}." -) - -_STATE_ARRAY_MISSING_MSG: Final[str] = ( - "ModelCore does not expose state_array; store_history must be enabled." -) - -_STATE_ARRAY_BAD_SHAPE_MSG: Final[str] = ( - "Unexpected state shape {actual}; expected (T, {n_state}, 1) or (T, {n_state})." -) - - -def _rhs_from_stepper( - stepper: SystemProtocol, - *, - params: dict[IdentifierString, object], - n_state: int, -) -> Callable[[float, np.ndarray], np.ndarray]: - """ - Wrap a flepimop2 stepper into an op_engine RHS callable. - - Args: - stepper: flepimop2 SystemProtocol stepper function. - params: Mapping of parameter names to values. - n_state: Number of state variables. - - Returns: - RHS function compatible with op_engine.CoreSolver. - """ - - def rhs(t: float, y: np.ndarray) -> np.ndarray: - """ - RHS function wrapping the flepimop2 stepper. - - Args: - t: Current time. - y: Current state array with shape (n_state, 1). - - Raises: - ValueError: If input or output shapes are invalid. - - Returns: - 2D array of shape (n_state, 1) representing dstate/dt - """ - y_arr = np.asarray(y, dtype=np.float64) - - expected_2d = (n_state, 1) - if y_arr.shape != expected_2d: - if y_arr.shape == (n_state,): - y_arr = y_arr.reshape(expected_2d) - else: - msg = _RHS_BAD_SHAPE_MSG.format( - actual=y_arr.shape, - expected=expected_2d, - ) - raise ValueError(msg) - - y1d = y_arr[:, 0] - out1d = stepper(np.float64(t), y1d, **params) - out_arr = np.asarray(out1d, dtype=np.float64) - - expected_1d = (n_state,) - if out_arr.shape != expected_1d: - msg = _STEPPER_BAD_SHAPE_MSG.format( - actual=out_arr.shape, - expected=expected_1d, - ) - raise ValueError(msg) - - return out_arr.reshape(expected_2d) - - return rhs - - -def _extract_states_2d(core: ModelCore, *, n_state: int) -> Float64Array2D: - """ - Extract stored trajectory from ModelCore. - - Args: - core: ModelCore instance - n_state: Number of state variables - - Raises: - RuntimeError: If state_array is missing or has an unexpected shape. - - Returns: - 2D float64 array of stored states with shape (T, n_state). - """ - state_array = getattr(core, "state_array", None) - if state_array is None: - raise RuntimeError(_STATE_ARRAY_MISSING_MSG) - - arr = np.asarray(state_array, dtype=np.float64) - - if arr.ndim == 3 and arr.shape[1] == n_state and arr.shape[2] == 1: - return arr[:, :, 0] - - if arr.ndim == 2 and arr.shape[1] == n_state: - return arr - - msg = _STATE_ARRAY_BAD_SHAPE_MSG.format( - actual=arr.shape, - n_state=n_state, - ) - raise RuntimeError(msg) - - -def _make_core(times: Float64Array, y0: Float64Array) -> ModelCore: - """ - Construct ModelCore with history enabled. - - Args: - times: 1D float64 array of evaluation times. - y0: 1D float64 array of initial state. - - Returns: - Configured ModelCore instance. - """ - n_states = int(y0.size) - n_subgroups = 1 - - opts = ModelCoreOptions( - other_axes=(), - store_history=True, - dtype=np.float64, - ) - - core = ModelCore( - n_states, - n_subgroups, - np.asarray(times, dtype=np.float64), - options=opts, - ) - - core.set_initial_state(y0.reshape(n_states, 1)) - return core - - -class _OpEngineFlepimop2EngineImpl(ModuleModel, EngineABC): - """flepimop2 engine adapter backed by op_engine.CoreSolver.""" - - module: Literal["flepimop2.engine.op_engine"] = "flepimop2.engine.op_engine" - config: OpEngineEngineConfig = Field(default_factory=OpEngineEngineConfig) - - def run( - self, - system: SystemABC, - eval_times: np.ndarray, - initial_state: np.ndarray, - params: dict[IdentifierString, object], - **kwargs: object, - ) -> np.ndarray: - """ - Execute the system using op_engine. - - Args: - system: flepimop2 System exposing a stepper. - eval_times: 1D array of evaluation times. - initial_state: 1D array of initial state. - params: Mapping of parameter names to values. - **kwargs: Additional engine-specific keyword arguments (ignored). - - Raises: - TypeError: If system does not expose a valid stepper. - - Returns: - 2D array of shape (T, 1 + n_states) with time in the first column. - """ - del kwargs - - times = as_float64_1d(eval_times, name="eval_times") - ensure_strictly_increasing_times(times, name="eval_times") - - y0 = as_float64_1d(initial_state, name="initial_state") - n_state = int(y0.size) - - # Note: IMEX/operator requirements are validated at config-parse time by - # OpEngineEngineConfig, so no runtime guard is needed here. - - stepper = getattr(system, "_stepper", None) - if not isinstance(stepper, SystemProtocol): - msg = "system does not expose a valid flepimop2 SystemProtocol stepper" - raise TypeError(msg) - - run_cfg = self.config.to_run_config() - - # Merge any precomputed mixing kernels exposed by op_system connector - kernels = getattr(system, "mixing_kernels", {}) - kernel_params = kernels if isinstance(kernels, dict) else {} - merged_params: dict[IdentifierString, object] = {**kernel_params, **params} - - rhs = _rhs_from_stepper(stepper, params=merged_params, n_state=n_state) - - core = _make_core(times, y0) - - op_default = run_cfg.operators.default - - solver = CoreSolver( - core, - operators=op_default, - operator_axis=self.config.operator_axis, - ) - solver.run(rhs, config=run_cfg) - - states = _extract_states_2d(core, n_state=n_state) - out = np.column_stack((times, states)) - return np.asarray(out, dtype=np.float64) diff --git a/flepimop2-op_engine/src/flepimop2/engine/op_engine/errors.py b/flepimop2-op_engine/src/flepimop2/engine/op_engine/errors.py deleted file mode 100644 index 3e94a8e..0000000 --- a/flepimop2-op_engine/src/flepimop2/engine/op_engine/errors.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Error types and dependency-guard utilities for op_engine.flepimop2. - -Design intent: -- op_engine can be installed without flepimop2 -- op_engine.flepimop2 fails fast with clear, actionable messages if used without - the optional extra dependencies. -""" - -from __future__ import annotations - -from enum import StrEnum -from importlib.util import find_spec -from typing import Final - -_FLEPIMOP2_EXTRA_INSTALL_MSG: Final[str] = ( - "Install the optional dependency group with:\n" - " pip install 'op_engine[flepimop2]'\n" - "or, if you are using uv:\n" - " uv pip install '.[flepimop2]'" -) - - -class ErrorCode(StrEnum): - """Machine-readable classification for op_engine.flepimop2 failures. - - Use these codes to support consistent logging and (optional) programmatic - recovery without requiring many custom exception subclasses. - """ - - OPTIONAL_DEPENDENCY_MISSING = "optional_dependency_missing" - INVALID_ENGINE_CONFIG = "invalid_engine_config" - UNSUPPORTED_METHOD = "unsupported_method" - INVALID_STATE_SHAPE = "invalid_state_shape" - INVALID_PARAMETERS = "invalid_parameters" - - -class OpEngineFlepimop2Error(Exception): - """Base exception for op_engine.flepimop2 integration errors. - - This exists so callers can catch integration-layer failures explicitly - without depending on a wide taxonomy of custom subclasses. - """ - - def __init__(self, message: str, *, code: ErrorCode | None = None) -> None: - """ - Initialize an OpEngineFlepimop2Error. - - Args: - message: Human-readable error message. - code: Optional machine-readable error code classifying the error. - """ - super().__init__(message) - self.code: ErrorCode | None = code - - -class OptionalDependencyMissingError(OpEngineFlepimop2Error, ImportError): - """Raised when an optional dependency is required but missing.""" - - -class EngineConfigError(OpEngineFlepimop2Error, ValueError): - """Raised when a flepimop2 engine config is invalid or incomplete.""" - - -def require_flepimop2() -> None: - """Require that flepimop2 is importable. - - Raises: - OptionalDependencyMissingError: If flepimop2 cannot be imported. - """ - if find_spec("flepimop2") is not None: - return - - msg = ( - "The op_engine.flepimop2 integration requires flepimop2, but it is not " - "available in this environment.\n\n" - "Import detail: Module spec not found\n\n" - f"{_FLEPIMOP2_EXTRA_INSTALL_MSG}" - ) - - raise OptionalDependencyMissingError( - msg, code=ErrorCode.OPTIONAL_DEPENDENCY_MISSING - ) - - -def raise_unsupported_imex(method: str, *, reason: str) -> None: - """ - Raise a standardized error for IMEX configuration issues. - - Args: - method: Name of the IMEX method. - reason: Explanation of why the method is unsupported. - - Raises: - ValueError: Always. - """ - msg = ( - f"Method '{method}' is not supported under the current flepimop2 engine " - "configuration.\n" - f"Reason: {reason}\n\n" - "IMEX methods require operator specifications that provide an implicit " - "linear operator A (or factories for dt-dependent operators)." - ) - raise ValueError(msg) from OpEngineFlepimop2Error( - msg, code=ErrorCode.UNSUPPORTED_METHOD - ) - - -def raise_invalid_engine_config( - *, - missing: list[str] | None = None, - detail: str | None = None, -) -> None: - """ - Raise a standardized engine configuration error. - - Args: - missing: List of missing required fields, if any. - detail: Additional detail about the configuration issue. - - Raises: - EngineConfigError: Always. - """ - parts: list[str] = ["Invalid op_engine.flepimop2 engine configuration."] - if missing: - parts.append(f"Missing required field(s): {sorted(set(missing))}.") - if detail: - parts.append(f"Detail: {detail}") - msg = " ".join(parts) - - raise EngineConfigError(msg, code=ErrorCode.INVALID_ENGINE_CONFIG) - - -def raise_state_shape_error(*, name: str, expected: str, got: object) -> None: - """ - Raise a standardized state/time array shape error. - - Args: - name: Name of the array (for error messages). - expected: Description of the expected shape/value. - got: Actual value received. - - Raises: - ValueError: Always. - """ - msg = f"{name} has an invalid shape/value. Expected {expected}. Got: {got!r}." - raise ValueError(msg) from OpEngineFlepimop2Error( - msg, code=ErrorCode.INVALID_STATE_SHAPE - ) - - -def raise_parameter_error(*, detail: str) -> None: - """ - Raise a standardized parameter/type error. - - Args: - detail: Detail text describing the parameter issue. - - Raises: - TypeError: Always. - """ - msg = f"Invalid parameters for op_engine.flepimop2 adapter: {detail}" - raise TypeError(msg) from OpEngineFlepimop2Error( - msg, code=ErrorCode.INVALID_PARAMETERS - ) diff --git a/flepimop2-op_engine/src/flepimop2/engine/op_engine/types.py b/flepimop2-op_engine/src/flepimop2/engine/op_engine/types.py deleted file mode 100644 index c85156c..0000000 --- a/flepimop2-op_engine/src/flepimop2/engine/op_engine/types.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Type definitions for the op_engine.flepimop2 integration layer. - -This module intentionally avoids runtime imports from flepimop2 so op_engine -can be installed without the optional extra. - -It mirrors flepimop2's public interfaces in a dependency-free way and defines -adapter-specific configuration types. -""" - -from __future__ import annotations - -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from typing import Final, Literal, Protocol, TypeAlias, TypedDict, runtime_checkable - -import numpy as np -from flepimop2.configuration import IdentifierString # noqa: TC002 -from numpy.typing import NDArray - -# ----------------------------------------------------------------------------- -# Core numeric aliases -# ----------------------------------------------------------------------------- - -Float64Array: TypeAlias = NDArray[np.float64] - -# Shape-intent aliases (NumPy typing does not encode shapes; these are semantic). -Float64Array2D: TypeAlias = NDArray[np.float64] - -# Generic floating tensor used by internal solver interfaces. -FloatArray: TypeAlias = NDArray[np.floating] - -_TIME_NOT_1D_MSG: Final[str] = "{name} must be a 1D array" -_TIME_NOT_INCREASING_MSG: Final[str] = "{name} must be strictly increasing" - -# ----------------------------------------------------------------------------- -# flepimop2-compatible protocol mirrors -# ----------------------------------------------------------------------------- - - -@runtime_checkable -class SystemStepper(Protocol): - """Protocol for flepimop2-compatible system stepper functions.""" - - def __call__( - self, - time: np.float64, - state: Float64Array, - **params: object, - ) -> Float64Array: - """Compute dstate/dt at a given time/state.""" - ... - - -@runtime_checkable -class EngineRunner(Protocol): - """Protocol for flepimop2-compatible engine runner functions.""" - - def __call__( - self, - stepper: SystemStepper, - times: Float64Array, - state: Float64Array, - params: Mapping[IdentifierString, object], - **engine_kwargs: object, - ) -> Float64Array2D: - """Run the stepper over times and return (T, n_state) output.""" - ... - - -# ----------------------------------------------------------------------------- -# Adapter-level configuration typing -# ----------------------------------------------------------------------------- - -MethodName: TypeAlias = Literal[ - "euler", - "heun", - "imex-euler", - "imex-heun-tr", - "imex-trbdf2", -] - -OperatorMode: TypeAlias = Literal[ - "none", - "static", - "time", - "stage_state", -] - - -class OperatorSpecDict(TypedDict, total=False): - """Dictionary form of operator specifications for IMEX methods.""" - - default: object - tr: object - bdf2: object - - -class AdapterKwargs(TypedDict, total=False): - """Keyword arguments accepted by the flepimop2 adapter.""" - - method: MethodName - adaptive: bool - strict: bool - - # tolerances - rtol: float - atol: float | Float64Array - dt_init: float | None - - # controller - dt_min: float - dt_max: float - safety: float - fac_min: float - fac_max: float - - # limits - max_reject: int - max_steps: int - - # IMEX - operators: OperatorSpecDict - gamma: float | None - - # axis routing - operator_axis: str | int - - -@dataclass(frozen=True, slots=True) -class EngineAdapterConfig: - """Normalized internal adapter configuration.""" - - method: MethodName = "heun" - adaptive: bool = True - strict: bool = True - - rtol: float = 1e-6 - atol: float | Float64Array = 1e-9 - dt_init: float | None = None - - dt_min: float = 0.0 - dt_max: float = float("inf") - safety: float = 0.9 - fac_min: float = 0.2 - fac_max: float = 5.0 - - max_reject: int = 25 - max_steps: int = 1_000_000 - - operators: OperatorSpecDict | None = None - gamma: float | None = None - - operator_axis: str | int = "state" - - -# ----------------------------------------------------------------------------- -# Adapter helpers -# ----------------------------------------------------------------------------- - - -def as_float64_1d(x: object, *, name: str = "array") -> Float64Array: - """Convert input to contiguous float64 1D array. - - Args: - x: Input array-like. - name: Name used in error messages. - - Returns: - Contiguous float64 1D array. - - Raises: - ValueError: If input cannot be represented as a 1D array. - """ - arr = np.asarray(x, dtype=np.float64) - if arr.ndim != 1: - raise ValueError(_TIME_NOT_1D_MSG.format(name=name)) - return np.ascontiguousarray(arr) - - -def ensure_strictly_increasing_times( - times: Float64Array, *, name: str = "times" -) -> None: - """Validate that a time vector is strictly increasing. - - Args: - times: 1D float64 time array. - name: Name used in error messages. - - Raises: - ValueError: If times is not strictly increasing. - """ - if times.size <= 1: - return - dt = np.diff(np.asarray(times, dtype=np.float64)) - if np.any(dt <= 0.0): - raise ValueError(_TIME_NOT_INCREASING_MSG.format(name=name)) - - -def normalize_params( - params: Mapping[IdentifierString, object] | None, -) -> dict[IdentifierString, object]: - """Normalize parameter mapping to a plain dictionary. - - Args: - params: Input parameter mapping or None. - - Returns: - Plain dictionary of parameters. - """ - if params is None: - return {} - return dict(params) - - -# ----------------------------------------------------------------------------- -# RHS tensor function alias (op_engine internal) -# ----------------------------------------------------------------------------- - -RhsTensorFunc: TypeAlias = Callable[[float, FloatArray], FloatArray] diff --git a/flepimop2-op_engine/tests/data/config.yaml b/flepimop2-op_engine/tests/data/config.yaml new file mode 100644 index 0000000..24ee4b5 --- /dev/null +++ b/flepimop2-op_engine/tests/data/config.yaml @@ -0,0 +1,34 @@ +--- +name: "example-provider" +system: + - module: "wrapper" + state_change: "flow" + script: "external_provider/src/flepimop2/system/sir.py" +engine: + - module: "op_engine" + state_change: "flow" + config: + method: "heun" + adaptive: false + strict: true +backend: + - module: "csv" +simulate: + demo: + times: "0.0:0.1:1" +parameters: + beta: + module: "fixed" + value: 0.3 + gamma: + module: "fixed" + value: 0.1 + s0: + module: "fixed" + value: 999 + i0: + module: "fixed" + value: 1 + r0: + module: "fixed" + value: 0 diff --git a/flepimop2-op_engine/tests/data/sir.py b/flepimop2-op_engine/tests/data/sir.py new file mode 100644 index 0000000..ea57c16 --- /dev/null +++ b/flepimop2-op_engine/tests/data/sir.py @@ -0,0 +1,18 @@ +"""SIR model plugin for integration testing.""" + +import numpy as np + + +def stepper( + time: np.float64, # noqa: ARG001 + state: np.ndarray, + *, + beta: float = 0.3, + gamma: float = 0.1, + **kwargs: object, # noqa: ARG001 +) -> np.ndarray: + """Return dstate/dt for a simple SIR model.""" + y_s, y_i, _ = np.asarray(state, dtype=float) + infection = beta * y_s * y_i / np.sum(state) + recovery = gamma * y_i + return np.array([-infection, infection - recovery, recovery], dtype=float) diff --git a/flepimop2-op_engine/tests/test_config.py b/flepimop2-op_engine/tests/test_config.py index efa9cc3..20e06e8 100644 --- a/flepimop2-op_engine/tests/test_config.py +++ b/flepimop2-op_engine/tests/test_config.py @@ -1,4 +1,3 @@ -# tests/flepimop2/test_config.py """Tests for op_engine.flepimop2.config.""" from __future__ import annotations @@ -9,7 +8,7 @@ from op_engine.core_solver import OperatorSpecs, RunConfig # noqa: E402 from pydantic import ValidationError # noqa: E402 -from flepimop2.engine.op_engine.config import OpEngineEngineConfig # noqa: E402 +from flepimop2.engine.op_engine import OpEngineEngineConfig # noqa: E402 def _has_any_operator_specs(specs: OperatorSpecs) -> bool: @@ -136,39 +135,29 @@ def test_engine_config_gamma_bounds_validation() -> None: ) -def test_engine_config_imex_requires_operators() -> None: - """IMEX methods should require operator specifications at config-parse time.""" - with pytest.raises(ValidationError): - OpEngineEngineConfig(method="imex-euler") +def test_engine_config_imex_allows_deferred_operators() -> None: + """IMEX methods may omit operators to defer to system options at runtime.""" + cfg = OpEngineEngineConfig(method="imex-euler") + run = cfg.to_run_config() + assert run.method == "imex-euler" + assert isinstance(run.operators, OperatorSpecs) + assert not _has_any_operator_specs(run.operators) - with pytest.raises(ValidationError): - OpEngineEngineConfig(method="imex-heun-tr") +def test_engine_config_imex_rejects_explicitly_empty_operator_block() -> None: + """Providing an empty operator block should raise validation errors.""" with pytest.raises(ValidationError): - OpEngineEngineConfig(method="imex-trbdf2", gamma=0.5) + OpEngineEngineConfig(method="imex-heun-tr", operators={}) + - # Providing operators should pass validation. +def test_engine_config_imex_with_operators_still_valid() -> None: + """Providing IMEX operators explicitly should still validate.""" cfg = OpEngineEngineConfig( method="imex-euler", operators={"default": "sentinel"}, ) run = cfg.to_run_config() assert run.method == "imex-euler" - assert isinstance(run.operators, OperatorSpecs) - assert _has_any_operator_specs(run.operators) - assert run.operators.default == "sentinel" - - -def test_engine_config_operator_dict_coerces_to_specs() -> None: - """Operator dict input is coerced into OperatorSpecs with stage keys.""" - cfg = OpEngineEngineConfig( - method="imex-trbdf2", - operators={"default": 1, "tr": 2, "bdf2": 3}, - gamma=0.6, - ) - run = cfg.to_run_config() assert isinstance(run.operators, OperatorSpecs) - assert run.operators.default == 1 - assert run.operators.tr == 2 - assert run.operators.bdf2 == 3 + assert _has_any_operator_specs(run.operators) diff --git a/flepimop2-op_engine/tests/test_engine.py b/flepimop2-op_engine/tests/test_engine.py index a0dddc1..da7d889 100644 --- a/flepimop2-op_engine/tests/test_engine.py +++ b/flepimop2-op_engine/tests/test_engine.py @@ -1,21 +1,17 @@ -# tests/test_engine.py -"""Unit tests for flepimop2.engine.op_engine.engine.""" +"""Unit tests for flepimop2.engine.op_engine.""" from __future__ import annotations -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING if TYPE_CHECKING: - from flepimop2.system.abc import SystemABC, SystemProtocol - - from flepimop2.engine.op_engine.types import IdentifierString + from flepimop2.system.abc import SystemProtocol import numpy as np import pytest +from flepimop2.system.abc import SystemABC -from flepimop2.engine.op_engine.engine import ( - _OpEngineFlepimop2EngineImpl, # noqa: PLC2701 -) +from flepimop2.engine.op_engine import OpEngineFlepimop2Engine # ----------------------------------------------------------------------------- # Test helpers @@ -36,46 +32,27 @@ def __call__( return np.asarray(state, dtype=np.float64) -class _GoodSystem: - """System-like object exposing a valid stepper via _stepper.""" - - def __init__(self) -> None: - self._stepper: SystemProtocol = _GoodStepper() - +class _GoodSystem(SystemABC): + """SystemABC implementation exposing a valid stepper via _stepper.""" -class _BadSystem: - """System-like object exposing an invalid _stepper.""" + module = "flepimop2.system.test_good" + state_change = "flow" def __init__(self) -> None: - self._stepper: object = object() - - -class _KernelStepper: - """Stepper that uses a kernel parameter to scale a constant RHS.""" - - def __call__( - self, time: np.float64, state: np.ndarray, **params: object - ) -> np.ndarray: - _ = time - _ = state - k = float(params.get("K", 0.0)) - return np.asarray([k], dtype=np.float64) - - -class _KernelSystem: - """System exposing a stepper and precomputed mixing_kernels.""" + super().__init__() + self._stepper: SystemProtocol = _GoodStepper() + self.options = { + "operators": { + "default": (np.eye(1, dtype=np.float64), np.eye(1, dtype=np.float64)) + } + } - def __init__(self, k: float) -> None: - self._stepper: SystemProtocol = _KernelStepper() - self.mixing_kernels = {"K": k} +class _DeltaSystem(_GoodSystem): + """SystemABC implementation with incompatible state_change.""" -class _ImexSystem: - """System exposing an identity stepper for IMEX tests.""" - - def __init__(self, n: int) -> None: - self._stepper: SystemProtocol = _GoodStepper() - self.n = n + module = "flepimop2.system.test_delta" + state_change = "delta" # ----------------------------------------------------------------------------- @@ -85,10 +62,9 @@ def __init__(self, n: int) -> None: def test_engine_default_config_constructs() -> None: """Engine can be constructed with defaults.""" - engine = _OpEngineFlepimop2EngineImpl() + engine = OpEngineFlepimop2Engine(state_change="flow") - assert isinstance(engine, _OpEngineFlepimop2EngineImpl) - # Default comes from OpEngineEngineConfig; we do not assert its exact value here. + assert isinstance(engine, OpEngineFlepimop2Engine) assert engine.module == "flepimop2.engine.op_engine" @@ -99,13 +75,13 @@ def test_engine_default_config_constructs() -> None: def test_engine_run_basic_shape_and_dtype() -> None: """Engine returns correctly shaped float64 output array.""" - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _GoodSystem()) + engine = OpEngineFlepimop2Engine(state_change="flow") + system = _GoodSystem() times = np.array([0.0, 0.5, 1.0], dtype=np.float64) y0 = np.array([1.0, 2.0], dtype=np.float64) - params: dict[IdentifierString, object] = {} + params: dict[str, object] = {} out = engine.run(system, times, y0, params) @@ -120,13 +96,13 @@ def test_engine_run_identity_rhs_behavior() -> None: This test validates wiring correctness, not numerical accuracy. """ - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _GoodSystem()) + engine = OpEngineFlepimop2Engine(state_change="flow") + system = _GoodSystem() times = np.array([0.0, 0.1, 0.2], dtype=np.float64) y0 = np.array([1.0], dtype=np.float64) - params: dict[IdentifierString, object] = {} + params: dict[str, object] = {} out = engine.run(system, times, y0, params) @@ -135,47 +111,6 @@ def test_engine_run_identity_rhs_behavior() -> None: assert state_values[2] >= state_values[1] -def test_engine_passes_mixing_kernels_into_params() -> None: - """mixing_kernels from the system are merged into RHS params.""" - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _KernelSystem(k=2.5)) - - times = np.array([0.0, 1.0], dtype=np.float64) - y0 = np.array([1.0], dtype=np.float64) - - params: dict[IdentifierString, object] = {} - - out = engine.run(system, times, y0, params) - - # dy/dt = K = 2.5, Heun with dt=1.0 gives y1 = 1 + 0.5*(K+K) = 3.5 - np.testing.assert_allclose(out[-1, 1], 3.5, rtol=1e-12, atol=0.0) - - -def test_engine_imex_identity_with_identity_ops() -> None: - """IMEX path accepts operator specs and runs with identity operators.""" - engine = _OpEngineFlepimop2EngineImpl( - config={ - "method": "imex-euler", - "operators": { - "default": (np.eye(1, dtype=np.float64), np.eye(1, dtype=np.float64)), - }, - "adaptive": False, - } - ) - system = cast("SystemABC", _ImexSystem(n=1)) - - times = np.array([0.0, 0.5, 1.0], dtype=np.float64) - y0 = np.array([1.0], dtype=np.float64) - - params: dict[IdentifierString, object] = {} - - out = engine.run(system, times, y0, params) - - assert out.shape == (3, 2) - # Identity RHS dy/dt = y; implicit Euler with identity L/R behaves like explicit. - assert np.all(np.isfinite(out)) - - # ----------------------------------------------------------------------------- # Error handling # ----------------------------------------------------------------------------- @@ -183,13 +118,13 @@ def test_engine_imex_identity_with_identity_ops() -> None: def test_engine_rejects_non_increasing_times() -> None: """Engine rejects non-strictly-increasing time grids.""" - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _GoodSystem()) + engine = OpEngineFlepimop2Engine(state_change="flow") + system = _GoodSystem() times = np.array([0.0, 0.0, 1.0], dtype=np.float64) y0 = np.array([1.0], dtype=np.float64) - params: dict[IdentifierString, object] = {} + params: dict[str, object] = {} with pytest.raises(ValueError, match="strictly increasing"): engine.run(system, times, y0, params) @@ -197,27 +132,25 @@ def test_engine_rejects_non_increasing_times() -> None: def test_engine_rejects_non_1d_initial_state() -> None: """Engine rejects non-1D initial state arrays.""" - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _GoodSystem()) + engine = OpEngineFlepimop2Engine(state_change="flow") + system = _GoodSystem() times = np.array([0.0, 1.0], dtype=np.float64) y0 = np.array([[1.0, 2.0]], dtype=np.float64) - params: dict[IdentifierString, object] = {} + params: dict[str, object] = {} with pytest.raises(ValueError, match="1D"): engine.run(system, times, y0, params) -def test_engine_rejects_missing_stepper() -> None: - """Engine raises TypeError if system does not expose a valid stepper.""" - engine = _OpEngineFlepimop2EngineImpl() - system = cast("SystemABC", _BadSystem()) +def test_validate_system_checks_state_change() -> None: + """Engine validates state_change compatibility via validate_system.""" + engine = OpEngineFlepimop2Engine(state_change="flow") + good = _GoodSystem() + assert engine.validate_system(good) is None - times = np.array([0.0, 1.0], dtype=np.float64) - y0 = np.array([1.0], dtype=np.float64) - - params: dict[IdentifierString, object] = {} - - with pytest.raises(TypeError, match="SystemProtocol"): - engine.run(system, times, y0, params) + bad = _DeltaSystem() + issues = engine.validate_system(bad) + assert issues is not None + assert issues[0].kind == "incompatible_system" diff --git a/flepimop2-op_engine/tests/test_errors.py b/flepimop2-op_engine/tests/test_errors.py deleted file mode 100644 index 2c63331..0000000 --- a/flepimop2-op_engine/tests/test_errors.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Unit tests for op_engine.flepimop2.errors.""" - -from __future__ import annotations - -from importlib.util import find_spec - -import pytest - -from flepimop2.engine.op_engine import errors - - -def test_require_flepimop2_raises_when_missing(monkeypatch: pytest.MonkeyPatch) -> None: - """require_flepimop2 raises OptionalDependencyMissingError when unavailable.""" - monkeypatch.setattr(errors, "find_spec", lambda _name: None) - - with pytest.raises(errors.OptionalDependencyMissingError) as excinfo: - errors.require_flepimop2() - - exc = excinfo.value - msg = str(exc) - - assert "requires flepimop2" in msg.lower() - assert "op_engine[flepimop2]" in msg - assert "import detail" in msg.lower() - assert getattr(exc, "code", None) == errors.ErrorCode.OPTIONAL_DEPENDENCY_MISSING - - -def test_require_flepimop2_noop_when_present(monkeypatch: pytest.MonkeyPatch) -> None: - """require_flepimop2 should not raise when flepimop2 is available.""" - sentinel = object() - monkeypatch.setattr(errors, "find_spec", lambda _name: sentinel) - errors.require_flepimop2() - - -def test_require_flepimop2_matches_environment() -> None: - """require_flepimop2 should raise iff flepimop2 is not importable.""" - installed = find_spec("flepimop2") is not None - - if installed: - errors.require_flepimop2() - else: - with pytest.raises(errors.OptionalDependencyMissingError): - errors.require_flepimop2() - - -def test_raise_unsupported_imex_raises_with_reason_and_code() -> None: - """raise_unsupported_imex raises ValueError and chains an OpEngineFlepimop2Error.""" - method = "imex-euler" - reason = "operators.default is missing" - - with pytest.raises( - ValueError, match=r"Method 'imex-euler' is not supported" - ) as excinfo: - errors.raise_unsupported_imex(method, reason=reason) - - exc = excinfo.value - msg = str(exc) - - assert method in msg - assert reason in msg - assert "imex methods require operator specifications" in msg.lower() - - cause = exc.__cause__ - assert isinstance(cause, errors.OpEngineFlepimop2Error) - assert cause.code == errors.ErrorCode.UNSUPPORTED_METHOD - - -def test_raise_invalid_engine_config_includes_missing_and_detail() -> None: - """raise_invalid_engine_config should include missing keys and detail text.""" - with pytest.raises(errors.EngineConfigError) as excinfo: - errors.raise_invalid_engine_config( - missing=["operators", "method", "operators"], - detail="Bad config shape", - ) - - exc = excinfo.value - msg = str(exc) - - assert "invalid op_engine.flepimop2 engine configuration" in msg.lower() - assert "['method', 'operators']" in msg - assert "detail: bad config shape" in msg.lower() - assert getattr(exc, "code", None) == errors.ErrorCode.INVALID_ENGINE_CONFIG - - -def test_raise_invalid_engine_config_minimal_message() -> None: - """raise_invalid_engine_config should still raise with a minimal message.""" - with pytest.raises(errors.EngineConfigError) as excinfo: - errors.raise_invalid_engine_config() - - exc = excinfo.value - msg = str(exc) - - assert "invalid op_engine.flepimop2 engine configuration" in msg.lower() - assert getattr(exc, "code", None) == errors.ErrorCode.INVALID_ENGINE_CONFIG - - -def test_raise_state_shape_error_includes_name_expected_got_and_code() -> None: - """raise_state_shape_error should surface name/expected/got clearly.""" - with pytest.raises(ValueError, match=r"y0 has an invalid shape/value") as excinfo: - errors.raise_state_shape_error(name="y0", expected="(n_state,)", got=(3, 7)) - - exc = excinfo.value - msg = str(exc) - - assert "y0" in msg - assert "expected (n_state,)" in msg.lower() - assert "(3, 7)" in msg - - cause = exc.__cause__ - assert isinstance(cause, errors.OpEngineFlepimop2Error) - assert cause.code == errors.ErrorCode.INVALID_STATE_SHAPE - - -def test_raise_parameter_error_raises_typeerror_and_code() -> None: - """raise_parameter_error should raise TypeError and chain a coded cause.""" - with pytest.raises(TypeError, match=r"Invalid parameters for op_engine\.flepimop2"): - errors.raise_parameter_error(detail="dt_init must be positive") - - try: - errors.raise_parameter_error(detail="dt_init must be positive") - except TypeError as exc: - msg = str(exc) - assert "invalid parameters" in msg.lower() - assert "dt_init must be positive" in msg - - cause = exc.__cause__ - assert isinstance(cause, errors.OpEngineFlepimop2Error) - assert cause.code == errors.ErrorCode.INVALID_PARAMETERS diff --git a/flepimop2-op_engine/tests/test_integration_cli.py b/flepimop2-op_engine/tests/test_integration_cli.py index 6166bce..c7b826b 100644 --- a/flepimop2-op_engine/tests/test_integration_cli.py +++ b/flepimop2-op_engine/tests/test_integration_cli.py @@ -1,141 +1,40 @@ -"""Integration test for the flepimop2 simulate pipeline using the op_engine provider. +"""Integration test for external provider functionality.""" -This test invokes the public `flepimop2` CLI via subprocess to avoid coupling to -internal pipeline implementation details. -""" - -from __future__ import annotations - -import os -import subprocess # noqa: S404 -import sys +import re from pathlib import Path import numpy as np -import pytest # noqa: TC002 -import yaml - - -def _write_sir_stepper(script_path: Path) -> None: - script_path.parent.mkdir(parents=True, exist_ok=True) - script_path.write_text( - ( - '"""SIR model plugin for flepimop2 demo."""\n\n' - "import numpy as np\n" - "from numpy.typing import NDArray\n\n\n" - "def stepper(\n" - " t: float, # noqa: ARG001\n" - " y: NDArray[np.float64],\n" - " beta: float,\n" - " gamma: float,\n" - ") -> NDArray[np.float64]:\n" - ' """dY/dt for the SIR model."""\n' - " y_s, y_i, _ = np.asarray(y, dtype=float)\n" - " infection = (beta * y_s * y_i) / np.sum(y)\n" - " recovery = gamma * y_i\n" - " dydt = [-infection, infection - recovery, recovery]\n" - " return np.array(dydt, dtype=float)\n" - ), - encoding="utf-8", - ) - - -def _write_config(config_path: Path) -> None: - config_path.parent.mkdir(parents=True, exist_ok=True) - cfg = { - "name": "SIR_op_engine", - "system": [{"module": "wrapper", "script": "model_input/plugins/SIR.py"}], - "engine": [ - { - "module": "op_engine", - "config": { - "method": "heun", - "adaptive": False, - "strict": True, - "rtol": 1.0e-6, - "atol": 1.0e-9, - }, - } - ], - "simulate": {"demo": {"times": [0, 10, 20]}}, - "backend": [{"module": "csv"}], - "parameter": { - "beta": {"module": "fixed", "value": 0.3}, - "gamma": {"module": "fixed", "value": 0.1}, - "s0": {"module": "fixed", "value": 999}, - "i0": {"module": "fixed", "value": 1}, - "r0": {"module": "fixed", "value": 0}, +import pytest +from flepimop2.testing import external_provider_package, flepimop2_run + + +def test_external_provider(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Run a simulation with an installed external op_engine provider package.""" + cwd = Path(__file__).parent.resolve() + external_provider_package( + tmp_path, + copy_files={ + cwd / "data" / "config.yaml": Path("config.yaml"), + cwd / "data" / "sir.py": Path("external_provider") + / "src" + / "flepimop2" + / "system" + / "sir.py", }, - } - config_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8") - - -def _run_flepimop2_simulate(config_path: Path) -> subprocess.CompletedProcess[str]: - """ - Run `flepimop2 simulate` in a subprocess using the current environment. - - flepimop2 exposes a console script entrypoint (`flepimop2`) rather than - supporting `python -m flepimop2` (no flepimop2.__main__). - - Args: - config_path: Path to the flepimop2 YAML configuration file. - - Returns: - CompletedProcess instance with execution results. - """ - exe = Path(sys.executable) - - # Resolve the venv scripts directory in a cross-platform way. - if exe.parent.name == "bin": - cli = exe.parent / "flepimop2" - else: - # Windows layout: .../Scripts/python.exe - cli = exe.parent / "flepimop2.exe" - - cmd = [str(cli), "simulate", str(config_path)] - - env = dict(os.environ) - env.setdefault("PYTHONUTF8", "1") - - return subprocess.run( # noqa:S603 - cmd, - text=True, - capture_output=True, - check=False, - env=env, + dependencies=["op-engine"], ) - - -def test_simulate_pipeline_writes_csv( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """End-to-end integration test invoking the public `flepimop2` CLI. - - Validates: - - YAML config parsing - - wrapper system loading from a script path - - provider engine resolution: `engine: module: op_engine` - - op_engine-backed execution - - csv backend writes output - """ monkeypatch.chdir(tmp_path) - (tmp_path / "model_output").mkdir(parents=True, exist_ok=True) - _write_sir_stepper(tmp_path / "model_input" / "plugins" / "SIR.py") - _write_config(tmp_path / "configs" / "SIR_op_engine.yml") - - proc = _run_flepimop2_simulate(Path("configs/SIR_op_engine.yml")) - - assert proc.returncode == 0, ( - "flepimop2 simulate failed\n\n" - f"STDOUT:\n{proc.stdout}\n\n" - f"STDERR:\n{proc.stderr}\n" - ) + assert len(list((tmp_path / "model_output").iterdir())) == 0 + result = flepimop2_run("simulate", args=["config.yaml"], cwd=tmp_path) + assert result.returncode == 0 - csv_files = sorted((tmp_path / "model_output").glob("*.csv")) - assert csv_files, "expected csv backend to write at least one output file" + model_output = list((tmp_path / "model_output").iterdir()) + assert len(model_output) == 1 + csv = model_output[0] + assert re.match(r"^simulate_\d{8}_\d{6}\.csv$", csv.name) + assert csv.stat().st_size > 0 - arr = np.loadtxt(csv_files[-1], delimiter=",") + arr = np.loadtxt(csv, delimiter=",") assert arr.ndim == 2 - assert arr.shape[1] == 1 + 3 # time + 3-state SIR - assert arr.shape[0] == 3 # times: [0, 10, 20] + assert arr.shape[1] == 4 diff --git a/flepimop2-op_engine/tests/test_types.py b/flepimop2-op_engine/tests/test_types.py deleted file mode 100644 index 083b6e1..0000000 --- a/flepimop2-op_engine/tests/test_types.py +++ /dev/null @@ -1,168 +0,0 @@ -# tests/test_types.py -"""Unit tests for op_engine.flepimop2.types.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -if TYPE_CHECKING: - from collections.abc import Mapping - -import numpy as np -import pytest - -from flepimop2.engine.op_engine.types import ( - EngineRunner, - IdentifierString, - SystemStepper, - as_float64_1d, - ensure_strictly_increasing_times, - normalize_params, -) - - -class _GoodStepper: - def __call__( - self, time: np.float64, state: np.ndarray, **params: object - ) -> np.ndarray: - _ = time - _ = params - return np.asarray(state, dtype=np.float64) - - -class _GoodRunner: - def __call__( - self, - stepper: SystemStepper, - times: np.ndarray, - state: np.ndarray, - params: Mapping[IdentifierString, object], - **engine_kwargs: object, - ) -> np.ndarray: - _ = engine_kwargs - out: list[np.ndarray] = [] - for t in np.asarray(times, dtype=np.float64): - dy = stepper( - np.float64(t), np.asarray(state, dtype=np.float64), **dict(params) - ) - out.append(np.asarray(dy, dtype=np.float64).reshape(-1)) - return np.asarray(out, dtype=np.float64) - - -def test_protocols_runtime_checkable() -> None: - """SystemStepper and EngineRunner should be runtime checkable protocols.""" - stepper = _GoodStepper() - runner = _GoodRunner() - - assert isinstance(stepper, SystemStepper) - assert isinstance(runner, EngineRunner) - - -@pytest.mark.parametrize( - ("x", "expected"), - [ - ([1, 2, 3], np.array([1.0, 2.0, 3.0], dtype=np.float64)), - ((1, 2, 3, 4), np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)), - ( - np.array([1, 2, 3], dtype=np.int32), - np.array([1.0, 2.0, 3.0], dtype=np.float64), - ), - ], -) -def test_as_float64_1d_converts_1d_inputs(x: object, expected: np.ndarray) -> None: - """as_float64_1d converts 1D input to float64 1D array correctly.""" - out = as_float64_1d(x) - assert out.dtype == np.float64 - assert out.ndim == 1 - np.testing.assert_allclose(out, expected) - - -def test_as_float64_1d_rejects_non_1d() -> None: - """as_float64_1d rejects non-1D inputs.""" - x = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float64) - with pytest.raises(ValueError, match="1D"): - as_float64_1d(x, name="x") - - -def test_as_float64_1d_is_contiguous() -> None: - """as_float64_1d returns a contiguous array.""" - x = np.arange(12, dtype=np.float64).reshape(3, 4)[:, ::2] # non-contiguous view - assert not x.flags["C_CONTIGUOUS"] - - x1d = x.reshape(-1) # still non-contiguous - assert not x1d.flags["C_CONTIGUOUS"] - - out = as_float64_1d(x1d, name="x1d") - assert out.flags["C_CONTIGUOUS"] - assert out.dtype == np.float64 - assert out.shape == (6,) - - -def test_ensure_strictly_increasing_times_accepts_trivial() -> None: - """ensure_strictly_increasing_times should accept empty/length-1 times.""" - ensure_strictly_increasing_times(np.array([], dtype=np.float64), name="times") - ensure_strictly_increasing_times(np.array([1.0], dtype=np.float64), name="times") - - -def test_ensure_strictly_increasing_times_accepts_increasing() -> None: - """ensure_strictly_increasing_times should accept strictly increasing arrays.""" - times = np.array([0.0, 0.5, 1.0, 2.0], dtype=np.float64) - ensure_strictly_increasing_times(times, name="times") - - -@pytest.mark.parametrize( - "times", - [ - np.array([0.0, 0.0, 1.0], dtype=np.float64), - np.array([0.0, -1.0, 1.0], dtype=np.float64), - np.array([0.0, 1.0, 1.0], dtype=np.float64), - ], -) -def test_ensure_strictly_increasing_times_rejects_non_increasing( - times: np.ndarray, -) -> None: - """ensure_strictly_increasing_times should reject non-increasing arrays.""" - with pytest.raises(ValueError, match="strictly increasing"): - ensure_strictly_increasing_times(times, name="times") - - -def test_normalize_params_none_returns_empty_dict() -> None: - """normalize_params with None returns empty dict.""" - out = normalize_params(None) - assert out == {} - assert isinstance(out, dict) - - -def test_normalize_params_mapping_returns_dict_copy() -> None: - """normalize_params returns a dict copy of the input mapping.""" - src: Mapping[str, object] = {"beta": 0.3, "gamma": 0.1} - out = normalize_params(src) - assert out == {"beta": 0.3, "gamma": 0.1} - assert isinstance(out, dict) - assert out is not src - - -def test_good_runner_produces_time_by_state_array() -> None: - """A well-typed EngineRunner produces correct output shape and dtype.""" - runner = _GoodRunner() - stepper = _GoodStepper() - - times = as_float64_1d([0, 1, 2], name="times") - y0 = as_float64_1d([1, 2, 3], name="y0") - params = normalize_params({"x": 1.0}) - - res = runner(stepper, times, y0, params) - assert res.dtype == np.float64 - assert res.shape == (3, 3) # (T, n_state) - - -def test_runner_protocol_signature_acceptance() -> None: - """Structural test: a callable with compatible signature satisfies EngineRunner.""" - runner = cast("EngineRunner", _GoodRunner()) - stepper = cast("SystemStepper", _GoodStepper()) - - times = np.array([0.0, 1.0], dtype=np.float64) - y0 = np.array([1.0, 2.0], dtype=np.float64) - - out = runner(stepper, times, y0, {"beta": 0.3}, atol=1e-9) # extra kwargs allowed - assert out.shape == (2, 2) diff --git a/src/op_engine/matrix_ops.py b/src/op_engine/matrix_ops.py index fc27adc..ec63eef 100644 --- a/src/op_engine/matrix_ops.py +++ b/src/op_engine/matrix_ops.py @@ -801,7 +801,7 @@ def _build_implicit_solver( if is_sparse: left_csr = cast("csr_matrix", left_op) right_csr = cast("csr_matrix", right_op) - solve_left = sparse_factorized(left_csr) + solve_left = sparse_factorized(left_csr.tocsc()) def sparse_solver(x: NDArray[np.floating]) -> NDArray[np.floating]: x_arr = np.asarray(x, dtype=left_csr.dtype)