From b77f3e06e85108c24f7f2ab5250fac2f99330e77 Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 05:53:05 +0530 Subject: [PATCH] feat(WP2): fail-safe an inspector accepts - static share under everything - sim.safe_share_kw(site) = min(p_max, (feed - max building load) / n) (1.833 kW on site-1); Connector carries safe_kw: plug-in starts on it, and a dynamic limit expires DYN_VALID_MIN (15) minutes after the last set_limit, exactly like an OCPP profile with valid_to. - pick_mode("full") plan, apply_limits() fallback and scheduler's linprog fallback (_full_power) return the static share, not p_max. - ocpp_gateway: two profiles per connector - static share at stack 0 sent once after BootNotification (as a task; awaiting inside @after deadlocks the message loop), dynamic plan at stack 1 with valid_to = now + 15 min, refreshed every 10 sim-min. Charger keeps profiles and applies the highest live one; self-check shows the revert once the dynamic one expires. - StatusOut.safe_share_kw (additive); site.json: safe_share_kw 1.833. - Tests: kill the control loop at 10:30 and tick 6 h - site never exceeds headroom, after 15 min never exceeds n * share; full rung = share. 64 pass; smoke OK; prove.py unchanged (64.6 %, 0 fallbacks). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- AGENTS.md | 5 ++- data/site.json | 2 +- docs/openapi.json | 5 +++ noonshift/api.py | 15 ++++--- noonshift/models.py | 1 + noonshift/ocpp_gateway.py | 90 ++++++++++++++++++++++++++++----------- noonshift/scheduler.py | 8 ++-- noonshift/sim.py | 58 +++++++++++++++++++------ tests/data/site.json | 2 +- tests/test_day.py | 25 ++++++++++- 10 files changed, 156 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5ea6e75..f0a194c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,13 +26,13 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead ## File Map - `noonshift/scheduler.py` — `solve(cars, site, signal, tariff, now)`, `solve_lp()` (+info), `impact()`, `impact_detail()`, `price(slack, *, r, saving_usd, kwh, alpha, urgent)` (§7b: R − α·S/E, ≤ R), `tier_of()`; per-car optional `floor_alpha`, `priority`; constants W_CARBON, ALPHA, FLOOR_EVERY, FIRST_HOUR_KWH (3.5), SPRINT_SLOTS, BUFFER_COST, M_SHORT, M_FLOOR, ASAP_EPS, E_MIN, MIN_KW. Slot 0 carries only its remaining minutes (`dur[0]`); floor checkpoints are times since arrival, pro rata in the slot containing the mark. - `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`; `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` (`/boost` = alias of `now`; `URGENCY_MIN` 15 min; `PRIORITY` = floor 0.9 / weight 2.0); `site_rate()` = `site.json: employee_rate_usd_per_kwh` (0 = free); `session_price()`. -- `noonshift/sim.py` — `Sim`, `Connector` (taper from 80% SoC), `load_sessions()`. +- `noonshift/sim.py` — `Sim`, `Connector(id, p_max_kw, safe_kw)` (taper from 80% SoC; plug-in starts on `safe_kw`; a `set_limit` expires after `DYN_VALID_MIN`=15 min back to `safe_kw`), `load_sessions()`, `safe_share_kw(site)` = min(p_max, (feed − max building)/n) = 1.833 kW on site-1. - `noonshift/models.py` — pydantic REST bodies + WS frames (contract with front-end). `SignalHour` added for `GET /grid/signal` (hourly MOER g/kWh + $/kWh; frames unchanged). - `web/src/context/GlobalStateContext.jsx` — ops app state: opens `/ws`, polls `/sites/site-1/impact|status`, derives the page shape (siteDetail/connectors Gantt on a 06–22 axis, sessions, alerts from events, chargersList); `triggerEvent()` maps demo buttons → `/demo/*`, prioritize → `/sessions/{id}/boost`. - `.docs/ROADMAP.md` — plain-language plan (§0 status, §1 driver inputs, §3 emergency rules, §4 price flip, §5 evidence runs, §7 CI, §8 order). `.github/workflows/ci.yml` — backend job + web/wattwise matrix. - `wattwise/src/api/noonshift.ts` — driver app client + hand-copied types; `connectWs()`, `onSimDay()` (ready-by is built on the **sim** day and must be > sim now). - `wattwise/src/context/WattwiseContext.tsx` — driver state: `POST /sessions` on the highest free connector (replayed sessions use c01–c36), `/live` polled every 2 s for the receipt, meter/plan frames for kW/kWh/window; unplug/day_reset moves the session to History. -- `noonshift/ocpp_gateway.py`, `db.py`, `seed.py`, `test_loop.py` — Tirth's; unchanged by Neal's lane. +- `noonshift/ocpp_gateway.py` — two profiles per connector: static share at stack 0 (sent as a task after BootNotification, no expiry) + dynamic plan at stack 1 with `valid_to` = now + 15 min, refreshed every 10 sim-min; `Charger.profiles`, `Charger.apply(now)`; self-check `python -m noonshift.ocpp_gateway`. Never `await self.call()` inside an `@after` handler (deadlocks the message loop). `db.py`, `seed.py`, `test_loop.py` — Tirth's. - `scripts/prove.py` — two real-loop replays (charge-now vs Noonshift), slide-1 table, gate exit code. - `scripts/smoke.py` — e2e without a browser: driver REST (`/price`, `/sessions`, `/boost`, `/live`) + `/ws` as the ops app sees it + all four `/demo/*` + ladder drop/restore; plugs 2 extra cars before the demo beats so early-unplug/boost always have a target. - `.docs/pitch/` — `deck.md` (slide list, all sourced), `demo-script.md` (4 min, 7 beats, what the audience sees per beat, failure modes), `hard-questions.md` (answers tied to code + tests). @@ -79,6 +79,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - 2026-09-12 — Session data = ACN 2019-04-09 re-dated onto the 2026 signal day; kwh_needed = delivered energy, stated departure = the driver's own input (early leavers kept). ## Changelog +2026-09-13 | WP2 fail-safe an inspector accepts: static share under everything | sim.py, api.py, scheduler.py, ocpp_gateway.py, models.py, data/site.json, tests/test_day.py | `full` rung, `apply_limits` fallback and the linprog fallback all return `safe_share_kw`; kill-the-loop test: 6 h with no step() never exceeds headroom and after 15 min never exceeds n × share; `StatusOut.safe_share_kw` 2026-09-13 | WP1 pricing + emergency + first-hour floor | scheduler.py, api.py, models.py, data/site.json, tests/, scripts/smoke.py, .docs/implementation-plan.md | price() = §7b shared savings (never above R); urgency endpoint; slot-0 remaining-duration + time-anchored checkpoints fixed a lost-energy bug that the 3.5 kWh promise exposed 2026-09-13 | Open-source docs pass: README rewrite (Mermaid, quick start, results, OCPP 1.6J badge), LICENSE/CONTRIBUTING/CODE_OF_CONDUCT/SECURITY/CHANGELOG, issue+PR templates, .docs/ARCHITECTURE.md (6 validated diagrams), .docs/README.md index; all human docs moved to .docs/; business.md (break points, §4b emergency scale at R, §7b shared-savings formula), metrics.md, solutions.md (real-world fixes → code; drivers under-state stay 29/36; prove.py plans with perfect foresight) | README.md, .docs/, LICENSE, CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, .github/ | All Mermaid blocks validated with mermaid@11 + jsdom; ROADMAP emergency pricing reconciled to "R, no quota". 2026-09-13 | ROADMAP.md (layman plan: driver inputs, emergency, incentives, Tier 0/1, order of work) + GitHub Actions CI | ROADMAP.md, .github/workflows/ci.yml, wattwise/src/context/WattwiseContext.tsx, web/src/pages/ImpactView.jsx | CI = pytest + prove gate + smoke against live uvicorn + contract-diff + lint/build both apps; lint fixes only (refs not read during render, Card hoisted) diff --git a/data/site.json b/data/site.json index 9d24b9f..c020fa9 100644 --- a/data/site.json +++ b/data/site.json @@ -1 +1 @@ -{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2} \ No newline at end of file +{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833} \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index 71b8ccf..bf004d6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1184,6 +1184,11 @@ } ], "title": "Sim Time" + }, + "safe_share_kw": { + "type": "number", + "title": "Safe Share Kw", + "default": 0.0 } }, "type": "object", diff --git a/noonshift/api.py b/noonshift/api.py index 1953df8..603f5fa 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -17,7 +17,7 @@ from .models import (WS_FRAMES, ConnectorMeter, ConnectorPlan, DemoOut, DrEvent, EventMsg, FlexHour, ImpactOut, LiveOut, MeterMsg, OutageIn, PlanMsg, PlanWindow, PricePreview, PriceTier, SessionIn, SessionOut, SignalHour, StatusOut, UrgencyIn) -from .sim import SLOT, SPEED, Connector, Sim, load_sessions +from .sim import SLOT, SPEED, Connector, Sim, load_sessions, safe_share_kw S = {} # site, signal, tariff, sim, plan, plan_at, baseline, impact, mode, ladder, live_lost_at, last_solve_at, dr, clients, next_id @@ -124,7 +124,7 @@ def apply_limits(): k = slot_of(sim.now) - slot_of(S["plan_at"]) if S["plan_at"] else -1 for c in sim.active(): kw = S["plan"].get(c.id) - c.set_limit(kw[k] if kw and 0 <= k < len(kw) else c.p_max_kw) + c.set_limit(kw[k] if kw and 0 <= k < len(kw) else c.safe_kw) # no plan for this car = the static share def plan_msg(reason): @@ -143,7 +143,7 @@ async def resolve(reason): now = sim.now mode = S["mode"] = pick_mode(now) cars = [car(c) for c in sim.active() if c.session["status"] == "charging"] - site = {"feed_kw": S["site"]["feed_kw"], "block_kw": S["site"]["block_kw"], + site = {"feed_kw": S["site"]["feed_kw"], "block_kw": S["site"]["block_kw"], "safe_share_kw": safe_share_kw(S["site"]), "building_load_kw": aligned(S["site"]["building_load_kw"], now)} t0 = slot_start(now) for dr in S["dr"]: # a DR event is just less headroom in those slots @@ -153,8 +153,8 @@ async def resolve(reason): sig = S["signal"] signal = {"moer": aligned(sig["moer"], now), "kind": sig["kind"]} if mode in ("live", "cached") else {"moer": [], "kind": None} tariff = dict(S["tariff"], price_per_kwh=aligned(S["tariff"]["price_per_kwh"], now) if mode != "deadline" else []) - if mode == "full": - plan = baseline = {c["connector_id"]: [c["p_max_kw"]] * 288 for c in cars} + if mode == "full": # nothing to plan with: every charger on its static share, which is what it would do without us + plan = baseline = {c["connector_id"]: [min(site["safe_share_kw"], c["p_max_kw"])] * 288 for c in cars} else: plan = await asyncio.to_thread(scheduler.solve, cars, site, signal, tariff, now) baseline = await asyncio.to_thread(scheduler.solve, [dict(c, departure=now) for c in cars], site, signal, tariff, now) @@ -257,7 +257,7 @@ async def lifespan(app): if os.environ.get("OCPP") == "1": from . import ocpp_gateway S["connector_cls"] = ocpp_gateway.OcppConnector - await ocpp_gateway.start(S["site"]["connectors"]) + await ocpp_gateway.start(S["site"]["connectors"], safe_share_kw(S["site"])) S["sim"] = new_sim() await db.connect() await seed.load() @@ -395,7 +395,8 @@ def site_status(site_id: str): check_site(site_id) return StatusOut(mode=S["mode"], last_solve_at=S["last_solve_at"], connectors_active=sum(c.status == "charging" for c in S["sim"].active()), - feed_kw=S["site"]["feed_kw"], block_kw=S["site"]["block_kw"], sim_time=S["sim"].now) + feed_kw=S["site"]["feed_kw"], block_kw=S["site"]["block_kw"], sim_time=S["sim"].now, + safe_share_kw=safe_share_kw(S["site"])) @app.get("/price", response_model=PricePreview) diff --git a/noonshift/models.py b/noonshift/models.py index 307d2bf..24c4761 100644 --- a/noonshift/models.py +++ b/noonshift/models.py @@ -75,6 +75,7 @@ class StatusOut(BaseModel): feed_kw: float = 0.0 block_kw: float = 0.0 sim_time: datetime | None = None + safe_share_kw: float = 0.0 # per-connector static share every charger reverts to when the controller is gone class PriceTier(BaseModel): diff --git a/noonshift/ocpp_gateway.py b/noonshift/ocpp_gateway.py index c1219ca..23c2fa4 100644 --- a/noonshift/ocpp_gateway.py +++ b/noonshift/ocpp_gateway.py @@ -4,24 +4,39 @@ process. OcppConnector has the same interface as sim.Connector, but: plug_in -> StartTransaction, tick -> MeterValues every 5 sim-min, unplug -> StopTransaction, set_limit -> CSMS sends SetChargingProfile; the limit only takes effect when the charge point applies it. + +Two profiles per connector (solutions.md §5, the NEC 625.42 argument): the static share at stack level 0 with no expiry, +sent once after BootNotification, and the dynamic plan at stack level 1 with valid_to = now + 15 min, refreshed by the +control loop. Profiles persist on the charger, so if Noonshift dies the dynamic one expires and every charger reverts +to its static share on its own, with no network. """ import asyncio -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import websockets -from ocpp.routing import on +from ocpp.routing import after, on from ocpp.v16 import ChargePoint, call, call_result from ocpp.v16.enums import Action, RegistrationStatus -from .sim import Connector +from .sim import DYN_VALID_MIN, Connector PORT = 9000 +STATIC_KW = 7.0 # the static share; start() sets it from the site csms = {} # connector id -> Csms (server side) chargers = {} # connector id -> Charger (client side) def _now(): - return datetime.now(timezone.utc).isoformat() + return datetime.now(timezone.utc) + + +def _profile(stack_level, kw, valid_to=None): + p = {"charging_profile_id": stack_level + 1, "stack_level": stack_level, "charging_profile_purpose": "TxDefaultProfile", + "charging_profile_kind": "Absolute", + "charging_schedule": {"charging_rate_unit": "W", "charging_schedule_period": [{"start_period": 0, "limit": round(kw * 1000)}]}} + if valid_to: + p["valid_to"] = valid_to.isoformat() + return p class Csms(ChargePoint): @@ -35,7 +50,12 @@ def __init__(self, *a): @on(Action.boot_notification) def on_boot(self, **kw): self.received.append("BootNotification") - return call_result.BootNotification(current_time=_now(), interval=300, status=RegistrationStatus.accepted) + return call_result.BootNotification(current_time=_now().isoformat(), interval=300, status=RegistrationStatus.accepted) + + @after(Action.boot_notification) + def after_boot(self, **kw): # the static share goes down once, right after boot, and never expires + # a task, not an await: this runs inside the message loop that has to read the reply + asyncio.create_task(self.call(call.SetChargingProfile(connector_id=1, cs_charging_profiles=_profile(0, STATIC_KW)))) @on(Action.start_transaction) def on_start(self, **kw): @@ -53,11 +73,9 @@ def on_stop(self, **kw): self.received.append("StopTransaction") return call_result.StopTransaction() - async def set_limit(self, kw): - await self.call(call.SetChargingProfile(connector_id=1, cs_charging_profiles={ - "charging_profile_id": 1, "stack_level": 0, "charging_profile_purpose": "TxDefaultProfile", - "charging_profile_kind": "Absolute", - "charging_schedule": {"charging_rate_unit": "W", "charging_schedule_period": [{"start_period": 0, "limit": round(kw * 1000)}]}})) + async def set_limit(self, kw): # the dynamic plan sits on top and dies with us + await self.call(call.SetChargingProfile( + connector_id=1, cs_charging_profiles=_profile(1, kw, _now() + timedelta(minutes=DYN_VALID_MIN)))) class Charger(ChargePoint): @@ -67,42 +85,54 @@ def __init__(self, *a): super().__init__(*a) self.connector = None self.tx = None + self.profiles = {} # stack_level -> (kW, valid_to | None); what a real charge point keeps in flash @on(Action.set_charging_profile) def on_profile(self, connector_id, cs_charging_profiles): - watts = cs_charging_profiles["charging_schedule"]["charging_schedule_period"][0]["limit"] - Connector.set_limit(self.connector, watts / 1000) + p = cs_charging_profiles + valid_to = datetime.fromisoformat(p["valid_to"]) if p.get("valid_to") else None + self.profiles[p["stack_level"]] = (p["charging_schedule"]["charging_schedule_period"][0]["limit"] / 1000, valid_to) + self.apply() return call_result.SetChargingProfile(status="Accepted") + def apply(self, now=None): + """The highest stack level still valid wins (OCPP 1.6 profile stacking). Called on every profile, and by the + self-check with a later `now` to show what the charger does once the dynamic profile has expired.""" + now = now or _now() + live = [(lvl, kw) for lvl, (kw, until) in self.profiles.items() if until is None or until > now] + if live and self.connector: + Connector.set_limit(self.connector, max(live)[1]) + async def start_tx(self, session): - r = await self.call(call.StartTransaction(connector_id=1, id_tag=f"s{session['id']}", meter_start=0, timestamp=_now())) + r = await self.call(call.StartTransaction(connector_id=1, id_tag=f"s{session['id']}", meter_start=0, timestamp=_now().isoformat())) self.tx = r.transaction_id async def meter(self, kw, kwh): - await self.call(call.MeterValues(connector_id=1, transaction_id=self.tx, meter_value=[{"timestamp": _now(), "sampled_value": [ + await self.call(call.MeterValues(connector_id=1, transaction_id=self.tx, meter_value=[{"timestamp": _now().isoformat(), "sampled_value": [ {"value": str(round(kw * 1000)), "measurand": "Power.Active.Import", "unit": "W"}, {"value": str(round(kwh * 1000)), "measurand": "Energy.Active.Import.Register", "unit": "Wh"}]}])) async def stop_tx(self, kwh): - await self.call(call.StopTransaction(meter_stop=round(kwh * 1000), timestamp=_now(), transaction_id=self.tx or 0)) + await self.call(call.StopTransaction(meter_stop=round(kwh * 1000), timestamp=_now().isoformat(), transaction_id=self.tx or 0)) self.tx = None class OcppConnector(Connector): - def __init__(self, id, p_max_kw=7.0): - super().__init__(id, p_max_kw) + def __init__(self, id, p_max_kw=7.0, safe_kw=None): + super().__init__(id, p_max_kw, safe_kw) chargers[id].connector = self self.wanted = None self.minutes = 0 + self.sent_min = -DYN_VALID_MIN def plug_in(self, session): super().plug_in(session) self.wanted = None asyncio.create_task(chargers[self.id].start_tx(session)) - def set_limit(self, kw): # over the wire; Charger.on_profile applies it - if kw != self.wanted: - self.wanted = kw + def set_limit(self, kw): # over the wire; Charger.on_profile applies it. Refreshed before the 15-min expiry. + if kw != self.wanted or self.minutes - self.sent_min >= DYN_VALID_MIN - 5: + self.wanted, self.sent_min = kw, self.minutes asyncio.create_task(csms[self.id].set_limit(kw)) def tick(self, minutes): @@ -117,7 +147,10 @@ def unplug(self, now): return s -async def start(connector_ids): +async def start(connector_ids, safe_kw=None): + global STATIC_KW + STATIC_KW = safe_kw or STATIC_KW + async def on_connect(ws): cid = ws.path.strip("/") csms[cid] = Csms(cid, ws) @@ -134,22 +167,29 @@ async def on_connect(ws): if __name__ == "__main__": # self-check: python -m noonshift.ocpp_gateway async def main(): - await start(["c01", "c02"]) - c = OcppConnector("c01") + await start(["c01", "c02"], safe_kw=1.833) + await asyncio.sleep(0.3) + assert chargers["c01"].profiles[0] == (1.833, None) and chargers["c02"].profiles[0] == (1.833, None), "static share on every charger after boot" + c = OcppConnector("c01", 7.0, 1.833) s = {"id": 1, "kwh_needed": 8.0, "kwh_delivered": 0.0, "status": "pending", "boost": False} c.plug_in(s) + assert c.limit_kw == 1.833, "plug-in starts on the static share" c.set_limit(3.5) - assert c.limit_kw == 7.0, "limit is not applied until SetChargingProfile round-trips" + assert c.limit_kw == 1.833, "limit is not applied until SetChargingProfile round-trips" await asyncio.sleep(0.3) assert c.limit_kw == 3.5, c.limit_kw + dyn = chargers["c01"].profiles[1] + assert dyn[0] == 3.5 and dyn[1] and dyn[1] > _now(), "dynamic profile carries a valid_to" for _ in range(5): c.tick(1) assert abs(s["kwh_delivered"] - 3.5 * 5 / 60) < 1e-6 + chargers["c01"].apply(now=_now() + timedelta(minutes=DYN_VALID_MIN + 1)) # the CSMS is gone: profile 1 expires + assert c.limit_kw == 1.833, "with no controller the charger reverts to its static share by itself" c.unplug(None) await asyncio.sleep(0.3) got = csms["c01"].received assert got == ["BootNotification", "StartTransaction", "MeterValues", "StopTransaction"], got assert csms["c02"].received == ["BootNotification"] - print("ocpp ok:", " -> ".join(got), "| SetChargingProfile applied 3.5 kW") + print("ocpp ok:", " -> ".join(got), "| static 1.833 kW at stack 0, dynamic 3.5 kW at stack 1 (15 min); expired -> 1.833 kW") asyncio.run(main()) diff --git a/noonshift/scheduler.py b/noonshift/scheduler.py index 9a6833e..15d9d65 100644 --- a/noonshift/scheduler.py +++ b/noonshift/scheduler.py @@ -81,8 +81,10 @@ def _slots_until(t, now): return max(0, min(HORIZON, math.ceil((t - now).total_seconds() / 300))) -def _full_power(cars): - return {c["connector_id"]: [float(c["p_max_kw"])] * HORIZON for c in cars} +def _full_power(cars, site=None): + """No plan: each charger on the site's static share (solutions.md §5), or its own max when no share is configured.""" + share = (site or {}).get("safe_share_kw") or math.inf + return {c["connector_id"]: [float(min(share, c["p_max_kw"]))] * HORIZON for c in cars} def solve_lp(cars, site, signal, tariff, now): @@ -200,7 +202,7 @@ def before(mark): if res.status != 0: # elastic LP cannot be infeasible; if HiGHS still fails, fail open like the ladder does log.error("linprog status %s (%s); falling back to full power", res.status, res.message) info["status"] = f"fallback:{res.status}" - return _full_power(cars), info + return _full_power(cars, site), info info["status"] = "optimal" x = res.x plan = {car["connector_id"]: np.maximum(x[i * H:(i + 1) * H], 0.0) for i, car in enumerate(cars)} diff --git a/noonshift/sim.py b/noonshift/sim.py index bae1fb9..b46779f 100644 --- a/noonshift/sim.py +++ b/noonshift/sim.py @@ -9,6 +9,16 @@ SPEED = float(os.environ.get("SIM_SPEED", "480")) # sim-seconds per real second; 480 = one day in 3 min SLOT = timedelta(minutes=5) START_HOUR = 6 # nothing happens before 06:00; skip it +DYN_VALID_MIN = 15 # a dynamic limit expires this many minutes after it was last set (OCPP profile valid_to); then safe share + + +def safe_share_kw(site): + """solutions.md §5: the static per-connector share that keeps the site inside its feed with no controller at all: + min(p_max, (feed - worst building load) / n). Sent once to every charger; what they revert to when we are gone.""" + if site.get("safe_share_kw"): + return float(site["safe_share_kw"]) + load = site.get("building_load_kw") or [0.0] + return round(max(0.0, min(site["p_max_kw"], (site["feed_kw"] - max(load)) / max(1, len(site["connectors"])))), 3) def load_sessions(path="data/sessions.json"): @@ -22,12 +32,17 @@ def load_sessions(path="data/sessions.json"): class Connector: - """One charger. Draws min(limit, p_max * taper) while a session is charging.""" + """One charger. Draws min(limit, p_max * taper) while a session is charging. + + Two limits, like the two OCPP profiles: `safe_kw` is the static share (applied at plug-in, and again when the + dynamic limit has not been refreshed for DYN_VALID_MIN minutes); `set_limit()` is the dynamic plan on top.""" - def __init__(self, id, p_max_kw=7.0): + def __init__(self, id, p_max_kw=7.0, safe_kw=None): self.id, self.p_max_kw = id, p_max_kw + self.safe_kw = min(safe_kw, p_max_kw) if safe_kw else p_max_kw # no share configured = fail-open, as before self.session = None - self.limit_kw = p_max_kw # fail-open: full power until a plan says otherwise + self.limit_kw = self.safe_kw + self.dyn_left = 0 self.kw = 0.0 @property @@ -38,12 +53,17 @@ def plug_in(self, session): self.session = session session["connector_id"] = self.id session["status"] = "charging" - self.limit_kw = self.p_max_kw + self.limit_kw, self.dyn_left = self.safe_kw, 0 # the static profile, until the next plan lands def set_limit(self, kw): self.limit_kw = max(0.0, min(kw, self.p_max_kw)) + self.dyn_left = DYN_VALID_MIN def tick(self, minutes): + if self.dyn_left > 0: + self.dyn_left -= minutes + if self.dyn_left <= 0: + self.limit_kw = self.safe_kw # nobody refreshed the plan: the charger falls back on its own s = self.session if not s or s["status"] != "charging": self.kw = 0.0 @@ -67,7 +87,7 @@ def __init__(self, sessions, site, connector_cls=Connector): self.now = day + timedelta(hours=START_HOUR) self.day_end = day + timedelta(days=1) self.pending = sorted(sessions, key=lambda s: s["arrival"]) - self.connectors = {cid: connector_cls(cid, site["p_max_kw"]) for cid in site["connectors"]} + self.connectors = {cid: connector_cls(cid, site["p_max_kw"], safe_share_kw(site)) for cid in site["connectors"]} self.sessions = {s["id"]: s for s in sessions} def free_connector(self, preferred=None): @@ -105,26 +125,36 @@ def active(self): return [c for c in self.connectors.values() if c.session] -if __name__ == "__main__": # self-check: python -m noonshift.sim - site = json.load(open("data/site.json")) +def _replay(site): sim = Sim(load_sessions(), site) plugged, peak = set(), 0.0 while sim.now < sim.day_end: for e in sim.tick(): + s = sim.sessions[e["session_id"]] if e["name"] == "plug_in": - s = sim.sessions[e["session_id"]] assert s["arrival"] <= sim.now < s["arrival"] + timedelta(minutes=2), (s["arrival"], sim.now) plugged.add(s["id"]) else: - s = sim.sessions[e["session_id"]] assert abs((s["departure"] - sim.now).total_seconds()) < 60 peak = max(peak, sum(c.kw for c in sim.connectors.values())) ss = sim.sessions.values() assert plugged == set(sim.sessions), "every session plugged in" assert all(s["status"] == "ended" for s in ss), "every session unplugged" assert all(s["kwh_delivered"] <= s["kwh_needed"] + 1e-6 for s in ss), "never overfill" - assert all(abs(s["kwh_delivered"] - s["kwh_needed"]) < 1e-3 for s in ss if s["id"] not in (7, 23)), "full power fills every long-dwell car" - assert 150 < peak <= 280, f"uncontrolled peak {peak:.0f} kW should exceed the 150 kW feed" - print(f"sim ok: 40 sessions replayed, uncontrolled peak {peak:.0f} kW, " - f"early leavers got {sim.sessions[7]['kwh_delivered']:.1f}/{sim.sessions[7]['kwh_needed']} and " - f"{sim.sessions[23]['kwh_delivered']:.1f}/{sim.sessions[23]['kwh_needed']} kWh") + return sim, peak + + +if __name__ == "__main__": # self-check: python -m noonshift.sim + site = json.load(open("data/site.json")) + # 1. no controller at all: every charger sits on its static share and the site never leaves the feed + sim, peak = _replay(site) + share = safe_share_kw(site) + assert peak <= len(site["connectors"]) * share + 1e-6 <= site["feed_kw"] - max(site["building_load_kw"]), peak + short = [s for s in sim.sessions.values() if s["kwh_delivered"] < s["kwh_needed"] - 1e-3] + # 2. fail-open (no share configured): the old picture, an uncontrolled peak above the feed + sim, peak_open = _replay(dict(site, safe_share_kw=site["p_max_kw"])) + short_open = [s for s in sim.sessions.values() if s["kwh_delivered"] < s["kwh_needed"] - 1e-3] + assert all(s["kwh_delivered"] >= 0.9 * s["kwh_needed"] for s in short_open), "at full power only the taper tail can be missing" + assert peak < peak_open <= len(site["connectors"]) * site["p_max_kw"], f"uncontrolled peak {peak_open:.0f} kW vs shared {peak:.0f} kW" + print(f"sim ok: {len(sim.sessions)} sessions replayed; static share {share} kW/connector keeps the site at {peak:.0f} kW " + f"({len(short)} cars short without a plan); fail-open peak {peak_open:.0f} kW, {len(short_open)} cars physically unfillable") diff --git a/tests/data/site.json b/tests/data/site.json index 9d24b9f..c020fa9 100644 --- a/tests/data/site.json +++ b/tests/data/site.json @@ -1 +1 @@ -{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2} \ No newline at end of file +{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833} \ No newline at end of file diff --git a/tests/test_day.py b/tests/test_day.py index 95a3766..9ca3918 100644 --- a/tests/test_day.py +++ b/tests/test_day.py @@ -12,7 +12,7 @@ from noonshift.api import (S, aligned, demo_boost, demo_early_unplug, demo_oversubscribe, demo_signal_outage, site_impact, step, urgency) from noonshift.models import OutageIn, UrgencyIn -from noonshift.sim import Sim, load_sessions +from noonshift.sim import DYN_VALID_MIN, Sim, load_sessions, safe_share_kw EARLY = {7, 23} # seed.py: told us ~17:00, actually leave ~13:00 DATA = Path(__file__).parent / "data" # frozen copy of the seeded placeholder day: data/ may hold real data later @@ -178,7 +178,9 @@ def test_demo_signal_outage_walks_the_ladder_and_keeps_planning(mid_morning): assert out.detail["mode_after"] == "deadline" and S["plan"], "deadline-only rung still yields a plan" assert all(c.limit_kw == S["plan"][c.id][0] for c in mid_morning.active() if c.status == "charging") out = asyncio.run(demo_signal_outage(OutageIn(rungs=["deadline"]))) - assert out.detail["mode_after"] == "full" and all(c.limit_kw == c.p_max_kw for c in mid_morning.active()) + share = safe_share_kw(S["site"]) + assert out.detail["mode_after"] == "full" and all(c.limit_kw == share for c in mid_morning.active()), "full = the static share, not p_max" + assert sum(c.limit_kw for c in mid_morning.connectors.values()) <= S["site"]["feed_kw"] - max(S["site"]["building_load_kw"]) out = asyncio.run(demo_signal_outage(OutageIn(restore=True))) assert out.detail["mode_after"] == "live" @@ -225,3 +227,22 @@ def test_flexible_driver_pays_less_than_r_and_never_more(day): prices = [session_out(s).price.usd_per_kwh for s in day["sessions"].values()] r = S["site"]["employee_rate_usd_per_kwh"] assert max(prices) <= r and min(prices) < r + + +# ---- the controller dies (solutions.md §5) ---- +def test_when_the_control_loop_dies_the_site_falls_back_to_the_static_share(mid_morning): + """Nobody calls step() after 10:30: no re-solve, no apply_limits. Cars keep their last limit for DYN_VALID_MIN + minutes (the dynamic profile's valid_to), then revert to the static share; new arrivals plug in on it. The site + never exceeds its headroom, and after the expiry never exceeds n * safe_share_kw, with no controller at all.""" + sim = mid_morning + share, n = safe_share_kw(S["site"]), len(sim.connectors) + worst_headroom, worst_after = -1e9, -1e9 + for minute in range(1, 6 * 60 + 1): + sim.tick(1) + ev_kw = sum(c.kw for c in sim.connectors.values()) + worst_headroom = max(worst_headroom, ev_kw - headroom_now()) + if minute > DYN_VALID_MIN: + worst_after = max(worst_after, ev_kw - n * share) + assert worst_headroom <= 1e-6, f"site exceeded its headroom by {worst_headroom:.1f} kW with the loop dead" + assert worst_after <= 1e-6, f"after {DYN_VALID_MIN} min the site drew {worst_after:.1f} kW above n * safe share" + assert all(c.limit_kw == share for c in sim.active()), "every plugged car sits on the static share"