From 6fbc026eac6a12e3761eaad83eae9cd8af0197bc Mon Sep 17 00:00:00 2001 From: minao Date: Mon, 24 Aug 2026 16:10:37 -0600 Subject: [PATCH] Harden microgrid state validation and CI --- .github/workflows/test.yml | 8 +++++--- README.md | 3 +++ microgrid_controller/__init__.py | 7 ++++++- microgrid_controller/controller.py | 29 ++++++++++++++++++++++++++--- pyproject.toml | 11 +++++++++++ tests/test_controller.py | 19 ++++++++++++++++++- 6 files changed, 69 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5ed5f7..dd81602 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,14 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install package - run: python -m pip install --no-deps . + run: python -m pip install -e ".[dev]" + - name: Lint + run: ruff check . - name: Run unit tests run: python -m unittest discover -s tests -v - name: Smoke-test command line diff --git a/README.md b/README.md index 8ad3f4f..e434ce8 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Example 24-step summary: - Power is treated as constant within each simulation interval. - Battery charge/discharge efficiency is applied to state-of-charge updates. +- Each input state must begin within the configured SOC range; invalid states are + rejected instead of being clamped in a way that would create or discard energy. +- Non-finite configuration and input-state values are rejected before dispatch. - The controller enforces SOC, battery-power, and grid-import limits rather than allowing an impossible dispatch. - The model reports unserved load instead of silently violating energy constraints. diff --git a/microgrid_controller/__init__.py b/microgrid_controller/__init__.py index 742776a..06bca90 100644 --- a/microgrid_controller/__init__.py +++ b/microgrid_controller/__init__.py @@ -1,6 +1,11 @@ """Deterministic microgrid control and simulation.""" -from .controller import ControllerConfig, DispatchDecision, MicrogridController, PowerState +from .controller import ( + ControllerConfig, + DispatchDecision, + MicrogridController, + PowerState, +) __all__ = ["ControllerConfig", "DispatchDecision", "MicrogridController", "PowerState"] diff --git a/microgrid_controller/controller.py b/microgrid_controller/controller.py index 444a721..2c8f433 100644 --- a/microgrid_controller/controller.py +++ b/microgrid_controller/controller.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from math import isfinite @dataclass(frozen=True) @@ -17,6 +18,18 @@ class ControllerConfig: discharge_efficiency: float = 0.95 def __post_init__(self) -> None: + numeric_values = ( + self.battery_capacity_kwh, + self.min_soc, + self.max_soc, + self.max_charge_kw, + self.max_discharge_kw, + self.grid_import_limit_kw, + self.charge_efficiency, + self.discharge_efficiency, + ) + if not all(isfinite(value) for value in numeric_values): + raise ValueError("controller configuration values must be finite") if self.battery_capacity_kwh <= 0: raise ValueError("battery capacity must be positive") if not 0 <= self.min_soc < self.max_soc <= 1: @@ -59,14 +72,24 @@ def __init__(self, config: ControllerConfig | None = None) -> None: self.config = config or ControllerConfig() def dispatch(self, state: PowerState) -> DispatchDecision: + cfg = self.config + state_values = ( + state.solar_kw, + state.load_kw, + state.battery_soc, + state.interval_hours, + ) + if not all(isfinite(value) for value in state_values): + raise ValueError("power-state values must be finite") if min(state.solar_kw, state.load_kw) < 0: raise ValueError("solar and load power cannot be negative") - if not 0 <= state.battery_soc <= 1: - raise ValueError("battery_soc must be between zero and one") + if not cfg.min_soc <= state.battery_soc <= cfg.max_soc: + raise ValueError( + "battery_soc must be within the configured minimum and maximum" + ) if state.interval_hours <= 0: raise ValueError("interval_hours must be positive") - cfg = self.config solar_to_load = min(state.solar_kw, state.load_kw) surplus = max(0.0, state.solar_kw - solar_to_load) deficit = max(0.0, state.load_kw - solar_to_load) diff --git a/pyproject.toml b/pyproject.toml index facaed7..ed0b3bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,20 @@ build-backend = "setuptools.build_meta" name = "microgrid-controller-sim" version = "0.1.0" description = "A deterministic solar, battery, grid, and load controller simulation" +readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } authors = [{ name = "Mina Soliman" }] +[project.optional-dependencies] +dev = ["ruff>=0.16.0,<1.0"] + [project.scripts] microgrid-sim = "microgrid_controller.cli:main" + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP", "B"] diff --git a/tests/test_controller.py b/tests/test_controller.py index 8997d73..7d2f11c 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,6 +1,11 @@ import unittest +from math import nan -from microgrid_controller.controller import ControllerConfig, MicrogridController, PowerState +from microgrid_controller.controller import ( + ControllerConfig, + MicrogridController, + PowerState, +) from microgrid_controller.simulation import day_profile @@ -61,6 +66,18 @@ def test_efficiencies_must_be_physical(self) -> None: with self.assertRaises(ValueError): ControllerConfig(discharge_efficiency=1.01) + def test_initial_soc_must_respect_configured_limits(self) -> None: + with self.assertRaisesRegex(ValueError, "configured minimum"): + self.controller.dispatch(PowerState(0.0, 2.0, 0.10)) + with self.assertRaisesRegex(ValueError, "configured minimum"): + self.controller.dispatch(PowerState(4.0, 2.0, 0.98)) + + def test_non_finite_configuration_and_state_are_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "finite"): + ControllerConfig(battery_capacity_kwh=nan) + with self.assertRaisesRegex(ValueError, "finite"): + self.controller.dispatch(PowerState(nan, 2.0, 0.50)) + def test_day_profile_respects_soc_and_grid_limit(self) -> None: rows = day_profile() self.assertTrue(all(0.15 <= float(row["next_soc"]) <= 0.95 for row in rows))