From 1f47c17f5b406dd6527087418baf8097a18e682c Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 06:15:37 +0530 Subject: [PATCH] feat(WP3): vehicle form, observation guard, asap bays, done events, queue with move-by - POST /sessions accepts vehicle{model,battery_kwh,max_kw}, soc_now, target_soc: kwh_needed = (target - now) * battery; need_confidence declared | history (bay median) | site (day median) replaces the 8.0 default. car() plans with min(bay, car); the sim draws min(limit, car_kw * taper). - Observation guard: 5 min under 80 % of the limit (not tapering) -> max_kw = metered * 1.05 for the session, event cap_observed. - site.json connectors_asap [c41, c42]: those bays are always ASAP. - Sim.tick emits done (+ idle_since); Sim.waiting holds arrivals with no bay and plugs them FIFO as bays free; a done car idle >= 10 min is unplugged when someone waits (assumption A5). resolve() gives the slackest plugged cars a move-by deadline (now + e_rem / p_max), one per waiting car, never urgent or < 1 h slack; event move_by. /demo/oversubscribe queues instead of dropping. - Additive contract: SessionOut/ConnectorMeter/MeterMsg/StatusOut fields, EventMsg names done | cap_observed | move_by. Contract files regenerated. - 5 new tests (69): 3.3 kW car planned at 3.3 and full by its deadline; need from history/site; guard trips and the plan follows; asap bay at full power with 8 h slack; queue of 60 late cars tightens the slackest, waiting cars get bays, done events fire. smoke OK; prove unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- AGENTS.md | 9 ++-- data/site.json | 2 +- docs/openapi.json | 117 +++++++++++++++++++++++++++++++++++++++++++ docs/ws-frames.json | 50 ++++++++++++++++++ noonshift/api.py | 112 +++++++++++++++++++++++++++++++++++------ noonshift/models.py | 27 +++++++++- noonshift/sim.py | 27 +++++++--- scripts/smoke.py | 15 +++++- tests/data/site.json | 2 +- tests/test_day.py | 89 ++++++++++++++++++++++++++++++++ 10 files changed, 421 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f0a194c..1bd2bed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Hackathon entry: "Noonshift" — a CPO-side, deadline-based EV charging schedule ## Stack & Commands - Python 3.12/3.13 · FastAPI · scipy 1.14 (HiGHS LP) · mobilityhouse/ocpp · asyncpg/Postgres (optional) · Docker Compose. Two Vite front-ends: `web/` (ops dashboard, React+JSX+Tailwind 3, :5173) and `wattwise/` (driver app, React+TS+Tailwind 4, :5174); `npm install && npm run dev` in each, both proxy `/api` and `/ws` to :8000. `npm run build` in each, then `docker compose up` serves them on :3000 / :3001. - `python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt` -- `python -m pytest` (63 tests, ~60 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` +- `python -m pytest` (69 tests, ~90 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` - Data: `python -m noonshift.seed gen` (placeholders) · `python scripts/fetch_data.py signal|caiso|sessions --day ...` (real). ## Current State & Focus @@ -25,9 +25,9 @@ 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(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). +- `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()`; `car_kw(c)` = min(bay, `session.max_kw`); `need_estimate()` (bay history median → site median); `observe_caps()` (5 min under 80 % of limit → `max_kw` = metered × 1.05, event `cap_observed`); `move_by()` (waiting cars → slackest plugged cars get deadline = now + e_rem/p_max, one per waiting car); `site.json: connectors_asap` bays are always `boost`. +- `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. `Sim.waiting` (arrivals with no bay; plugged FIFO as bays free); a `done` car with `idle_since` ≥ `MOVE_AFTER_MIN`=10 is unplugged when someone waits (A5); `session.car_kw` = the car's physical limit (sim only; `max_kw` is the planner's belief). +- `noonshift/models.py` — pydantic REST bodies + WS frames (contract with front-end). `SessionIn` optional `vehicle{model,battery_kwh,max_kw}`, `soc_now`, `target_soc`; `SessionOut` +`kwh_needed`, `need_confidence`, `max_kw`, `urgency`; `ConnectorMeter` +`idle_min`, `need_confidence`, `p_max_kw`, `cap_observed`, `asap`, `move_by`, `urgency`; `MeterMsg.waiting`; `StatusOut` +`safe_share_kw`, `waiting`, `connectors_asap`; `EventMsg.name` +`urgency|done|cap_observed|move_by`. All additive. - `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). @@ -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 | WP3 cars, needs, queues | models.py, sim.py, api.py, data/site.json, tests/test_day.py, scripts/smoke.py | vehicle form → per-car cap + kWh from SoC; observation guard believes the meter; connectors_asap c41/c42; done event + idle_min; Sim.waiting + move-by tightening; a notified done car moves within 10 min when someone waits 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". diff --git a/data/site.json b/data/site.json index c020fa9..07429ec 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, "safe_share_kw": 1.833} \ 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, "connectors_asap": ["c41", "c42"]} \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index bf004d6..627c939 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1022,6 +1022,36 @@ } ], "title": "Kwh Needed" + }, + "vehicle": { + "anyOf": [ + { + "$ref": "#/components/schemas/Vehicle" + }, + { + "type": "null" + } + ] + }, + "soc_now": { + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Soc Now" + }, + "target_soc": { + "type": "number", + "maximum": 1.0, + "exclusiveMinimum": 0.0, + "title": "Target Soc", + "default": 1.0 } }, "type": "object", @@ -1088,6 +1118,38 @@ } ], "title": "Urgency" + }, + "kwh_needed": { + "type": "number", + "title": "Kwh Needed", + "default": 0.0 + }, + "need_confidence": { + "anyOf": [ + { + "type": "string", + "enum": [ + "declared", + "history", + "site" + ] + }, + { + "type": "null" + } + ], + "title": "Need Confidence" + }, + "max_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Max Kw" } }, "type": "object", @@ -1189,6 +1251,19 @@ "type": "number", "title": "Safe Share Kw", "default": 0.0 + }, + "waiting": { + "type": "integer", + "title": "Waiting", + "default": 0 + }, + "connectors_asap": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Connectors Asap", + "default": [] } }, "type": "object", @@ -1261,6 +1336,48 @@ "type" ], "title": "ValidationError" + }, + "Vehicle": { + "properties": { + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "battery_kwh": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Battery Kwh" + }, + "max_kw": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Kw" + } + }, + "type": "object", + "title": "Vehicle", + "description": "What the driver tells us about the car. All optional: the brain plans with the bay's limit when it knows nothing." } } } diff --git a/docs/ws-frames.json b/docs/ws-frames.json index 2a7fd19..199d63b 100644 --- a/docs/ws-frames.json +++ b/docs/ws-frames.json @@ -70,6 +70,48 @@ ], "default": null, "title": "Urgency" + }, + "idle_min": { + "default": 0, + "title": "Idle Min", + "type": "integer" + }, + "need_confidence": { + "anyOf": [ + { + "enum": [ + "declared", + "history", + "site" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Need Confidence" + }, + "p_max_kw": { + "default": 0.0, + "title": "P Max Kw", + "type": "number" + }, + "cap_observed": { + "default": false, + "title": "Cap Observed", + "type": "boolean" + }, + "asap": { + "default": false, + "title": "Asap", + "type": "boolean" + }, + "move_by": { + "default": false, + "title": "Move By", + "type": "boolean" } }, "required": [ @@ -131,6 +173,9 @@ "deadline", "boost", "urgency", + "done", + "cap_observed", + "move_by", "dr", "demo", "mode", @@ -195,6 +240,11 @@ }, "title": "Connectors", "type": "array" + }, + "waiting": { + "default": 0, + "title": "Waiting", + "type": "integer" } }, "required": [ diff --git a/noonshift/api.py b/noonshift/api.py index 603f5fa..6877b6e 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -7,6 +7,7 @@ import json import os import random +import statistics from datetime import datetime, timedelta from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect @@ -55,13 +56,75 @@ def pick_mode(now): PRIORITY = {"floor_alpha": 0.9, "priority": 2.0} # business.md §4b low band: floor 90 % pro-rata, gives way last +OBS_MIN = 5 # observation guard: minutes at a limit before the meter is believed over the form +OBS_FRAC = 0.8 # ... when the car draws less than this share of its limit +OBS_MARGIN = 1.05 # the new planning cap: metered kW plus a little, so a warming battery can still climb + + +def car_kw(c): + """What the brain plans with for this car: the bay's limit, capped by the vehicle form or the observation guard.""" + return min(c.p_max_kw, c.session.get("max_kw") or c.p_max_kw) + + def car(c): s = c.session return {"connector_id": c.id, "arrival": s["arrival"], "departure": s["user_stated_departure"], - "kwh_needed": s["kwh_needed"], "kwh_delivered": s["kwh_delivered"], "p_max_kw": c.p_max_kw, "boost": s["boost"], + "kwh_needed": s["kwh_needed"], "kwh_delivered": s["kwh_delivered"], "p_max_kw": car_kw(c), + "boost": s["boost"] or c.id in S["site"].get("connectors_asap", []), # fleet bays are never deferred **(PRIORITY if s.get("urgency") == "priority" else {})} +def need_estimate(connector_id): + """solutions.md §11: no form and no number from the driver -> this bay's history, else the site's median.""" + ss = S["sim"].sessions.values() + here = [s["kwh_needed"] for s in ss if s["connector_id"] == connector_id and s["status"] == "ended"] + if here: + return round(statistics.median(here), 1), "history" + return round(statistics.median(s["kwh_needed"] for s in ss), 1), "site" + + +def observe_caps(): + """The observation guard (solutions.md §10): a car that has drawn < OBS_FRAC of its limit for OBS_MIN minutes cannot + take what we plan for it (PHEV, cold battery, wrong form). Believe the meter: cap it at metered * OBS_MARGIN for the + rest of the session. Skipped once the car is tapering (> 80 % of its need), which is not a cap but physics.""" + out = [] + for c in S["sim"].active(): + s = c.session + slow = s["status"] == "charging" and c.kw > 0 and c.kw < OBS_FRAC * c.limit_kw and s["kwh_delivered"] < 0.8 * s["kwh_needed"] + c.slow_min = c.slow_min + 1 if slow and not s.get("cap_observed") else 0 + if c.slow_min >= OBS_MIN: + s["max_kw"], s["cap_observed"], c.slow_min = round(c.kw * OBS_MARGIN, 2), True, 0 + out.append({"name": "cap_observed", "session_id": s["id"], "connector_id": c.id, "limit_kw": round(c.limit_kw, 2), + "metered_kw": round(c.kw, 2), "max_kw": s["max_kw"]}) + return out + + +def move_by(): + """solutions.md §3: cars are waiting, so the plugged cars with the most slack get a move-by time: deadline = now + + time to finish at full power. Never a car with < 1 h slack, never an urgent one, one tightened car per waiting car.""" + sim = S["sim"] + now = sim.now + need = len(sim.waiting) - sum(1 for c in sim.active() if c.session.get("move_by")) + out = [] + if need <= 0: + return out + + def slack(c): + s = c.session + return (s["user_stated_departure"] - now).total_seconds() / 3600 - max(0.0, s["kwh_needed"] - s["kwh_delivered"]) / car_kw(c) + + cands = sorted((c for c in sim.active() if c.status == "charging" and not c.session.get("urgent") and not c.session.get("move_by") + and slack(c) >= 1.0), key=slack, reverse=True) + for c in cands[:need]: + s = c.session + was = s["user_stated_departure"] + finish = now + timedelta(hours=max(0.0, s["kwh_needed"] - s["kwh_delivered"]) / car_kw(c)) + SLOT + s["user_stated_departure"], s["move_by"] = finish.replace(second=0, microsecond=0), True + out.append({"name": "move_by", "session_id": s["id"], "connector_id": c.id, "deadline_was": was.isoformat(), + "move_by": s["user_stated_departure"].isoformat(), "waiting": len(sim.waiting)}) + return out + + def site_rate(): """R: what the driver pays per kWh today. 0 = free workplace charging (business.md §0b).""" return float(S["site"].get("employee_rate_usd_per_kwh", 0.0)) @@ -142,6 +205,8 @@ async def resolve(reason): sim = S["sim"] now = sim.now mode = S["mode"] = pick_mode(now) + for e in move_by(): + await event("move_by", e) 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"], "safe_share_kw": safe_share_kw(S["site"]), "building_load_kw": aligned(S["site"]["building_load_kw"], now)} @@ -174,6 +239,7 @@ async def resolve(reason): def meter_msg(): sim = S["sim"] conns = [] + asap = S["site"].get("connectors_asap", []) for c in sim.connectors.values(): s = c.session conns.append(ConnectorMeter( @@ -181,16 +247,20 @@ def meter_msg(): status=c.status if s else "idle", kw=round(c.kw, 2), kwh_delivered=round(s["kwh_delivered"], 3) if s else 0, kwh_needed=s["kwh_needed"] if s else 0, departure_at=s["user_stated_departure"] if s else None, boost=s["boost"] if s else False, - urgency=s.get("urgency") if s else None)) + urgency=s.get("urgency") if s else None, + idle_min=int((sim.now - s["idle_since"]).total_seconds() // 60) if s and s.get("idle_since") else 0, + need_confidence=s.get("need_confidence") if s else None, p_max_kw=car_kw(c) if s else c.p_max_kw, + cap_observed=bool(s.get("cap_observed")) if s else False, asap=c.id in asap, + move_by=bool(s.get("move_by")) if s else False)) return MeterMsg(sim_time=sim.now, mode=S["mode"], site_kw=round(sum(c.kw for c in sim.connectors.values()), 2), building_load_kw=S["site"]["building_load_kw"][slot_of(sim.now)], feed_kw=S["site"]["feed_kw"], - connectors=conns) + connectors=conns, waiting=len(sim.waiting)) async def step(): """One sim-minute: draw power, handle plug-ins/unplugs, re-solve on event or 5-min boundary, publish meters.""" sim = S["sim"] - events = sim.tick(1) + events = sim.tick(1) + observe_caps() meter_history() for e in events: await event(e["name"], e) @@ -227,12 +297,14 @@ def session_out(s): eta = h0 + k * SLOT break acc += v * 5 / 60 - p_max = sim.connectors[s["connector_id"]].p_max_kw + c = sim.connectors.get(s["connector_id"]) + p_max = car_kw(c) if c and c.session is s else min(sim.connectors[s["connector_id"]].p_max_kw, s.get("max_kw") or 99) slack = 0 if s["boost"] else (s["user_stated_departure"] - now).total_seconds() / 3600 - max(0, s["kwh_needed"] - s["kwh_delivered"]) / p_max return SessionOut(session_id=s["id"], connector_id=s["connector_id"], status=s["status"], plan=PlanWindow(start=h0 + on[0] * SLOT if on else None, end=h0 + (on[-1] + 1) * SLOT if on else None, ready_by=s["user_stated_departure"]), - eta=eta, price=session_price(s, slack), boost=s["boost"], urgency=s.get("urgency")) + eta=eta, price=session_price(s, slack), boost=s["boost"], urgency=s.get("urgency"), + kwh_needed=s["kwh_needed"], need_confidence=s.get("need_confidence"), max_kw=s.get("max_kw")) def get_session(sid): @@ -287,19 +359,30 @@ async def create_session(body: SessionIn): raise HTTPException(404, "no such connector") if body.departure_at <= sim.now: raise HTTPException(400, f"departure_at must be after sim time {sim.now.isoformat()}") + v = body.vehicle + if v and v.battery_kwh and body.soc_now is not None: # solutions.md §11: the form beats a typed number + kwh, conf = round(max(0.0, body.target_soc - body.soc_now) * v.battery_kwh, 2), "declared" + elif body.kwh_needed: + kwh, conf = body.kwh_needed, "declared" + else: + kwh, conf = need_estimate(c.id) if c.session: s = c.session s["user_stated_departure"] = body.departure_at - if body.kwh_needed: - s["kwh_needed"] = max(body.kwh_needed, s["kwh_delivered"]) + if body.kwh_needed or (v and v.battery_kwh and body.soc_now is not None): + s["kwh_needed"], s["need_confidence"] = max(kwh, s["kwh_delivered"]), conf name = "deadline" else: S["next_id"] += 1 s = {"id": S["next_id"], "connector_id": c.id, "arrival": sim.now, "departure": body.departure_at, - "user_stated_departure": body.departure_at, "kwh_needed": body.kwh_needed or 8.0, + "user_stated_departure": body.departure_at, "kwh_needed": kwh, "need_confidence": conf, "kwh_delivered": 0.0, "boost": False, "status": "pending", "ended_at": None} sim.arrive(s) name = "plug_in" + if v: + s["vehicle"] = v.model_dump(exclude_none=True) + if v.max_kw: + s["max_kw"] = s["car_kw"] = v.max_kw # the planner's belief and, in the sim, the physical truth await event(name, {"session_id": s["id"], "connector_id": c.id, "departure_at": body.departure_at.isoformat()}) await db.save_session(s) await resolve(name) @@ -396,7 +479,8 @@ def site_status(site_id: str): 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, - safe_share_kw=safe_share_kw(S["site"])) + safe_share_kw=safe_share_kw(S["site"]), waiting=len(S["sim"].waiting), + connectors_asap=S["site"].get("connectors_asap", [])) @app.get("/price", response_model=PricePreview) @@ -520,11 +604,11 @@ async def demo_oversubscribe(): s = {"id": S["next_id"], "connector_id": None, "arrival": sim.now, "departure": sim.now + timedelta(hours=2), "user_stated_departure": sim.now + timedelta(hours=2), "kwh_needed": round(rnd.uniform(8, 10), 1), "kwh_delivered": 0.0, "boost": False, "status": "pending", "ended_at": None} - if sim.arrive(s): - added.append(s["id"]) - await db.save_session(s) + sim.arrive(s) + added.append(s["id"]) + await db.save_session(s) active = charging_sessions() - detail = {"added": len(added), "session_ids": added, "connectors_active": len(active), + detail = {"added": len(added), "session_ids": added, "connectors_active": len(active), "waiting": len(sim.waiting), "demand_kw_if_all_full_power": len(active) * S["site"]["p_max_kw"], "feed_kw": S["site"]["feed_kw"], "kwh_due_in_2h": round(sum(s["kwh_needed"] - s["kwh_delivered"] for s in active if s["user_stated_departure"] <= sim.now + timedelta(hours=2)), 1)} await event("demo", {"what": "oversubscribe", **detail}) diff --git a/noonshift/models.py b/noonshift/models.py index 24c4761..69a7ecb 100644 --- a/noonshift/models.py +++ b/noonshift/models.py @@ -9,10 +9,23 @@ # ---- REST ---- +class Vehicle(BaseModel): + """What the driver tells us about the car. All optional: the brain plans with the bay's limit when it knows nothing.""" + model: str | None = None + battery_kwh: float | None = Field(None, gt=0) + max_kw: float | None = Field(None, gt=0) # the car's own AC limit (3.7 / 7 / 11 kW are common) + + +NeedConfidence = Literal["declared", "history", "site"] # solutions.md §11: where kwh_needed came from + + class SessionIn(BaseModel): connector_id: str departure_at: datetime kwh_needed: float | None = None + vehicle: Vehicle | None = None + soc_now: float | None = Field(None, ge=0, le=1) # with vehicle.battery_kwh: kwh_needed = (target - now) * battery + target_soc: float = Field(1.0, gt=0, le=1) Urgency = Literal["now", "soon", "priority"] # business.md §4b: three bands, one price (R) @@ -43,6 +56,9 @@ class SessionOut(BaseModel): price: Price boost: bool urgency: Urgency | None = None # set by POST /sessions/{id}/urgency (or /boost = "now"); price is then exactly R + kwh_needed: float = 0.0 + need_confidence: NeedConfidence | None = None + max_kw: float | None = None # what the brain plans with: min(bay, car), corrected by the observation guard class LiveOut(BaseModel): @@ -76,6 +92,8 @@ class StatusOut(BaseModel): 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 + waiting: int = 0 # cars that arrived with no free bay + connectors_asap: list[str] = [] # fleet bays that are never deferred class PriceTier(BaseModel): @@ -148,6 +166,12 @@ class ConnectorMeter(BaseModel): departure_at: datetime | None # what the driver told us boost: bool urgency: Urgency | None = None + idle_min: int = 0 # minutes since the car was full and still plugged in + need_confidence: NeedConfidence | None = None + p_max_kw: float = 0.0 # what the brain plans with for this car + cap_observed: bool = False # the meter said the car draws less than we planned; p_max_kw was lowered to match + asap: bool = False # a connectors_asap bay + move_by: bool = False # deadline tightened to make room for a waiting car class MeterMsg(BaseModel): @@ -158,12 +182,13 @@ class MeterMsg(BaseModel): building_load_kw: float feed_kw: float connectors: list[ConnectorMeter] + waiting: int = 0 class EventMsg(BaseModel): type: Literal["event"] = "event" sim_time: datetime - name: Literal["plug_in", "unplug", "deadline", "boost", "urgency", "dr", "demo", "mode", "day_reset"] + name: Literal["plug_in", "unplug", "deadline", "boost", "urgency", "done", "cap_observed", "move_by", "dr", "demo", "mode", "day_reset"] detail: dict diff --git a/noonshift/sim.py b/noonshift/sim.py index b46779f..8804c94 100644 --- a/noonshift/sim.py +++ b/noonshift/sim.py @@ -10,6 +10,7 @@ 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 +MOVE_AFTER_MIN = 10 # Assumption A5: a driver told "done, please move" vacates within this when someone is waiting def safe_share_kw(site): @@ -43,6 +44,7 @@ def __init__(self, id, p_max_kw=7.0, safe_kw=None): self.session = None self.limit_kw = self.safe_kw self.dyn_left = 0 + self.slow_min = 0 # api.observe_caps: consecutive minutes drawing well under the limit self.kw = 0.0 @property @@ -70,7 +72,8 @@ def tick(self, minutes): return frac = s["kwh_delivered"] / s["kwh_needed"] taper = 1.0 if frac < 0.8 else max(0.1, (1 - frac) / 0.2) # battery taper: linear from 80% to ~10% at full - self.kw = min(self.limit_kw, self.p_max_kw * taper) + car_kw = min(self.p_max_kw, s.get("car_kw") or self.p_max_kw) # the car's own onboard charger limit + self.kw = min(self.limit_kw, car_kw * taper) s["kwh_delivered"] = min(s["kwh_needed"], s["kwh_delivered"] + self.kw * minutes / 60) if s["kwh_delivered"] >= s["kwh_needed"] - 1e-6: s["status"], self.kw = "done", 0.0 @@ -87,6 +90,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.waiting = [] # arrived, no free bay (solutions.md §3); plugged in as bays free up, first come first served 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} @@ -97,28 +101,39 @@ def free_connector(self, preferred=None): return next((c for c in self.connectors.values() if not c.session), None) def arrive(self, session): - """Plug a session in now. Returns the connector, or None if the lot is full.""" + """Plug a session in now. Returns the connector, or None if the lot is full (the car then waits).""" + self.sessions[session["id"]] = session c = self.free_connector(session.get("connector_id")) if c: - self.sessions[session["id"]] = session c.plug_in(session) + elif session not in self.waiting: + self.waiting.append(session) return c def tick(self, minutes=1): - """Advance the clock. Returns [{"name": "plug_in"|"unplug", "session_id", "connector_id"}].""" + """Advance the clock. Returns [{"name": "plug_in"|"unplug"|"done", "session_id", "connector_id"}].""" for c in self.connectors.values(): c.tick(minutes) self.now += timedelta(minutes=minutes) events = [] for c in self.connectors.values(): - if c.session and c.session["departure"] <= self.now: + s = c.session + if s and s["status"] == "done" and not s.get("idle_since"): + s["idle_since"] = self.now + events.append({"name": "done", "session_id": s["id"], "connector_id": c.id}) + moved = s and s.get("idle_since") and self.waiting and self.now - s["idle_since"] >= timedelta(minutes=MOVE_AFTER_MIN) + if s and (s["departure"] <= self.now or moved): s = c.unplug(self.now) - events.append({"name": "unplug", "session_id": s["id"], "connector_id": c.id}) + events.append({"name": "unplug", "session_id": s["id"], "connector_id": c.id, "moved": bool(moved)}) while self.pending and self.pending[0]["arrival"] <= self.now: s = self.pending.pop(0) c = self.arrive(s) if c: events.append({"name": "plug_in", "session_id": s["id"], "connector_id": c.id}) + while self.waiting and self.free_connector(): + s = self.waiting.pop(0) + c = self.arrive(s) + events.append({"name": "plug_in", "session_id": s["id"], "connector_id": c.id, "waited_min": int((self.now - s["arrival"]).total_seconds() // 60)}) return events def active(self): diff --git a/scripts/smoke.py b/scripts/smoke.py index bb78f95..1a1d39d 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -41,7 +41,7 @@ async def pump(): assert meters, "no meter frames on /ws" sim_now = datetime.fromisoformat(meters[-1]["sim_time"]) st = http("GET", "/sites/site-1/status") - assert st["feed_kw"] > 0 and st["block_kw"] > 0 and st["sim_time"], st + assert st["feed_kw"] > 0 and st["block_kw"] > 0 and st["sim_time"] and st["safe_share_kw"] > 0 and st["connectors_asap"], st free = [c["connector_id"] for c in meters[-1]["connectors"] if c["session_id"] is None] connector = free[-1] print(f"[ws] sim_time={sim_now} mode={st['mode']} feed={st['feed_kw']} kW block={st['block_kw']} kW free={len(free)} -> driver takes {connector}") @@ -85,6 +85,17 @@ async def pump(): for cid in (free[-2], free[-3])] await asyncio.sleep(1.5) + # 5b. a fleet van on a connectors_asap bay is never deferred; a PHEV form caps the plan at the car's 3.3 kW + van = http("POST", "/sessions", {"connector_id": st["connectors_asap"][0], "departure_at": departure, "kwh_needed": 20}) + phev = http("POST", "/sessions", {"connector_id": free[-4], "departure_at": departure, + "vehicle": {"model": "PHEV", "battery_kwh": 40, "max_kw": 3.3}, "soc_now": 0.7, "target_soc": 0.9}) + assert van["plan"]["start"] and phev["kwh_needed"] == 8.0 and phev["need_confidence"] == "declared" and phev["max_kw"] == 3.3 + await asyncio.sleep(1.5) + plans = frames_of(frames, "plan")[-1]["connectors"] + assert max(next(c["kw"] for c in plans if c["session_id"] == phev["session_id"])) <= 3.3 + assert next(c["kw"] for c in plans if c["session_id"] == van["session_id"])[0] == 7.0, "asap bay: full power now" + print(f"[driver] van on {st['connectors_asap'][0]} at full power; PHEV form -> {phev['kwh_needed']} kWh ({phev['need_confidence']}) capped at {phev['max_kw']} kW") + # 6a. the emergency bands (business.md §4b): "soon" moves the deadline, "priority" keeps it; both pay exactly R soon_at = (sim_now + timedelta(hours=1, minutes=30)).replace(second=0, microsecond=0).isoformat() u1 = http("POST", f"/sessions/{others[0]}/urgency", {"level": "soon", "leave_at": soon_at}) @@ -100,7 +111,7 @@ async def pump(): for path, body in (("/demo/early_unplug", None), ("/demo/boost", None), ("/demo/oversubscribe", None), ("/demo/signal_outage", {"rungs": ["live"]})): d = http("POST", path, body) - print(f"[ops] POST {path} -> {d['changed']}") + print(f"[ops] POST {path} -> {d['changed']}" + (f" (waiting {d['detail']['waiting']})" if "waiting" in d["detail"] else "")) await asyncio.sleep(2.5) assert frames_of(frames, "event", name="unplug"), "unplug event not broadcast" assert frames_of(frames, "event", name="mode"), "mode event not broadcast" diff --git a/tests/data/site.json b/tests/data/site.json index c020fa9..07429ec 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, "safe_share_kw": 1.833} \ 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, "connectors_asap": ["c41", "c42"]} \ No newline at end of file diff --git a/tests/test_day.py b/tests/test_day.py index 9ca3918..af21d43 100644 --- a/tests/test_day.py +++ b/tests/test_day.py @@ -246,3 +246,92 @@ def test_when_the_control_loop_dies_the_site_falls_back_to_the_static_share(mid_ 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" + + +# ---- WP3: cars, needs, queues (solutions.md §3, §10, §11) ---- +class Sink: + """A fake /ws client: collects every broadcast frame.""" + def __init__(self): + self.msgs = [] + + async def send_json(self, m): + self.msgs.append(m) + + def events(self, name): + return [m["detail"] for m in self.msgs if m.get("type") == "event" and m["name"] == name] + + +def plug(connector_id, hours=4, **body): + from noonshift.api import create_session + from noonshift.models import SessionIn + return asyncio.run(create_session(SessionIn(connector_id=connector_id, departure_at=S["sim"].now + timedelta(hours=hours), **body))) + + +def test_a_3_3_kw_car_on_a_7_kw_bay_is_planned_at_3_3_and_meets_its_deadline(mid_morning): + from noonshift.api import car + out = plug("c50", hours=4, vehicle={"model": "PHEV", "battery_kwh": 40, "max_kw": 3.3}, soc_now=0.7, target_soc=0.9) + s = mid_morning.sessions[out.session_id] + assert out.kwh_needed == pytest.approx(8.0) and out.need_confidence == "declared" and out.max_kw == 3.3 + assert car(mid_morning.connectors["c50"])["p_max_kw"] == 3.3 + assert max(S["plan"]["c50"]) <= 3.3 + 1e-9, "the brain never plans more than the car can take" + asyncio.run(play_until(s["user_stated_departure"])) + assert s["kwh_delivered"] == pytest.approx(8.0, abs=0.1), "8 kWh at 3.3 kW fits in 4 h and lands" + + +def test_need_comes_from_history_or_the_site_when_the_driver_types_nothing(mid_morning): + out = plug("c51", hours=5) + assert out.need_confidence in ("history", "site") and out.kwh_needed > 0 + assert out.kwh_needed == pytest.approx(8.0, abs=2.0), "the frozen day's median need, not a magic number" + + +def test_observation_guard_believes_the_meter_over_the_plan(mid_morning): + """A car the form calls a 7 kW car draws 3.3 kW (PHEV, cold battery). After 5 minutes under 80 % of its limit the + brain caps it at metered * 1.05 and says so; from then on the plan never asks for more than the car can take.""" + from noonshift.api import car + sink = Sink() + S["clients"].add(sink) + out = plug("c52", hours=6, kwh_needed=9.0) + s = mid_morning.sessions[out.session_id] + s["car_kw"] = 3.3 # the physical truth the sim knows and the brain does not + asyncio.run(urgency(out.session_id, UrgencyIn(level="now"))) # full power asked, so the gap shows + asyncio.run(play_until(mid_morning.now + timedelta(minutes=7))) + assert s["cap_observed"] and s["max_kw"] == pytest.approx(3.3 * 1.05, abs=0.02) + caps = sink.events("cap_observed") + assert caps and caps[-1]["connector_id"] == "c52" and caps[-1]["limit_kw"] > caps[-1]["metered_kw"] + assert car(mid_morning.connectors["c52"])["p_max_kw"] == pytest.approx(3.47, abs=0.02) + assert max(S["plan"]["c52"]) <= 3.47 + 1e-6 + S["clients"].discard(sink) + + +def test_asap_bay_is_never_deferred(mid_morning): + from noonshift.api import car + out = plug("c41", hours=8, kwh_needed=20.0) # site.json connectors_asap: a loading-dock bay, 8 h of slack + assert car(mid_morning.connectors["c41"])["boost"] is True + assert S["plan"]["c41"][:6] == [S["site"]["p_max_kw"]] * 6, "full power from the first slot despite 8 h of slack" + assert out.boost is False, "the session itself is not a driver Boost; the bay is configured that way" + + +def test_a_queue_tightens_the_slackest_car_and_a_bay_frees(mid_morning): + from noonshift.api import site_status + sink = Sink() + S["clients"].add(sink) + for _ in range(3): # 36 replayed + 60 late cars > 60 bays + asyncio.run(demo_oversubscribe()) + sim = mid_morning + waiting = [s["id"] for s in sim.waiting] + assert waiting and site_status("site-1").waiting == len(waiting) + moved = [c.session for c in sim.active() if c.session.get("move_by")] + assert moved, "at least one plugged car got a move-by time" + for s in moved: + finish = sim.now + timedelta(hours=(s["kwh_needed"] - s["kwh_delivered"]) / S["site"]["p_max_kw"]) + assert finish <= s["user_stated_departure"] <= finish + timedelta(minutes=6), "deadline = now + time to finish at full power" + assert not s.get("urgent") + assert len(moved) <= len(waiting), "one tightened car per waiting car" + assert sink.events("move_by") and all(e["waiting"] > 0 for e in sink.events("move_by")) + asyncio.run(play_until(sim.now + timedelta(hours=4))) + plugged_later = [e for e in sink.events("plug_in") if e["session_id"] in waiting] + assert plugged_later and all(e["waited_min"] > 0 for e in plugged_later), "waiting cars got a bay as bays freed" + assert len(sim.waiting) < len(waiting) + dones = sink.events("done") + assert dones, "a full car raises a done event (the 'please move' notification)" + S["clients"].discard(sink)