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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion microgrid_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]

29 changes: 26 additions & 3 deletions microgrid_controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from dataclasses import dataclass
from math import isfinite


@dataclass(frozen=True)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
19 changes: 18 additions & 1 deletion tests/test_controller.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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))
Expand Down