Add battery dispatch implementation and Gitea CI pipeline
CI / test (push) Failing after 45m4s

Implements the package from PLAN.md: config/data loading, the LP-first
MILP-fallback rolling-horizon optimiser, battery degradation tracking,
independent schedule validation, metrics, plots and the CLI, plus the
matching test suite. Adds a Gitea Actions workflow (lint + tests) that
posts a pass/fail notification to ntfy on every run, and a .gitignore
for build/cache artefacts that had been tracked by mistake.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 13:32:06 +01:00
co-authored by Claude Sonnet 5
parent 54e877db9b
commit 298724a9d3
22 changed files with 3330 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
"""Market definitions.
Both markets are expressed on the common half-hourly grid. A market whose
trading block is longer than a half-hour (Market 2, hourly) is described by
``block_half_hours``; the optimiser creates one decision variable per *block*
and references it from every half-hour inside that block, so the commitment
rule cannot be violated by construction.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
MARKET_1 = "market_1"
MARKET_2 = "market_2"
@dataclass(frozen=True)
class Market:
"""A market the battery can trade in.
Attributes:
name: Identifier used in output column names.
block_half_hours: Number of half-hours a single commitment must span.
prices: One price per half-hour, broadcast from the block price.
"""
name: str
block_half_hours: int
prices: np.ndarray
def __post_init__(self) -> None:
if self.block_half_hours < 1:
raise ValueError("block_half_hours must be >= 1")
if self.prices.ndim != 1:
raise ValueError("prices must be one-dimensional")
def block_of(self, half_hour: int) -> int:
"""Index of the trading block containing ``half_hour``."""
return half_hour // self.block_half_hours
def slice(self, start: int, stop: int) -> "Market":
"""A view of this market over half-hours ``[start, stop)``.
``start`` must fall on a block boundary, otherwise the window would cut
a trading block in half and the commitment rule would be meaningless.
"""
if start % self.block_half_hours:
raise ValueError(
f"{self.name}: window start {start} is not aligned to a "
f"{self.block_half_hours}-half-hour block boundary"
)
return Market(self.name, self.block_half_hours, self.prices[start:stop])