diff --git a/AGENTS.md b/AGENTS.md index 1fb496a..a429136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - `scripts/prove.py` — real-loop replays: charge-now vs Noonshift (+ `--policy timer` = dumb 09–14 timer; `--block/--feed` overrides; `--forecast ` plans on it and scores on data/signal.json); prints clean-hour share, block overage $, kWh short, stated-vs-actual departures; gate exit code. Timer delta on the real day: CO2 −56 %, bill −28.5 %, peak −28 %. At feed 80/block 50: CO2 −23 % but bill +7 % (deferred energy lands in the 16–21 h price peak). - `scripts/fit.py ` — pre-sales site fit: `cash tiers | perks only | not a fit` from stated-slack median, tariff spread, demand charge, `--saving-rate` (measured). Caltech: perks only (slack median 3.8 h, 4/15/17). - `scripts/replay_days.py --days a,b,c` — min/median/max over `data/days/.json` signals; names missing days instead of inventing them. +- `scripts/scenarios.py` — ten real-world scenarios (commuter, leaving now/soon, prioritise, skipped form, PHEV, fleet van, early leaver, grid outage, two-wave rush) against a live backend at `SIM_SPEED=120`; `Run` recorder (steps, checks, `wait_sim`, `wait_until`, `worst_overshoot`), writes `docs/scenarios/run.{md,json}`; exit 1 on a failed check. `docs/scenarios/` — the last recorded transcript. - `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). - `scripts/fetch_data.py` — WattTime / ACN-Data / CAISO fallback → `data/*.json`; `signal --forecast` → `data/signal_forecast.json` via `/v3/forecast/historical` (untested: no WattTime creds on this machine); `signal` also stores `health_damage` ($/MWh) when the plan serves it → `impact(..., health=True)["health_usd"]`. @@ -54,6 +55,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - Every regression found by the day replay gets a unit test that fails on the old code before the fix. ## Dependencies & Gotchas +- Live-server races the in-process tests cannot see: (1) a plug-in over HTTP holds the static share until a re-solve lands, and the control loop ticks during `resolve()`'s awaits (`to_thread` solve) → `Sim.arrive` gives a newcomer only the share out of unheld headroom (`test_a_burst_of_plug_ins…`); (2) `hist.start` must be anchored by `meter_history()` to the first recorded minute, not `resolve()`'s `now` (`test_meter_history_anchors_start…`). `scripts/scenarios.py` is the net that catches this class; keep its whole-run overshoot line at 0.0. - `site_peaks()`/`hourly_ledger()` index per-minute meters by `k0 + (start.minute % 5 + i) // 5`; without the start-minute offset a mid-slot plug-in once reported a 171 kW "peak" on a 150 kW feed. `test_day.py::test_metered_peak_never_reports_above_the_feed` guards it. `anthropic` is only imported inside `assist.ask()`; no key = deterministic fallback, so CI needs neither key nor network. - **scipy must stay 1.14.x**: 1.15.2's HiGHS bindings took 66 s for any ~11k-column LP on Windows (HiGHS itself 0.01 s); 1.14.1 = 38 ms. `tests/test_perf.py` trips if the pin is lifted. - HiGHS returns "Unknown" (status 4) when the objective is only 1e-7 tie-breaks (all-ASAP baseline of nearly-full cars). Keep ASAP_EPS = 1e-3; `solve()` falls open to full power and logs ERROR if it ever happens. @@ -85,6 +87,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 | 10 real-world scenarios recorded + 2 live-only fixes | scripts/scenarios.py, docs/scenarios/, sim.py (newcomer provisional limit), api.py (hist start anchor, POST /sessions/{id}/unplug), assist.py (pinned bays, early-leaver template), tests | the recording is the showcase and the regression net; 36/36 promises kept, EV load never above headroom over the whole run 2026-09-13 | WP7 docs | README (API block, assistant paragraph), .docs/ARCHITECTURE.md (§7 contracts, §9 assistant + validated Mermaid), CHANGELOG Unreleased (WP1–WP6), .docs/ROADMAP.md §0 | docs describe the code as merged, not the plan 2026-09-13 | WP6 operator assistant | noonshift/assist.py, assist_knowledge.md, api.py (/assist, ops_auth, events deque, site_peaks/hourly_ledger mid-slot offset), models.py, tests/test_assist.py, web AssistDrawer + lib/api.js, smoke.py, requirements.txt (anthropic) | explains, never acts; deterministic fallback so the demo needs no key; fixed a reporting bug where a mid-slot plug-in could show a peak above the feed 2026-09-13 | WP5 facilities-manager ops dashboard | api.py (`hourly_ledger`, `/impact.csv`, StatusOut/ImpactOut fields), models.py, web/src/{context,components/UrgencyControl,lib/ui,pages/*}, smoke.py | CO2 leads $ everywhere; every number is a live field (no Math.random); urgency is the only driver-side control the FM has; mobile = responsive layout only, not checked by eye diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e449d5..36f438e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ All notable changes to this project. Format follows [Keep a Changelog](https://k - **WP4 — evidence scripts.** `prove.py --policy timer | --block | --feed | --forecast`, `fit.py` (site fit verdict), `replay_days.py`, WattTime forecast + health-damage fetch (unverified without credentials), `impact(..., health=True)`. - **WP5 — facilities-manager dashboard.** CO₂ first, every number a live field, `UrgencyControl`, fail-safe banner in plain words, hourly ledger export `GET /sites/{id}/impact.csv`, `StatusOut`/`ImpactOut` site-card fields. - **WP6 — operator assistant.** `POST /assist`, `GET /assist/suggestions`, `noonshift/assist.py` + `assist_knowledge.md`, chat drawer in the ops app; deterministic fallback without a key; `OPS_TOKEN` bearer on operator endpoints. -- `.docs/implementation-plan.md` — the refined plan for WP1–WP7 with every assumption and deviation. +- `scripts/scenarios.py` — ten real-world scenarios against the live backend, told as stories, checked (36 promises) and recorded to `docs/scenarios/run.{md,json}`. +- `POST /sessions/{id}/unplug` — the driver pulls the plug; returns the receipt, re-solves without the car. +- Operator assistant: a bay named in the question is always in the snapshot; an "early leaver" template. - Open-source scaffolding: `LICENSE` (MIT), `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, issue and PR templates, this changelog. - `.docs/ARCHITECTURE.md` — control loop, LP constraints, fail-safe ladder and OCPP flow, with Mermaid diagrams. - `.docs/business.md` — scoped to the corporate-building customer (§0b: who pays, value stack; §9b: the deal, employee incentives when charging is free); break points, edge cases where the scheduler is worse than a dumb charger, the shared-savings pricing formula (§7b), the emergency scale (§4b), 4-tier benefits. @@ -19,6 +21,8 @@ All notable changes to this project. Format follows [Keep a Changelog](https://k - `.docs/solutions.md` — real-world fixes for every open problem, mapped to code locations; two findings from the data (drivers under-state their stay; `prove.py` plans with perfect foresight). ### Fixed +- A burst of plug-ins over the API could draw the static share each on top of a plan that already filled the feed for one sim-minute (the control loop ticks while the handler awaits the threaded re-solve): 20 arrivals = +26 kW over headroom, caught by `scripts/scenarios.py`. A newcomer's provisional limit is now its share out of headroom nobody holds yet (full share after 15 min if no plan ever lands). +- `meter_history()` anchors a session's record to the first minute it actually records, so a re-solve that lands a tick late no longer shifts the samples a minute early (a 151.8 kW "peak" on a 150 kW feed that never happened). - `site_peaks()` / `hourly_ledger()` dropped the start-minute offset of mid-slot plug-ins and could report a slot peak above the feed that never happened. ### Changed diff --git a/README.md b/README.md index 298f952..b8afcc9 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ Four buttons on the ops dashboard, each one a `POST /demo/*`: Script for a 4-minute run: [`.docs/pitch/demo-script.md`](.docs/pitch/demo-script.md). +**Ten real-world scenarios, recorded.** `SIM_SPEED=120 python -m uvicorn noonshift.api:app --port 8000`, then `python scripts/scenarios.py` plays a normal commuter, "leaving now", "leaving soon", "prioritise", a skipped form, a plug-in hybrid, a fleet van, an early leaver, a grid-API outage and a two-wave lunchtime rush against the live backend, checks the promise each one makes (36 checks) and writes the transcript to [`docs/scenarios/run.md`](docs/scenarios/run.md) — every line a real request or a frame from `/ws`. + **Ask the site a question.** The ops dashboard has an *Ask* button: *"why is bay c07 only getting 1.4 kW?"*, *"what happens if the grid API dies?"*, *"how much CO₂ did we save today, in km?"*. The answer comes from a snapshot of the live state plus a hand-written knowledge file (`noonshift/assist_knowledge.md`), via `claude-opus-5` with a cached system prefix when `ANTHROPIC_API_KEY` is set, and from deterministic templates filled with the same snapshot when it is not — so the demo never depends on a key or the internet. It explains and suggests (each answer ends with up to two *label → page* buttons); it never presses anything. ## API @@ -137,6 +139,7 @@ GET /price what a ready-by would cost before plugging in POST /sessions {connector_id, departure_at, kwh_needed?, vehicle?, soc_now?, target_soc?} POST /sessions/{id}/urgency {level: now | soon | priority, leave_at?} — three bands, one price (R) POST /sessions/{id}/boost alias of urgency level "now" +POST /sessions/{id}/unplug the driver pulls the plug: honest receipt, plan re-solved without the car GET /sessions/{id}/live live kW, grid-cleanliness percentile, $ and kg saved so far GET /sites/{id}/plan per-connector 5-min kW profile GET /sites/{id}/status site kW vs feed / block / contracted peak, ladder mode, safe share, waiting, package, R diff --git a/docs/openapi.json b/docs/openapi.json index 87f5caf..bccd2a1 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -150,6 +150,46 @@ } } }, + "/sessions/{sid}/unplug": { + "post": { + "summary": "Unplug", + "description": "The driver unplugs (early or not): the session ends with an honest receipt and the plan re-solves without it.", + "operationId": "unplug_sessions__sid__unplug_post", + "parameters": [ + { + "name": "sid", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sid" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LiveOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/sessions/{sid}/live": { "get": { "summary": "Live", diff --git a/docs/scenarios/run.json b/docs/scenarios/run.json new file mode 100644 index 0000000..95083a2 --- /dev/null +++ b/docs/scenarios/run.json @@ -0,0 +1,1226 @@ +{ + "meta": { + "api": "http://127.0.0.1:8001", + "site": { + "mode": "live", + "last_solve_at": "2026-04-14T06:15:00", + "connectors_active": 0, + "feed_kw": 150.0, + "block_kw": 100.0, + "sim_time": "2026-04-14T06:17:00", + "safe_share_kw": 1.833, + "waiting": 0, + "connectors_asap": [ + "c41", + "c42" + ], + "n_connectors": 60, + "p_max_kw": 7.0, + "contracted_peak_kw": 150.0, + "package": "pilot", + "employee_rate_usd_per_kwh": 0.25, + "driver_share": 0.5, + "noonshift_share": 0.2, + "signal_kind": "marginal", + "signal_source": "WattTime v3 co2_moer", + "tariff_name": "PG&E BEV-2-S (approx)", + "ladder": { + "live": true, + "cached": true, + "tariff": true, + "deadline": true + } + }, + "speed": 119.43690180427346, + "zero_hours": [ + 9, + 11, + 12, + 13, + 14, + 15, + 16, + 17 + ], + "started": "2026-09-13T08:16:30", + "impact": { + "from": "2026-04-14T00:00:00", + "to": "2026-04-15T00:00:00", + "sessions": 67, + "kwh": 318.25, + "saved_usd": 1.43, + "saved_kgco2": 13.91, + "peak_kw": 149.9, + "baseline_peak_kw": 150.0, + "renewable_share": 0.5556, + "health_usd": null, + "note": "estimate vs charge-immediately baseline" + }, + "worst_overshoot_kw": 0.0, + "meter_frames": 335, + "ended": "2026-09-13T08:19:23" + }, + "scenarios": [ + { + "n": 1, + "title": "Priya's normal day", + "story": "Priya parks at the office, taps the pre-filled 'Leaving at 17:30?', asks for 12 kWh. She wants her car full by then; the site wants that energy in the zero-carbon hours and never above the feed.", + "sim_start": "2026-04-14T06:17", + "steps": [ + { + "sim": "06:17", + "who": "driver", + "text": "previews the price for a ready-by 8 h out on bay c60", + "request": { + "method": "GET", + "path": "/price?departure_at=2026-04-14T14:17:00&kwh_needed=12" + }, + "response": { + "status": 200, + "slack_hours": 6.29, + "price": { + "tier": "green", + "usd_per_kwh": 0.25 + }, + "tiers": [ + { + "tier": "green", + "min_slack_hours": 4.0, + "usd_per_kwh": 0.25 + }, + { + "tier": "standard", + "min_slack_hours": 1.0, + "usd_per_kwh": 0.25 + }, + { + "tier": "boost", + "min_slack_hours": 0.0, + "usd_per_kwh": 0.25 + } + ] + } + }, + { + "sim": "06:17", + "who": "driver", + "text": "plugs in: 12 kWh by 14:17", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c60", + "departure_at": "2026-04-14T14:17:00", + "kwh_needed": 12 + } + }, + "response": { + "status": "charging", + "session_id": 1001, + "connector_id": "c60", + "plan": { + "start": "2026-04-14T06:20:00", + "end": "2026-04-14T10:15:00", + "ready_by": "2026-04-14T14:17:00" + }, + "eta": "2026-04-14T10:15:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.2358 + }, + "boost": false, + "urgency": null, + "kwh_needed": 12.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "06:23", + "who": "system", + "text": "plan window 06:20\u201310:15, ready by 14:17; 58% of her planned energy lands in zero-carbon hours (charging right now: 0%)" + }, + { + "sim": "07:18", + "who": "driver", + "text": "checks the app an hour in", + "request": { + "method": "GET", + "path": "/sessions/1001/live" + }, + "response": { + "status": "charging", + "session_id": 1001, + "kw_now": 0.0, + "grid_percentile": 1.0, + "kwh_delivered": 3.5, + "kwh_needed": 12.0, + "saved_usd": 0.3167, + "saved_kgco2": 2.8517 + } + }, + { + "sim": "07:18", + "who": "driver", + "text": "receipt so far: $0.3167 and 2.8517 kg CO2 saved (estimate) \u2014 grid cleaner than 1.0% of today" + } + ], + "checks": [ + { + "name": "plan ends before her stated departure", + "ok": true, + "detail": "10:15 <= 14:17" + }, + { + "name": "price never above R", + "ok": true, + "detail": "$0.2358 vs R $0.25 (green)" + }, + { + "name": "more of her energy in clean hours than charging immediately", + "ok": true, + "detail": "58% vs 0%" + }, + { + "name": "first-hour floor: >= min(need, 3.5 kWh) after 60 min", + "ok": true, + "detail": "3.50 kWh" + } + ], + "frame_mark": 10, + "assistant": { + "question": "what is bay c60 doing right now?", + "answer": "Bay c60 is at 0.0 kW, 3.5 of 12.0 kWh delivered \u2014 deferred: an empty slot in its plan. It has 5.77 h of slack before 14:17, so the plan moves the bulk of its 12.0 kWh to cleaner or cheaper slots inside that deadline (today's zero-carbon hours are 09:00\u201318:00); next slots [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] kW. Not a fault.", + "sources": [ + "The scheduler" + ], + "suggested_actions": [ + { + "label": "Open the bay", + "path": "/ops/chargers" + }, + { + "label": "See the plan", + "path": "/ops/schedules" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 2, + "title": "Maya's sick kid \u2014 Leaving now", + "story": "Maya plugged in for the day. School calls mid-morning: come now. She taps 'Leaving now'. Promise: full power from the next slot, at today's rate, no premium \u2014 and nobody else loses their floor.", + "sim_start": "2026-04-14T07:18", + "steps": [ + { + "sim": "07:18", + "who": "driver", + "text": "had plugged in for 6 h wanting 10 kWh", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c59", + "departure_at": "2026-04-14T13:18:00", + "kwh_needed": 10 + } + }, + "response": { + "status": "charging", + "session_id": 1002, + "connector_id": "c59", + "plan": { + "start": "2026-04-14T07:40:00", + "end": "2026-04-14T10:00:00", + "ready_by": "2026-04-14T13:18:00" + }, + "eta": "2026-04-14T10:00:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.2369 + }, + "boost": false, + "urgency": null, + "kwh_needed": 10.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "07:19", + "who": "system", + "text": "bay c59 is deferred to a cleaner hour, drawing 0.0 kW when the phone rings" + }, + { + "sim": "07:19", + "who": "driver", + "text": "taps 'Leaving now'", + "request": { + "method": "POST", + "path": "/sessions/1002/urgency", + "body": { + "level": "now" + } + }, + "response": { + "status": "charging", + "session_id": 1002, + "connector_id": "c59", + "plan": { + "start": "2026-04-14T07:15:00", + "end": "2026-04-14T08:45:00", + "ready_by": "2026-04-14T07:19:00" + }, + "eta": "2026-04-14T08:45:00", + "price": { + "tier": "boost", + "usd_per_kwh": 0.25 + }, + "boost": true, + "urgency": "now", + "kwh_needed": 10.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "07:22", + "who": "system", + "text": "bay c59: 0.0 kW before -> 7.0 kW now; 'boost' event on the ops feed" + } + ], + "checks": [ + { + "name": "full power within two slots", + "ok": true, + "detail": "7.0 kW" + }, + { + "name": "pays exactly R, no premium", + "ok": true, + "detail": "$0.25" + }, + { + "name": "ops feed shows the boost event and flag", + "ok": true, + "detail": "" + } + ], + "frame_mark": 92, + "assistant": { + "question": "what does boost do to the other cars?", + "answer": "Three driver buttons, one price. 'Leaving now' sets the deadline to now: full power immediately, first claim on headroom. 'Leaving soon' takes a time (15 min or later) and the plan fills the cleanest slots inside it. 'Prioritise' keeps the deadline but raises the car's floor to 90 % of pro-rata and doubles its weight, so it wins ties. Every urgent session pays the traditional rate R = $0.25/kWh and forfeits the shared-savings discount \u2014 that is why nobody presses it to jump the queue. 1 urgent right now.", + "sources": [ + "Urgency \u2014 three bands, one price" + ], + "suggested_actions": [ + { + "label": "Sessions", + "path": "/ops/sessions" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 3, + "title": "Dev's meeting across town \u2014 Leaving soon", + "story": "Dev said 6 h, then a client moves the meeting up: he must leave in 90 minutes with 8 kWh. He taps 'Leaving soon' and picks the time. Promise: the new time becomes the deadline, the plan fills the cleanest slots before it, price R.", + "sim_start": "2026-04-14T07:22", + "steps": [ + { + "sim": "07:22", + "who": "driver", + "text": "had plugged in for 6 h wanting 8 kWh", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c58", + "departure_at": "2026-04-14T13:22:00", + "kwh_needed": 8 + } + }, + "response": { + "status": "charging", + "session_id": 1003, + "connector_id": "c58", + "plan": { + "start": "2026-04-14T07:40:00", + "end": "2026-04-14T09:40:00", + "ready_by": "2026-04-14T13:22:00" + }, + "eta": null, + "price": { + "tier": "green", + "usd_per_kwh": 0.2387 + }, + "boost": false, + "urgency": null, + "kwh_needed": 8.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "07:22", + "who": "driver", + "text": "taps 'Leaving soon' at 08:52", + "request": { + "method": "POST", + "path": "/sessions/1003/urgency", + "body": { + "level": "soon", + "leave_at": "2026-04-14T08:52:00" + } + }, + "response": { + "status": "charging", + "session_id": 1003, + "connector_id": "c58", + "plan": { + "start": "2026-04-14T07:20:00", + "end": "2026-04-14T08:40:00", + "ready_by": "2026-04-14T08:52:00" + }, + "eta": "2026-04-14T08:30:00", + "price": { + "tier": "boost", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": "soon", + "kwh_needed": 8.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "07:22", + "who": "system", + "text": "new plan 07:20\u201308:40: 0% of the energy in zero-carbon slots inside the 90 min (charge-now: 0%)" + }, + { + "sim": "08:52", + "who": "driver", + "text": "walks to the car at the time he said", + "request": { + "method": "GET", + "path": "/sessions/1003/live" + }, + "response": { + "status": "ended", + "session_id": 1003, + "kw_now": 0.0, + "grid_percentile": 57.6, + "kwh_delivered": 7.837, + "kwh_needed": 8.0, + "saved_usd": 0.0, + "saved_kgco2": -0.0578 + } + } + ], + "checks": [ + { + "name": "ready-by moved to the chosen time", + "ok": true, + "detail": "08:52" + }, + { + "name": "pays exactly R", + "ok": true, + "detail": "$0.25" + }, + { + "name": "the 8 kWh are in the car by the new time", + "ok": true, + "detail": "7.84 / 8 kWh at 08:52" + } + ], + "frame_mark": 101 + }, + { + "n": 4, + "title": "Aisha is on call \u2014 Prioritise", + "story": "Aisha, a nurse, might be paged. She keeps her 17:30 but taps 'Prioritise'. Ben plugs in next to her with the same need and no button. Promise: her deadline is unchanged, she pays R, she is held to 90 % of pro-rata (Ben to 50 %), and when the site is short her allowed shortfall is half of anyone else's. On a quiet site both simply get charged.", + "sim_start": "2026-04-14T08:52", + "steps": [ + { + "sim": "08:52", + "who": "driver", + "text": "Aisha plugs in on c57: 12 kWh by 14:52", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c57", + "departure_at": "2026-04-14T14:52:00", + "kwh_needed": 12 + } + }, + "response": { + "status": "charging", + "session_id": 1004, + "connector_id": "c57", + "plan": { + "start": "2026-04-14T09:00:00", + "end": "2026-04-14T11:05:00", + "ready_by": "2026-04-14T14:52:00" + }, + "eta": "2026-04-14T11:05:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.2481 + }, + "boost": false, + "urgency": null, + "kwh_needed": 12.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "08:52", + "who": "driver", + "text": "Ben plugs in on c58: same 12 kWh by 14:52", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c58", + "departure_at": "2026-04-14T14:52:00", + "kwh_needed": 12 + } + }, + "response": { + "status": "charging", + "session_id": 1005, + "connector_id": "c58", + "plan": { + "start": "2026-04-14T09:00:00", + "end": "2026-04-14T11:05:00", + "ready_by": "2026-04-14T14:52:00" + }, + "eta": "2026-04-14T11:05:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.2481 + }, + "boost": false, + "urgency": null, + "kwh_needed": 12.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "08:52", + "who": "driver", + "text": "Aisha taps 'Prioritise'", + "request": { + "method": "POST", + "path": "/sessions/1004/urgency", + "body": { + "level": "priority" + } + }, + "response": { + "status": "charging", + "session_id": 1004, + "connector_id": "c57", + "plan": { + "start": "2026-04-14T09:00:00", + "end": "2026-04-14T11:05:00", + "ready_by": "2026-04-14T14:52:00" + }, + "eta": "2026-04-14T11:05:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": "priority", + "kwh_needed": 12.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "09:52", + "who": "system", + "text": "after 60 min: Aisha 5.23 kWh, Ben 5.82 kWh (pro-rata after 1 of 6 h = 2.0 kWh; floors 90 % / 50 %; both also hold the 3.5 kWh first-hour floor)" + }, + { + "sim": "09:52", + "who": "system", + "text": "priority costs the other drivers nothing unless the site is short: today both are well above their floors" + } + ], + "checks": [ + { + "name": "deadline unchanged", + "ok": true, + "detail": "14:52" + }, + { + "name": "pays exactly R", + "ok": true, + "detail": "$0.25" + }, + { + "name": "Aisha at or above 90 % of pro-rata", + "ok": true, + "detail": "5.23 >= 1.8" + }, + { + "name": "Ben at or above 50 % of pro-rata", + "ok": true, + "detail": "5.82 >= 1.0" + } + ], + "frame_mark": 229, + "assistant": { + "question": "what does prioritise do?", + "answer": "Three driver buttons, one price. 'Leaving now' sets the deadline to now: full power immediately, first claim on headroom. 'Leaving soon' takes a time (15 min or later) and the plan fills the cleanest slots inside it. 'Prioritise' keeps the deadline but raises the car's floor to 90 % of pro-rata and doubles its weight, so it wins ties. Every urgent session pays the traditional rate R = $0.25/kWh and forfeits the shared-savings discount \u2014 that is why nobody presses it to jump the queue. 2 urgent right now.", + "sources": [ + "Urgency \u2014 three bands, one price" + ], + "suggested_actions": [ + { + "label": "Sessions", + "path": "/ops/sessions" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 5, + "title": "Sam skips the form", + "story": "Sam plugs in and ignores the app: the default 'Leaving at 17:30?' stands and no kWh is typed. Promise: the site still plans for him \u2014 need from this bay's history, else the site median \u2014 and says how confident it is.", + "sim_start": "2026-04-14T09:52", + "steps": [ + { + "sim": "09:52", + "who": "driver", + "text": "plugs in on c56, types nothing", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c56", + "departure_at": "2026-04-14T17:52:00" + } + }, + "response": { + "status": "charging", + "session_id": 1006, + "connector_id": "c56", + "plan": { + "start": "2026-04-14T09:50:00", + "end": "2026-04-14T11:30:00", + "ready_by": "2026-04-14T17:52:00" + }, + "eta": "2026-04-14T11:30:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": null, + "kwh_needed": 9.1, + "need_confidence": "site", + "max_kw": null + } + }, + { + "sim": "09:52", + "who": "system", + "text": "need estimated at 9.1 kWh (confidence: site); plan 09:50\u201311:30" + } + ], + "checks": [ + { + "name": "a need was estimated", + "ok": true, + "detail": "9.1 kWh" + }, + { + "name": "confidence is history or site, never 'declared'", + "ok": true, + "detail": "site" + }, + { + "name": "a plan exists", + "ok": true, + "detail": "" + } + ], + "frame_mark": 326 + }, + { + "n": 6, + "title": "Ravi's plug-in hybrid", + "story": "Ravi's PHEV takes 3.3 kW at most on a 7 kW bay; the app asks for the car once and remembers it. He is at 70 %, wants 90 % of a 40 kWh pack. Promise: the plan never asks the car for more than it can take, and the need comes from the SoC.", + "sim_start": "2026-04-14T09:52", + "steps": [ + { + "sim": "09:52", + "who": "driver", + "text": "plugs in on c56: PHEV, 40 kWh pack, max 3.3 kW, 70 % -> 90 %", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c56", + "departure_at": "2026-04-14T16:52:00", + "vehicle": { + "model": "PHEV", + "battery_kwh": 40, + "max_kw": 3.3 + }, + "soc_now": 0.7, + "target_soc": 0.9 + } + }, + "response": { + "status": "charging", + "session_id": 1006, + "connector_id": "c56", + "plan": { + "start": "2026-04-14T09:50:00", + "end": "2026-04-14T12:20:00", + "ready_by": "2026-04-14T16:52:00" + }, + "eta": "2026-04-14T12:20:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": null, + "kwh_needed": 8.0, + "need_confidence": "declared", + "max_kw": 3.3 + } + }, + { + "sim": "10:00", + "who": "system", + "text": "need 8.0 kWh (declared); planner cap 3.3 kW; plan peak 3.3 kW; meter 3.3 kW" + } + ], + "checks": [ + { + "name": "need = (0.9 - 0.7) x 40 = 8 kWh", + "ok": true, + "detail": "8.0" + }, + { + "name": "plan never exceeds the car's 3.3 kW", + "ok": true, + "detail": "peak 3.3 kW" + }, + { + "name": "meter never exceeds 3.3 kW", + "ok": true, + "detail": "3.3 kW" + } + ], + "frame_mark": 326, + "assistant": { + "question": "why is bay c56 only getting 3.3 kW?", + "answer": "Bay c56 is at 3.3 kW, 0.44 of 8.0 kWh delivered \u2014 in its planned window: next slots [3.3, 3.3, 3.3, 3.3, 3.3, 3.3] kW, deadline 16:52, slack 4.58 h.", + "sources": [ + "The scheduler" + ], + "suggested_actions": [ + { + "label": "Open the bay", + "path": "/ops/chargers" + }, + { + "label": "See the plan", + "path": "/ops/schedules" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 7, + "title": "The delivery van on the fleet bay", + "story": "Facilities marked bays c41/c42 as fleet bays: a van that cannot leave is lost revenue, not an inconvenience. The van plugs into c41 with a vague '5 h'. Promise: full power immediately, whatever the slack, and the ops screen marks the bay ASAP.", + "sim_start": "2026-04-14T10:00", + "steps": [ + { + "sim": "10:00", + "who": "driver", + "text": "van plugs into c41: 20 kWh, 5 h", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c41", + "departure_at": "2026-04-14T15:00:00", + "kwh_needed": 20 + } + }, + "response": { + "status": "charging", + "session_id": 1007, + "connector_id": "c41", + "plan": { + "start": "2026-04-14T10:00:00", + "end": "2026-04-14T12:55:00", + "ready_by": "2026-04-14T15:00:00" + }, + "eta": "2026-04-14T12:55:00", + "price": { + "tier": "standard", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": null, + "kwh_needed": 20.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "10:03", + "who": "system", + "text": "plan first slot 7.0 kW; meter 7.0 kW; asap=True (boost=False: nobody pressed anything)" + } + ], + "checks": [ + { + "name": "full power from the first slot", + "ok": true, + "detail": "7.0 kW" + }, + { + "name": "meter at full power", + "ok": true, + "detail": "7.0 kW" + }, + { + "name": "ops sees the ASAP bay flag", + "ok": true, + "detail": "" + } + ], + "frame_mark": 340 + }, + { + "n": 8, + "title": "Tom leaves early", + "story": "Tom said 17:00; something comes up and he unplugs after about an hour without telling anyone. Promise: the floors mean he leaves with a usable charge, the receipt says plainly how far short of the stated need he is, and the plan re-solves without him.", + "sim_start": "2026-04-14T10:03", + "steps": [ + { + "sim": "10:03", + "who": "driver", + "text": "Tom plugs in on c55: 15 kWh by 18:03", + "request": { + "method": "POST", + "path": "/sessions", + "body": { + "connector_id": "c55", + "departure_at": "2026-04-14T18:03:00", + "kwh_needed": 15 + } + }, + "response": { + "status": "charging", + "session_id": 1008, + "connector_id": "c55", + "plan": { + "start": "2026-04-14T10:00:00", + "end": "2026-04-14T12:35:00", + "ready_by": "2026-04-14T18:03:00" + }, + "eta": "2026-04-14T12:30:00", + "price": { + "tier": "green", + "usd_per_kwh": 0.25 + }, + "boost": false, + "urgency": null, + "kwh_needed": 15.0, + "need_confidence": "declared", + "max_kw": null + } + }, + { + "sim": "11:05", + "who": "driver", + "text": "Tom pulls the plug an hour in, without a word", + "request": { + "method": "POST", + "path": "/sessions/1008/unplug" + }, + "response": { + "status": "ended", + "session_id": 1008, + "kw_now": 0.0, + "grid_percentile": 57.6, + "kwh_delivered": 4.9, + "kwh_needed": 15.0, + "saved_usd": -0.0, + "saved_kgco2": 0.7966 + } + }, + { + "sim": "11:07", + "who": "system", + "text": "receipt: 4.90 of 15.0 kWh, status ended, 10.10 kWh short of what he said he needed; ops event: stated 18:03, left 11:05, 10.1 kWh short" + } + ], + "checks": [ + { + "name": "first-hour floor held: >= 3.5 kWh after ~60 min", + "ok": true, + "detail": "4.90 kWh" + }, + { + "name": "receipt is honest: status ended, shortfall visible", + "ok": true, + "detail": "" + }, + { + "name": "plan re-solved without him", + "ok": true, + "detail": "" + }, + { + "name": "unplug event on the ops feed names him and the shortfall", + "ok": true, + "detail": "" + } + ], + "frame_mark": 348, + "assistant": { + "question": "what happens when a driver leaves before the time they gave?", + "answer": "A driver who leaves before the time they gave still leaves with a usable charge: the first-hour floor puts min(need, 3.5 kWh) in the car within 60 minutes of plugging in, and the progress floor keeps every car at or above 50 % of pro-rata at every 30-minute checkpoint since arrival (90 % for a prioritised car). The receipt states plainly how many kWh short of the stated need the car left, the plan re-solves without it at once, and the ops feed logs an 'unplug' event with the stated and actual times.", + "sources": [ + "The scheduler", + "Driver-side guarantees", + "Hard questions" + ], + "suggested_actions": [ + { + "label": "Sessions", + "path": "/ops/sessions" + }, + { + "label": "Alerts", + "path": "/ops/alerts" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 9, + "title": "The grid API goes dark", + "story": "The carbon-signal provider has an outage, then the cached forecast is knocked out too. Promise: planning continues on every rung (cached -> tariff), no car loses its plan or deadline, the banner says why, and it all comes back when the signal does.", + "sim_start": "2026-04-14T11:07", + "steps": [ + { + "sim": "11:07", + "who": "system", + "text": "live signal lost", + "request": { + "method": "POST", + "path": "/demo/signal_outage", + "body": { + "rungs": [ + "live" + ] + } + }, + "response": { + "status": 200, + "changed": "fail-safe mode live -> cached", + "detail": { + "mode_before": "live", + "mode_after": "cached", + "ladder": { + "live": false, + "cached": true, + "tariff": true, + "deadline": true + }, + "cache_expires_at": "2026-04-14T17:07:00" + } + } + }, + { + "sim": "11:07", + "who": "system", + "text": "cached forecast lost too", + "request": { + "method": "POST", + "path": "/demo/signal_outage", + "body": { + "rungs": [ + "cached" + ] + } + }, + "response": { + "status": 200, + "changed": "fail-safe mode cached -> tariff", + "detail": { + "mode_before": "cached", + "mode_after": "tariff", + "ladder": { + "live": false, + "cached": false, + "tariff": true, + "deadline": true + }, + "cache_expires_at": "2026-04-14T17:07:00" + } + } + }, + { + "sim": "11:13", + "who": "system", + "text": "mode live -> cached -> tariff; plan still covers 32 cars (was 32); mode events on the ops feed: 2" + }, + { + "sim": "11:13", + "who": "system", + "text": "signal restored", + "request": { + "method": "POST", + "path": "/demo/signal_outage", + "body": { + "restore": true + } + }, + "response": { + "status": 200, + "changed": "fail-safe mode tariff -> live", + "detail": { + "mode_before": "tariff", + "mode_after": "live", + "ladder": { + "live": true, + "cached": true, + "tariff": true, + "deadline": true + }, + "cache_expires_at": null + } + } + } + ], + "checks": [ + { + "name": "ladder stepped down in order", + "ok": true, + "detail": "" + }, + { + "name": "no car lost its plan", + "ok": true, + "detail": "32 vs 32" + }, + { + "name": "plan frames still flowing with the degraded mode", + "ok": true, + "detail": "tariff" + }, + { + "name": "back to live when the signal returns", + "ok": true, + "detail": "live" + } + ], + "frame_mark": 444, + "assistant": { + "question": "what happens if the grid API dies?", + "answer": "We are on the 'tariff' rung. If the grid API dies the ladder steps down: tariff (tariff + deadlines, no carbon) \u2192 deadline (earliest-deadline-first only) \u2192 full (no solve: every bay holds its static safe share). Every rung that solves still honours deadlines and the feed; the last rung is the 1.833 kW static share that lives on each charger itself, so even with the backend gone the sum over 60 bays stays under the 150.0 kW feed. Nobody is stranded; the plan gets less clean, not less safe.", + "sources": [ + "The fail-safe ladder", + "OCPP" + ], + "suggested_actions": [ + { + "label": "Watch the mode", + "path": "/ops/overview" + }, + { + "label": "Alerts", + "path": "/ops/alerts" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + }, + { + "n": 10, + "title": "The lunchtime rush", + "story": "Two waves of twenty cars arrive within minutes, all wanting 8\u201310 kWh inside two hours \u2014 more cars than bays, more demand than the feed can give. Promise: nobody is refused, the shortfall is shared instead of starving one car, the site never crosses its feed, cars that are done are asked to move, and the slackest cars get a move-by time.", + "sim_start": "2026-04-14T11:13", + "steps": [ + { + "sim": "11:13", + "who": "ops", + "text": "first wave: 20 arrivals with 2 h deadlines", + "request": { + "method": "POST", + "path": "/demo/oversubscribe" + }, + "response": { + "status": 200, + "changed": "20 cars plugged in with 2 h deadlines; plan re-solved (elastic shortfall)", + "detail": { + "added": 20, + "session_ids": [ + 1009, + 1010, + 1011, + 1012, + 1013, + 1014, + 1015, + 1016, + 1017, + 1018, + 1019, + 1020, + 1021, + 1022, + 1023, + 1024, + 1025, + 1026, + 1027, + 1028 + ], + "connectors_active": 52, + "waiting": 0, + "demand_kw_if_all_full_power": 364.0, + "feed_kw": 150, + "kwh_due_in_2h": 203.2 + } + } + }, + { + "sim": "11:14", + "who": "ops", + "text": "second wave: 20 more", + "request": { + "method": "POST", + "path": "/demo/oversubscribe" + }, + "response": { + "status": 200, + "changed": "20 cars plugged in with 2 h deadlines; plan re-solved (elastic shortfall)", + "detail": { + "added": 20, + "session_ids": [ + 1029, + 1030, + 1031, + 1032, + 1033, + 1034, + 1035, + 1036, + 1037, + 1038, + 1039, + 1040, + 1041, + 1042, + 1043, + 1044, + 1045, + 1046, + 1047, + 1048 + ], + "connectors_active": 55, + "waiting": 17, + "demand_kw_if_all_full_power": 385.0, + "feed_kw": 150, + "kwh_due_in_2h": 230.5 + } + } + }, + { + "sim": "11:14", + "who": "system", + "text": "55 of 60 bays active, 17 cars waiting for a bay; if every plugged car drew full power that would be 385.0 kW on a 150 kW feed; 230.5 kWh due inside 2 h" + }, + { + "sim": "11:44", + "who": "system", + "text": "30 min later: peak EV draw 110.0 kW vs headroom 110.0 kW; 17 move-by times issued, 0 cars finished, 5 done cars moved on for a waiting car, 13 still waiting" + } + ], + "checks": [ + { + "name": "metered EV load never exceeded feed minus building", + "ok": true, + "detail": "worst 110.0 vs 110.0 kW" + }, + { + "name": "nobody refused: every arrival is charging or queued", + "ok": true, + "detail": "" + }, + { + "name": "the site really was full: a queue formed", + "ok": true, + "detail": "17 waiting" + }, + { + "name": "move-by times were issued to the slackest cars", + "ok": true, + "detail": "17 move_by events" + }, + { + "name": "the day's reported peak (EV + building) stays within the feed", + "ok": true, + "detail": "149.9 vs 150.0 kW" + } + ], + "frame_mark": 457, + "assistant": { + "question": "13 cars are waiting \u2014 who moves first?", + "answer": "13 cars waiting for a bay. Done cars idle 10 min or more are asked to move first (0 done now); then, one per waiting car, the plugged car with the most slack gets a move-by time = now + time to finish at full power (move-by set: c06 by 11:28, c13 by 11:30, c17 by 12:06, c24 by 12:05, c27 by 12:04). Never a car under 1 h of slack, never an urgent one.", + "sources": [ + "The scheduler" + ], + "suggested_actions": [ + { + "label": "Live charging", + "path": "/ops/sites" + } + ], + "fallback": true, + "degraded": false, + "usage": null + } + } + ] +} \ No newline at end of file diff --git a/docs/scenarios/run.md b/docs/scenarios/run.md new file mode 100644 index 0000000..fbf5a90 --- /dev/null +++ b/docs/scenarios/run.md @@ -0,0 +1,224 @@ +# Noonshift — ten real-world scenarios, recorded + +Run 2026-09-13 08:16 against `http://127.0.0.1:8001` · site `site-1`: 60 bays, feed 150.0 kW, block 100.0 kW, R = $0.25/kWh, package pilot · sim clock ×119 · zero-carbon hours today 09:00–18:00 + +**36 of 36 checks passed.** Every step below is a real request to the API or a frame from `/ws`; nothing is mocked. Reproduce with `SIM_SPEED=120 python -m uvicorn noonshift.api:app --port 8000` then `python scripts/scenarios.py`. + +## 1. Priya's normal day + +*Priya parks at the office, taps the pre-filled 'Leaving at 17:30?', asks for 12 kWh. She wants her car full by then; the site wants that energy in the zero-carbon hours and never above the feed.* + +| sim | who | what happened | +|---|---|---| +| 06:17 | driver | previews the price for a ready-by 8 h out on bay c60 `GET /price?departure_at=2026-04-14T14:17:00&kwh_needed=12` | +| 06:17 | driver | plugs in: 12 kWh by 14:17 `POST /sessions` | +| 06:23 | system | plan window 06:20–10:15, ready by 14:17; 58% of her planned energy lands in zero-carbon hours (charging right now: 0%) | +| 07:18 | driver | checks the app an hour in `GET /sessions/1001/live` | +| 07:18 | driver | receipt so far: $0.3167 and 2.8517 kg CO2 saved (estimate) — grid cleaner than 1.0% of today | + +**Promise kept?** + +- ✅ plan ends before her stated departure — 10:15 <= 14:17 +- ✅ price never above R — $0.2358 vs R $0.25 (green) +- ✅ more of her energy in clean hours than charging immediately — 58% vs 0% +- ✅ first-hour floor: >= min(need, 3.5 kWh) after 60 min — 3.50 kWh + +**Ask the site:** *what is bay c60 doing right now?* + +> Bay c60 is at 0.0 kW, 3.5 of 12.0 kWh delivered — deferred: an empty slot in its plan. It has 5.77 h of slack before 14:17, so the plan moves the bulk of its 12.0 kWh to cleaner or cheaper slots inside that deadline (today's zero-carbon hours are 09:00–18:00); next slots [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] kW. Not a fault. + +offline template answer (no model key) · sources: The scheduler · suggested: Open the bay → /ops/chargers, See the plan → /ops/schedules + +## 2. Maya's sick kid — Leaving now + +*Maya plugged in for the day. School calls mid-morning: come now. She taps 'Leaving now'. Promise: full power from the next slot, at today's rate, no premium — and nobody else loses their floor.* + +| sim | who | what happened | +|---|---|---| +| 07:18 | driver | had plugged in for 6 h wanting 10 kWh `POST /sessions` | +| 07:19 | system | bay c59 is deferred to a cleaner hour, drawing 0.0 kW when the phone rings | +| 07:19 | driver | taps 'Leaving now' `POST /sessions/1002/urgency` | +| 07:22 | system | bay c59: 0.0 kW before -> 7.0 kW now; 'boost' event on the ops feed | + +**Promise kept?** + +- ✅ full power within two slots — 7.0 kW +- ✅ pays exactly R, no premium — $0.25 +- ✅ ops feed shows the boost event and flag + +**Ask the site:** *what does boost do to the other cars?* + +> Three driver buttons, one price. 'Leaving now' sets the deadline to now: full power immediately, first claim on headroom. 'Leaving soon' takes a time (15 min or later) and the plan fills the cleanest slots inside it. 'Prioritise' keeps the deadline but raises the car's floor to 90 % of pro-rata and doubles its weight, so it wins ties. Every urgent session pays the traditional rate R = $0.25/kWh and forfeits the shared-savings discount — that is why nobody presses it to jump the queue. 1 urgent right now. + +offline template answer (no model key) · sources: Urgency — three bands, one price · suggested: Sessions → /ops/sessions + +## 3. Dev's meeting across town — Leaving soon + +*Dev said 6 h, then a client moves the meeting up: he must leave in 90 minutes with 8 kWh. He taps 'Leaving soon' and picks the time. Promise: the new time becomes the deadline, the plan fills the cleanest slots before it, price R.* + +| sim | who | what happened | +|---|---|---| +| 07:22 | driver | had plugged in for 6 h wanting 8 kWh `POST /sessions` | +| 07:22 | driver | taps 'Leaving soon' at 08:52 `POST /sessions/1003/urgency` | +| 07:22 | system | new plan 07:20–08:40: 0% of the energy in zero-carbon slots inside the 90 min (charge-now: 0%) | +| 08:52 | driver | walks to the car at the time he said `GET /sessions/1003/live` | + +**Promise kept?** + +- ✅ ready-by moved to the chosen time — 08:52 +- ✅ pays exactly R — $0.25 +- ✅ the 8 kWh are in the car by the new time — 7.84 / 8 kWh at 08:52 + +## 4. Aisha is on call — Prioritise + +*Aisha, a nurse, might be paged. She keeps her 17:30 but taps 'Prioritise'. Ben plugs in next to her with the same need and no button. Promise: her deadline is unchanged, she pays R, she is held to 90 % of pro-rata (Ben to 50 %), and when the site is short her allowed shortfall is half of anyone else's. On a quiet site both simply get charged.* + +| sim | who | what happened | +|---|---|---| +| 08:52 | driver | Aisha plugs in on c57: 12 kWh by 14:52 `POST /sessions` | +| 08:52 | driver | Ben plugs in on c58: same 12 kWh by 14:52 `POST /sessions` | +| 08:52 | driver | Aisha taps 'Prioritise' `POST /sessions/1004/urgency` | +| 09:52 | system | after 60 min: Aisha 5.23 kWh, Ben 5.82 kWh (pro-rata after 1 of 6 h = 2.0 kWh; floors 90 % / 50 %; both also hold the 3.5 kWh first-hour floor) | +| 09:52 | system | priority costs the other drivers nothing unless the site is short: today both are well above their floors | + +**Promise kept?** + +- ✅ deadline unchanged — 14:52 +- ✅ pays exactly R — $0.25 +- ✅ Aisha at or above 90 % of pro-rata — 5.23 >= 1.8 +- ✅ Ben at or above 50 % of pro-rata — 5.82 >= 1.0 + +**Ask the site:** *what does prioritise do?* + +> Three driver buttons, one price. 'Leaving now' sets the deadline to now: full power immediately, first claim on headroom. 'Leaving soon' takes a time (15 min or later) and the plan fills the cleanest slots inside it. 'Prioritise' keeps the deadline but raises the car's floor to 90 % of pro-rata and doubles its weight, so it wins ties. Every urgent session pays the traditional rate R = $0.25/kWh and forfeits the shared-savings discount — that is why nobody presses it to jump the queue. 2 urgent right now. + +offline template answer (no model key) · sources: Urgency — three bands, one price · suggested: Sessions → /ops/sessions + +## 5. Sam skips the form + +*Sam plugs in and ignores the app: the default 'Leaving at 17:30?' stands and no kWh is typed. Promise: the site still plans for him — need from this bay's history, else the site median — and says how confident it is.* + +| sim | who | what happened | +|---|---|---| +| 09:52 | driver | plugs in on c56, types nothing `POST /sessions` | +| 09:52 | system | need estimated at 9.1 kWh (confidence: site); plan 09:50–11:30 | + +**Promise kept?** + +- ✅ a need was estimated — 9.1 kWh +- ✅ confidence is history or site, never 'declared' — site +- ✅ a plan exists + +## 6. Ravi's plug-in hybrid + +*Ravi's PHEV takes 3.3 kW at most on a 7 kW bay; the app asks for the car once and remembers it. He is at 70 %, wants 90 % of a 40 kWh pack. Promise: the plan never asks the car for more than it can take, and the need comes from the SoC.* + +| sim | who | what happened | +|---|---|---| +| 09:52 | driver | plugs in on c56: PHEV, 40 kWh pack, max 3.3 kW, 70 % -> 90 % `POST /sessions` | +| 10:00 | system | need 8.0 kWh (declared); planner cap 3.3 kW; plan peak 3.3 kW; meter 3.3 kW | + +**Promise kept?** + +- ✅ need = (0.9 - 0.7) x 40 = 8 kWh — 8.0 +- ✅ plan never exceeds the car's 3.3 kW — peak 3.3 kW +- ✅ meter never exceeds 3.3 kW — 3.3 kW + +**Ask the site:** *why is bay c56 only getting 3.3 kW?* + +> Bay c56 is at 3.3 kW, 0.44 of 8.0 kWh delivered — in its planned window: next slots [3.3, 3.3, 3.3, 3.3, 3.3, 3.3] kW, deadline 16:52, slack 4.58 h. + +offline template answer (no model key) · sources: The scheduler · suggested: Open the bay → /ops/chargers, See the plan → /ops/schedules + +## 7. The delivery van on the fleet bay + +*Facilities marked bays c41/c42 as fleet bays: a van that cannot leave is lost revenue, not an inconvenience. The van plugs into c41 with a vague '5 h'. Promise: full power immediately, whatever the slack, and the ops screen marks the bay ASAP.* + +| sim | who | what happened | +|---|---|---| +| 10:00 | driver | van plugs into c41: 20 kWh, 5 h `POST /sessions` | +| 10:03 | system | plan first slot 7.0 kW; meter 7.0 kW; asap=True (boost=False: nobody pressed anything) | + +**Promise kept?** + +- ✅ full power from the first slot — 7.0 kW +- ✅ meter at full power — 7.0 kW +- ✅ ops sees the ASAP bay flag + +## 8. Tom leaves early + +*Tom said 17:00; something comes up and he unplugs after about an hour without telling anyone. Promise: the floors mean he leaves with a usable charge, the receipt says plainly how far short of the stated need he is, and the plan re-solves without him.* + +| sim | who | what happened | +|---|---|---| +| 10:03 | driver | Tom plugs in on c55: 15 kWh by 18:03 `POST /sessions` | +| 11:05 | driver | Tom pulls the plug an hour in, without a word `POST /sessions/1008/unplug` | +| 11:07 | system | receipt: 4.90 of 15.0 kWh, status ended, 10.10 kWh short of what he said he needed; ops event: stated 18:03, left 11:05, 10.1 kWh short | + +**Promise kept?** + +- ✅ first-hour floor held: >= 3.5 kWh after ~60 min — 4.90 kWh +- ✅ receipt is honest: status ended, shortfall visible +- ✅ plan re-solved without him +- ✅ unplug event on the ops feed names him and the shortfall + +**Ask the site:** *what happens when a driver leaves before the time they gave?* + +> A driver who leaves before the time they gave still leaves with a usable charge: the first-hour floor puts min(need, 3.5 kWh) in the car within 60 minutes of plugging in, and the progress floor keeps every car at or above 50 % of pro-rata at every 30-minute checkpoint since arrival (90 % for a prioritised car). The receipt states plainly how many kWh short of the stated need the car left, the plan re-solves without it at once, and the ops feed logs an 'unplug' event with the stated and actual times. + +offline template answer (no model key) · sources: The scheduler, Driver-side guarantees, Hard questions · suggested: Sessions → /ops/sessions, Alerts → /ops/alerts + +## 9. The grid API goes dark + +*The carbon-signal provider has an outage, then the cached forecast is knocked out too. Promise: planning continues on every rung (cached -> tariff), no car loses its plan or deadline, the banner says why, and it all comes back when the signal does.* + +| sim | who | what happened | +|---|---|---| +| 11:07 | system | live signal lost `POST /demo/signal_outage` | +| 11:07 | system | cached forecast lost too `POST /demo/signal_outage` | +| 11:13 | system | mode live -> cached -> tariff; plan still covers 32 cars (was 32); mode events on the ops feed: 2 | +| 11:13 | system | signal restored `POST /demo/signal_outage` | + +**Promise kept?** + +- ✅ ladder stepped down in order +- ✅ no car lost its plan — 32 vs 32 +- ✅ plan frames still flowing with the degraded mode — tariff +- ✅ back to live when the signal returns — live + +**Ask the site:** *what happens if the grid API dies?* + +> We are on the 'tariff' rung. If the grid API dies the ladder steps down: tariff (tariff + deadlines, no carbon) → deadline (earliest-deadline-first only) → full (no solve: every bay holds its static safe share). Every rung that solves still honours deadlines and the feed; the last rung is the 1.833 kW static share that lives on each charger itself, so even with the backend gone the sum over 60 bays stays under the 150.0 kW feed. Nobody is stranded; the plan gets less clean, not less safe. + +offline template answer (no model key) · sources: The fail-safe ladder, OCPP · suggested: Watch the mode → /ops/overview, Alerts → /ops/alerts + +## 10. The lunchtime rush + +*Two waves of twenty cars arrive within minutes, all wanting 8–10 kWh inside two hours — more cars than bays, more demand than the feed can give. Promise: nobody is refused, the shortfall is shared instead of starving one car, the site never crosses its feed, cars that are done are asked to move, and the slackest cars get a move-by time.* + +| sim | who | what happened | +|---|---|---| +| 11:13 | ops | first wave: 20 arrivals with 2 h deadlines `POST /demo/oversubscribe` | +| 11:14 | ops | second wave: 20 more `POST /demo/oversubscribe` | +| 11:14 | system | 55 of 60 bays active, 17 cars waiting for a bay; if every plugged car drew full power that would be 385.0 kW on a 150 kW feed; 230.5 kWh due inside 2 h | +| 11:44 | system | 30 min later: peak EV draw 110.0 kW vs headroom 110.0 kW; 17 move-by times issued, 0 cars finished, 5 done cars moved on for a waiting car, 13 still waiting | + +**Promise kept?** + +- ✅ metered EV load never exceeded feed minus building — worst 110.0 vs 110.0 kW +- ✅ nobody refused: every arrival is charging or queued +- ✅ the site really was full: a queue formed — 17 waiting +- ✅ move-by times were issued to the slackest cars — 17 move_by events +- ✅ the day's reported peak (EV + building) stays within the feed — 149.9 vs 150.0 kW + +**Ask the site:** *13 cars are waiting — who moves first?* + +> 13 cars waiting for a bay. Done cars idle 10 min or more are asked to move first (0 done now); then, one per waiting car, the plugged car with the most slack gets a move-by time = now + time to finish at full power (move-by set: c06 by 11:28, c13 by 11:30, c17 by 12:06, c24 by 12:05, c27 by 12:04). Never a car under 1 h of slack, never an urgent one. + +offline template answer (no model key) · sources: The scheduler · suggested: Live charging → /ops/sites + +## The day so far + +67 sessions · 318.25 kWh metered · **13.91 kg CO₂** and $1.43 saved vs charging at plug-in (estimate) · peak 149.9 kW vs 150.0 kW charge-now · renewable-hour share 56 % + +**Safety, whole run:** over 335 meter frames the EV load exceeded feed − building by at most 0.0 kW (never). diff --git a/noonshift/api.py b/noonshift/api.py index 9af6204..199c18a 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -192,6 +192,8 @@ def meter_history(): for sid, h in S.setdefault("hist", {}).items(): s = sim.sessions[sid] if s["status"] == "charging": + if not h["kw_min"]: # resolve() may have created the entry a tick before or after this car's first draw + h["start"] = sim.now - timedelta(minutes=1) # (it awaits a threaded solve); anchor to the minute recorded h["kw_min"].append(sim.connectors[s["connector_id"]].kw) elif not h.get("final"): S["impact"][sid], h["final"] = session_impact(h), True @@ -435,6 +437,16 @@ async def boost(sid: int): return await urgency(sid, UrgencyIn(level="now")) +@app.post("/sessions/{sid}/unplug", response_model=LiveOut) +async def unplug(sid: int): + """The driver unplugs (early or not): the session ends with an honest receipt and the plan re-solves without it.""" + s = get_session(sid) + if s["status"] != "charging" and s["status"] != "done": + raise HTTPException(409, f"session is {s['status']}") + await unplug_session(s, "unplug") + return live(sid) + + @app.get("/sessions/{sid}/live", response_model=LiveOut) def live(sid: int): s = get_session(sid) @@ -636,7 +648,13 @@ async def demo_early_unplug(): cands = sorted(charging_sessions(), key=lambda s: (s["kwh_delivered"] / s["kwh_needed"], -s["user_stated_departure"].timestamp())) if not cands: raise HTTPException(409, "nobody is charging") - s = cands[0] + detail = await unplug_session(cands[0], "demo:early_unplug") + return DemoOut(changed="session unplugged early; plan re-solved without it", detail=detail) + + +async def unplug_session(s, reason): + """The driver pulls the plug (StopTransaction on a real charger): end the session, tell ops, re-solve without it.""" + sim = S["sim"] c = sim.connectors[s["connector_id"]] s["departure"] = sim.now c.unplug(sim.now) @@ -645,8 +663,8 @@ async def demo_early_unplug(): "shortfall_kwh": round(s["kwh_needed"] - s["kwh_delivered"], 2)} await event("unplug", detail) await db.save_session(s) - await resolve("demo:early_unplug") - return DemoOut(changed="session unplugged early; plan re-solved without it", detail=detail) + await resolve(reason) + return detail @app.post("/demo/boost", response_model=DemoOut, dependencies=OPS) diff --git a/noonshift/assist.py b/noonshift/assist.py index f976d52..85a889c 100644 --- a/noonshift/assist.py +++ b/noonshift/assist.py @@ -48,8 +48,9 @@ # ---- snapshot ---- -def snapshot(): - """A plain dict from api.S, no I/O. Reuses the REST builders so every number matches the dashboard.""" +def snapshot(pin=()): + """A plain dict from api.S, no I/O. Reuses the REST builders so every number matches the dashboard. + `pin`: connector ids the question names; they always make the row cut.""" from . import api # lazy: api imports this module S, sim = api.S, api.S["sim"] now = sim.now @@ -74,8 +75,8 @@ def snapshot(): "done": sum(r["status"] == "done" for r in rows), "at_risk": sum(r["risk"] == "high" for r in rows), "urgent": sum(bool(r["urgency"] or r["boost"]) for r in rows), "free_bays": status["n_connectors"] - len(rows), "waiting": status["waiting"]} - rows.sort(key=lambda r: (r["risk"] != "high", not (r["urgency"] or r["boost"]), r["status"] != "charging", - r["status"] != "done", r["slack_h"])) + rows.sort(key=lambda r: (r["connector"] not in pin, r["risk"] != "high", not (r["urgency"] or r["boost"]), + r["status"] != "charging", r["status"] != "done", r["slack_h"])) moer, price = S["signal"]["moer"], S["tariff"]["price_per_kwh"] per = len(moer) // 24 hourly = [sum(moer[h * per:(h + 1) * per]) / per for h in range(24)] @@ -122,7 +123,7 @@ def parse(text): def ask(question, history=(), page="", snap=None): """Returns (payload, http_status). The model when a key works; the deterministic fallback otherwise.""" - snap = snap or snapshot() + snap = snap or snapshot(pin={b.lower() for b in BAY_RE.findall(question)}) if not os.environ.get("ANTHROPIC_API_KEY"): return fallback(question, snap), 200 import anthropic @@ -309,6 +310,14 @@ def _trickle(q, snap): ["The scheduler"], [("Chargers", "/ops/chargers")]) +def _early(q, snap): + return _out("A driver who leaves before the time they gave still leaves with a usable charge: the first-hour floor puts min(need, 3.5 kWh) " + "in the car within 60 minutes of plugging in, and the progress floor keeps every car at or above 50 % of pro-rata at every 30-minute " + "checkpoint since arrival (90 % for a prioritised car). The receipt states plainly how many kWh short of the stated need the car left, " + "the plan re-solves without it at once, and the ops feed logs an 'unplug' event with the stated and actual times.", + ["The scheduler", "Driver-side guarantees", "Hard questions"], [("Sessions", "/ops/sessions"), ("Alerts", "/ops/alerts")]) + + def _done(q, snap): done = [r for r in snap["connectors"] if r["status"] == "done"] lst = ", ".join(f"{r['connector']} ({r['idle_min']} min idle)" for r in done[:8]) or "none" @@ -325,11 +334,12 @@ def _done(q, snap): (("co2", "co₂", "carbon", "kg", "km", "saved", "save", "impact", "renewable"), _co2), (("boost", "urgen", "leaving", "priorit", "emergency", "sooner"), _urgency), (("1.4", "trickle", "minimum", "paused", "pause"), _trickle), + (("early", "before the time", "before they said", "leaves before", "left before", "pulls the plug"), _early), (("done", "idle", "finished", "unplug"), _done), (("waiting", "queue", "move", "rotate", "more cars", "than bays", "than plugs"), _queue), (("price", "pay", "cost", "discount", "rate", "$", "money", "package", "alpha", "beta", "profit", "bill"), _money), (("load", "block", "feed", "peak", "limit", "contracted", "overage", "kw", "overcharge", "circuit", "bug", "exceed"), _load), - (("signal", "moer", "solar", "clean", "cleanest", "when", "hour", "grid"), _signal), + (("signal", "moer", "solar", "clean", "cleanest", "grid", "window"), _signal), ] diff --git a/noonshift/sim.py b/noonshift/sim.py index 8804c94..4143579 100644 --- a/noonshift/sim.py +++ b/noonshift/sim.py @@ -91,6 +91,7 @@ def __init__(self, sessions, site, connector_cls=Connector): 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.site = site 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} @@ -106,6 +107,13 @@ def arrive(self, session): c = self.free_connector(session.get("connector_id")) if c: c.plug_in(session) + # A newcomer takes its static share only out of headroom nobody holds yet: the plan may already fill the + # feed, and a re-solve (a thread, tens of ms) can be a tick away. Falls back to the full share after + # DYN_VALID_MIN if no plan ever lands, so the no-backend guarantee is unchanged. + room = self.site["feed_kw"] - self.site["building_load_kw"][(self.now.hour * 60 + self.now.minute) // 5] \ + - sum(x.limit_kw for x in self.connectors.values() if x.session and x is not c) + if room < c.limit_kw: + c.limit_kw, c.dyn_left = max(0.0, room), DYN_VALID_MIN elif session not in self.waiting: self.waiting.append(session) return c diff --git a/scripts/scenarios.py b/scripts/scenarios.py new file mode 100644 index 0000000..ddeb73d --- /dev/null +++ b/scripts/scenarios.py @@ -0,0 +1,385 @@ +"""Ten real-world scenarios against a live backend, told as stories and recorded. + + SIM_SPEED=120 python -m uvicorn noonshift.api:app --port 8000 # a sim-hour every 30 s: the run fits in one sim day + python scripts/scenarios.py [http://127.0.0.1:8000] [--out docs/scenarios] + +Each scenario: who is involved, what they do (driver REST), what the site does (plan/meter/event frames on /ws, the +facilities manager's status), the promise it must keep (checks), and what the operator assistant says when asked. +Writes run.json (every request, response, check) and run.md (the readable transcript). Exit 1 on a failed check. +""" +import asyncio +import json +import sys +import time +import urllib.error +import urllib.request +from datetime import datetime, timedelta +from pathlib import Path + +import websockets + +API = next((a for a in sys.argv[1:] if a.startswith("http")), "http://127.0.0.1:8000") +OUT = Path(sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else "docs/scenarios") +WS = API.replace("http", "ws", 1) + "/ws" +SITE = "site-1" + + +def http(method, path, body=None): + req = urllib.request.Request(API + path, method=method, data=json.dumps(body).encode() if body is not None else None, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b"{}") + + +class Run: + """The recorder: one WS pump for the whole run, a step log per scenario, sim-clock waits.""" + + def __init__(self): + self.frames, self.scenarios, self.cur, self.meta = [], [], None, {} + self.speed = 120.0 # measured at start + + # -- recording -- + def scenario(self, n, title, story): + self.cur = {"n": n, "title": title, "story": story, "sim_start": self.sim_now().isoformat(timespec="minutes"), + "steps": [], "checks": [], "frame_mark": len(self.frames)} + self.scenarios.append(self.cur) + print(f"\n== {n}. {title} [{self.cur['sim_start'][11:]}]") + return self + + def step(self, who, text, request=None, response=None): + self.cur["steps"].append({"sim": self.sim_now().strftime("%H:%M"), "who": who, "text": text, + **({"request": request} if request else {}), **({"response": response} if response is not None else {})}) + print(f" [{who:<9}] {text}") + + def act(self, who, text, method, path, body=None): + code, out = http(method, path, body) + self.step(who, text, {"method": method, "path": path, **({"body": body} if body else {})}, + {"status": code, **(out if isinstance(out, dict) else {"list": out})}) + return code, out + + def check(self, name, ok, detail=""): + self.cur["checks"].append({"name": name, "ok": bool(ok), "detail": str(detail)}) + print(f" {'PASS' if ok else 'FAIL'} {name}" + (f" ({detail})" if detail else "")) + return ok + + def ask(self, question): + code, a = http("POST", "/assist", {"question": question, "page": "/ops/overview"}) + self.cur["assistant"] = {"question": question, **a} + ans = a.get("answer", "") + print(f" [assistant] Q: {question}\n A: {ans[:200]}{'…' if len(ans) > 200 else ''} {'(offline template)' if a.get('fallback') else '(model)'}") + return a + + # -- the site as seen from outside -- + def status(self): + return http("GET", f"/sites/{SITE}/status")[1] + + def sim_now(self): + return datetime.fromisoformat(self.status()["sim_time"]) + + def meters(self, since=0): + return [f for f in self.frames[since:] if f["type"] == "meter"] + + def events(self, name, since=None): + since = self.cur["frame_mark"] if since is None else since + return [f for f in self.frames[since:] if f["type"] == "event" and f["name"] == name] + + def meter_of(self, sid): + for f in reversed(self.frames): + if f["type"] == "meter": + return next((c for c in f["connectors"] if c["session_id"] == sid), None) + + def free_bays(self): + asap = self.status()["connectors_asap"] + return [c["connector_id"] for c in self.meters()[-1]["connectors"] if c["session_id"] is None and c["connector_id"] not in asap] + + def plan_of(self, sid): + p = http("GET", f"/sites/{SITE}/plan")[1] + kw = next((c["kw"] for c in p["connectors"] if c["session_id"] == sid), []) + return datetime.fromisoformat(p["horizon_start"]), kw + + async def wait_until(self, cond, max_minutes): + """Run the site until cond() holds or max_minutes of sim time have passed; returns whether it held.""" + target = self.sim_now() + timedelta(minutes=max_minutes) + while not cond(): + if self.sim_now() >= target: + return False + await asyncio.sleep(0.3) + return True + + async def free_bay(self): + """A free non-fleet bay; if the site is full, wait for one (a done car moves, a session ends).""" + await self.wait_until(lambda: bool(self.free_bays()), 60) + return self.free_bays()[-1] + + def worst_overshoot(self): + """Max over every meter frame of EV kW minus (feed − building): the safety property, for the whole run.""" + return max((f["site_kw"] - (f["feed_kw"] - f["building_load_kw"]) for f in self.meters()), default=-999.0) + + async def wait_sim(self, minutes): + """Let the site run for `minutes` of simulated time (SIM_SPEED sim-seconds per real second).""" + target = self.sim_now() + timedelta(minutes=minutes) + deadline = time.time() + minutes * 60 / self.speed * 3 + 10 + while self.sim_now() < target: + if time.time() > deadline: + raise RuntimeError(f"sim clock did not advance {minutes} min in time (speed {self.speed})") + await asyncio.sleep(0.3) + + def clean_share(self, sid, hourly): + """Share of this car's planned energy that lands in zero-marginal-carbon hours, and the same for charge-now.""" + t0, kw = self.plan_of(sid) + e = [k / 12 for k in kw] + if sum(e) <= 0: + return None, None + planned = sum(x for k, x in enumerate(e) if hourly[(t0 + timedelta(minutes=5 * k)).hour] == 0) / sum(e) + n = max(1, round(sum(e) / (7.0 / 12))) # charge-now: the same energy at 7 kW from this slot on + now = self.sim_now() + immediate = sum(1 for k in range(n) if hourly[(now + timedelta(minutes=5 * k)).hour] == 0) / n + return round(100 * planned), round(100 * immediate) + + +async def main(): + run = Run() + OUT.mkdir(parents=True, exist_ok=True) + async with websockets.connect(WS) as ws: + async def pump(): + async for m in ws: + run.frames.append(json.loads(m)) + task = asyncio.create_task(pump()) + await asyncio.sleep(2) + t0, w0 = run.sim_now(), time.time() + await asyncio.sleep(2) + run.speed = max(1.0, (run.sim_now() - t0).total_seconds() / (time.time() - w0)) + st = run.status() + hourly = [h["gco2_per_kwh"] for h in http("GET", "/grid/signal")[1]] + R = st["employee_rate_usd_per_kwh"] + zero = [h for h in range(24) if hourly[h] == 0] + day_end = run.sim_now().replace(hour=23, minute=30, second=0, microsecond=0) + dep = lambda hours: min(run.sim_now() + timedelta(hours=hours), day_end).replace(second=0, microsecond=0).isoformat() + print(f"site {SITE}: {st['n_connectors']} bays, feed {st['feed_kw']} kW, block {st['block_kw']} kW, R=${R}/kWh, package {st['package']}, " + f"mode {st['mode']}, sim {st['sim_time'][11:16]}, clock x{run.speed:.0f}, zero-carbon hours {zero}") + run.meta = {"api": API, "site": st, "speed": run.speed, "zero_hours": zero, "started": datetime.now().isoformat(timespec="seconds")} + + # 1 ------------------------------------------------------------------------------------------------------ + run.scenario(1, "Priya's normal day", "Priya parks at the office, taps the pre-filled 'Leaving at 17:30?', asks for 12 kWh. " + "She wants her car full by then; the site wants that energy in the zero-carbon hours and never above the feed.") + bay = run.free_bays()[-1] + d = dep(8) + _, pv = run.act("driver", f"previews the price for a ready-by 8 h out on bay {bay}", "GET", f"/price?departure_at={d}&kwh_needed=12") + _, s1 = run.act("driver", "plugs in: 12 kWh by " + d[11:16], "POST", "/sessions", {"connector_id": bay, "departure_at": d, "kwh_needed": 12}) + await run.wait_sim(6) + pl, im = run.clean_share(s1["session_id"], hourly) + run.step("system", f"plan window {s1['plan']['start'][11:16]}–{s1['plan']['end'][11:16]}, ready by {s1['plan']['ready_by'][11:16]}; " + f"{pl}% of her planned energy lands in zero-carbon hours (charging right now: {im}%)") + run.check("plan ends before her stated departure", s1["plan"]["end"] <= d, f"{s1['plan']['end'][11:16]} <= {d[11:16]}") + run.check("price never above R", s1["price"]["usd_per_kwh"] <= R, f"${s1['price']['usd_per_kwh']} vs R ${R} ({s1['price']['tier']})") + run.check("more of her energy in clean hours than charging immediately", pl is not None and pl >= im, f"{pl}% vs {im}%") + await run.wait_sim(55) + _, live = run.act("driver", "checks the app an hour in", "GET", f"/sessions/{s1['session_id']}/live") + run.check("first-hour floor: >= min(need, 3.5 kWh) after 60 min", live["kwh_delivered"] >= 3.5 - 0.35, f"{live['kwh_delivered']:.2f} kWh") + run.step("driver", f"receipt so far: ${live['saved_usd']} and {live['saved_kgco2']} kg CO2 saved (estimate) — grid cleaner than {live['grid_percentile']}% of today") + run.ask(f"what is bay {bay} doing right now?") + + # 2 ------------------------------------------------------------------------------------------------------ + run.scenario(2, "Maya's sick kid — Leaving now", "Maya plugged in for the day. School calls mid-morning: come now. She taps 'Leaving now'. " + "Promise: full power from the next slot, at today's rate, no premium — and nobody else loses their floor.") + bay = run.free_bays()[-1] + _, s2 = run.act("driver", "had plugged in for 6 h wanting 10 kWh", "POST", "/sessions", {"connector_id": bay, "departure_at": dep(6), "kwh_needed": 10}) + deferred = await run.wait_until(lambda: (run.meter_of(s2["session_id"]) or {"kw": 9})["kw"] < 2.0, 45) + before = run.meter_of(s2["session_id"])["kw"] + run.step("system", f"bay {bay} is {'deferred to a cleaner hour, drawing' if deferred else 'already charging at'} {before} kW when the phone rings") + _, u = run.act("driver", "taps 'Leaving now'", "POST", f"/sessions/{s2['session_id']}/urgency", {"level": "now"}) + await run.wait_sim(3) + m = run.meter_of(s2["session_id"]) + run.step("system", f"bay {bay}: {before} kW before -> {m['kw']} kW now; '{'boost' if run.events('boost') else 'no'}' event on the ops feed") + run.check("full power within two slots", m["kw"] >= 6.5, f"{m['kw']} kW") + run.check("pays exactly R, no premium", u["price"]["usd_per_kwh"] == R, f"${u['price']['usd_per_kwh']}") + run.check("ops feed shows the boost event and flag", bool(run.events("boost")) and m["boost"] is True) + run.ask("what does boost do to the other cars?") + + # 3 ------------------------------------------------------------------------------------------------------ + run.scenario(3, "Dev's meeting across town — Leaving soon", "Dev said 6 h, then a client moves the meeting up: he must leave in 90 minutes with 8 kWh. " + "He taps 'Leaving soon' and picks the time. Promise: the new time becomes the deadline, the plan fills the cleanest slots before it, price R.") + bay = run.free_bays()[-1] + _, s3 = run.act("driver", "had plugged in for 6 h wanting 8 kWh", "POST", "/sessions", {"connector_id": bay, "departure_at": dep(6), "kwh_needed": 8}) + leave = dep(1.5) + _, u = run.act("driver", f"taps 'Leaving soon' at {leave[11:16]}", "POST", f"/sessions/{s3['session_id']}/urgency", {"level": "soon", "leave_at": leave}) + run.check("ready-by moved to the chosen time", u["plan"]["ready_by"][:16] == leave[:16], u["plan"]["ready_by"][11:16]) + run.check("pays exactly R", u["price"]["usd_per_kwh"] == R, f"${u['price']['usd_per_kwh']}") + pl, im = run.clean_share(s3["session_id"], hourly) + run.step("system", f"new plan {u['plan']['start'][11:16]}–{u['plan']['end'][11:16]}: {pl}% of the energy in zero-carbon slots inside the 90 min (charge-now: {im}%)") + await run.wait_until(lambda: run.sim_now() >= datetime.fromisoformat(leave), 95) + _, live = run.act("driver", "walks to the car at the time he said", "GET", f"/sessions/{s3['session_id']}/live") + run.check("the 8 kWh are in the car by the new time", live["kwh_delivered"] >= 8 - 0.3, f"{live['kwh_delivered']:.2f} / 8 kWh at {run.sim_now():%H:%M}") + + # 4 ------------------------------------------------------------------------------------------------------ + run.scenario(4, "Aisha is on call — Prioritise", "Aisha, a nurse, might be paged. She keeps her 17:30 but taps 'Prioritise'. Ben plugs in next to her with the same need and no button. " + "Promise: her deadline is unchanged, she pays R, she is held to 90 % of pro-rata (Ben to 50 %), and when the site is short her allowed shortfall is half of anyone else's. On a quiet site both simply get charged.") + bays = run.free_bays()[-2:] + d = dep(6) + _, s4 = run.act("driver", f"Aisha plugs in on {bays[0]}: 12 kWh by {d[11:16]}", "POST", "/sessions", {"connector_id": bays[0], "departure_at": d, "kwh_needed": 12}) + _, s4b = run.act("driver", f"Ben plugs in on {bays[1]}: same 12 kWh by {d[11:16]}", "POST", "/sessions", {"connector_id": bays[1], "departure_at": d, "kwh_needed": 12}) + _, u = run.act("driver", "Aisha taps 'Prioritise'", "POST", f"/sessions/{s4['session_id']}/urgency", {"level": "priority"}) + run.check("deadline unchanged", u["plan"]["ready_by"][:16] == d[:16], u["plan"]["ready_by"][11:16]) + run.check("pays exactly R", u["price"]["usd_per_kwh"] == R, f"${u['price']['usd_per_kwh']}") + await run.wait_sim(60) + a, b = http("GET", f"/sessions/{s4['session_id']}/live")[1], http("GET", f"/sessions/{s4b['session_id']}/live")[1] + run.step("system", f"after 60 min: Aisha {a['kwh_delivered']:.2f} kWh, Ben {b['kwh_delivered']:.2f} kWh (pro-rata after 1 of 6 h = 2.0 kWh; floors 90 % / 50 %; both also hold the 3.5 kWh first-hour floor)") + run.check("Aisha at or above 90 % of pro-rata", a["kwh_delivered"] >= 0.9 * 2.0 - 0.2, f"{a['kwh_delivered']:.2f} >= 1.8") + run.check("Ben at or above 50 % of pro-rata", b["kwh_delivered"] >= 0.5 * 2.0 - 0.2, f"{b['kwh_delivered']:.2f} >= 1.0") + run.step("system", "priority costs the other drivers nothing unless the site is short: " + ( + "today both are well above their floors" if min(a["kwh_delivered"], b["kwh_delivered"]) > 2.0 else f"Aisha {a['kwh_delivered']:.2f} vs Ben {b['kwh_delivered']:.2f} kWh")) + run.ask("what does prioritise do?") + + # 5 ------------------------------------------------------------------------------------------------------ + run.scenario(5, "Sam skips the form", "Sam plugs in and ignores the app: the default 'Leaving at 17:30?' stands and no kWh is typed. " + "Promise: the site still plans for him — need from this bay's history, else the site median — and says how confident it is.") + bay = run.free_bays()[-1] + _, s5 = run.act("driver", f"plugs in on {bay}, types nothing", "POST", "/sessions", {"connector_id": bay, "departure_at": dep(8)}) + run.step("system", f"need estimated at {s5['kwh_needed']} kWh (confidence: {s5['need_confidence']}); plan {s5['plan']['start'][11:16]}–{s5['plan']['end'][11:16]}") + run.check("a need was estimated", s5["kwh_needed"] > 0, f"{s5['kwh_needed']} kWh") + run.check("confidence is history or site, never 'declared'", s5["need_confidence"] in ("history", "site"), s5["need_confidence"]) + run.check("a plan exists", s5["plan"]["start"] is not None) + + # 6 ------------------------------------------------------------------------------------------------------ + run.scenario(6, "Ravi's plug-in hybrid", "Ravi's PHEV takes 3.3 kW at most on a 7 kW bay; the app asks for the car once and remembers it. " + "He is at 70 %, wants 90 % of a 40 kWh pack. Promise: the plan never asks the car for more than it can take, and the need comes from the SoC.") + bay = run.free_bays()[-1] + _, s6 = run.act("driver", f"plugs in on {bay}: PHEV, 40 kWh pack, max 3.3 kW, 70 % -> 90 %", "POST", "/sessions", + {"connector_id": bay, "departure_at": dep(7), "vehicle": {"model": "PHEV", "battery_kwh": 40, "max_kw": 3.3}, "soc_now": 0.7, "target_soc": 0.9}) + _, kw = run.plan_of(s6["session_id"]) + await run.wait_sim(8) + m = run.meter_of(s6["session_id"]) + run.step("system", f"need {s6['kwh_needed']} kWh ({s6['need_confidence']}); planner cap {s6['max_kw']} kW; plan peak {max(kw) if kw else 0} kW; meter {m['kw']} kW") + run.check("need = (0.9 - 0.7) x 40 = 8 kWh", s6["kwh_needed"] == 8.0, s6["kwh_needed"]) + run.check("plan never exceeds the car's 3.3 kW", bool(kw) and max(kw) <= 3.3, f"peak {max(kw) if kw else 0} kW") + run.check("meter never exceeds 3.3 kW", m["kw"] <= 3.3, f"{m['kw']} kW") + run.ask(f"why is bay {bay} only getting {m['kw']} kW?") # a bay named in the question is always in the assistant's snapshot + + # 7 ------------------------------------------------------------------------------------------------------ + run.scenario(7, "The delivery van on the fleet bay", "Facilities marked bays c41/c42 as fleet bays: a van that cannot leave is lost revenue, not an inconvenience. " + "The van plugs into c41 with a vague '5 h'. Promise: full power immediately, whatever the slack, and the ops screen marks the bay ASAP.") + van_bay = st["connectors_asap"][0] + _, s7 = run.act("driver", f"van plugs into {van_bay}: 20 kWh, 5 h", "POST", "/sessions", {"connector_id": van_bay, "departure_at": dep(5), "kwh_needed": 20}) + _, kw = run.plan_of(s7["session_id"]) + await run.wait_sim(3) + m = run.meter_of(s7["session_id"]) + run.step("system", f"plan first slot {kw[0] if kw else 0} kW; meter {m['kw']} kW; asap={m['asap']} (boost={m['boost']}: nobody pressed anything)") + run.check("full power from the first slot", bool(kw) and kw[0] >= 6.9, f"{kw[0] if kw else 0} kW") + run.check("meter at full power", m["kw"] >= 6.5, f"{m['kw']} kW") + run.check("ops sees the ASAP bay flag", m["asap"] is True) + + # 8 ------------------------------------------------------------------------------------------------------ + run.scenario(8, "Tom leaves early", "Tom said 17:00; something comes up and he unplugs after about an hour without telling anyone. " + "Promise: the floors mean he leaves with a usable charge, the receipt says plainly how far short of the stated need he is, and the plan re-solves without him.") + bay = await run.free_bay() + d = dep(8) + _, s8 = run.act("driver", f"Tom plugs in on {bay}: 15 kWh by {d[11:16]}", "POST", "/sessions", {"connector_id": bay, "departure_at": d, "kwh_needed": 15}) + await run.wait_sim(62) + _, rc = run.act("driver", "Tom pulls the plug an hour in, without a word", "POST", f"/sessions/{s8['session_id']}/unplug") + await run.wait_sim(2) # let the ops feed catch up + ev = next((e["detail"] for e in run.events("unplug") if e["detail"].get("session_id") == s8["session_id"]), {}) + run.step("system", f"receipt: {rc['kwh_delivered']:.2f} of {rc['kwh_needed']} kWh, status {rc['status']}, {rc['kwh_needed'] - rc['kwh_delivered']:.2f} kWh short of what he said he needed" + + (f"; ops event: stated {ev['stated_departure'][11:16]}, left {ev['left_at'][11:16]}, {ev['shortfall_kwh']} kWh short" if ev else "")) + run.check("first-hour floor held: >= 3.5 kWh after ~60 min", rc["kwh_delivered"] >= 3.5 - 0.35, f"{rc['kwh_delivered']:.2f} kWh") + run.check("receipt is honest: status ended, shortfall visible", rc["status"] == "ended" and rc["kwh_delivered"] < rc["kwh_needed"]) + run.check("plan re-solved without him", s8["session_id"] not in {c["session_id"] for c in http("GET", f"/sites/{SITE}/plan")[1]["connectors"]}) + run.check("unplug event on the ops feed names him and the shortfall", bool(ev) and "shortfall_kwh" in ev) + run.ask("what happens when a driver leaves before the time they gave?") + + # 9 ------------------------------------------------------------------------------------------------------ + run.scenario(9, "The grid API goes dark", "The carbon-signal provider has an outage, then the cached forecast is knocked out too. " + "Promise: planning continues on every rung (cached -> tariff), no car loses its plan or deadline, the banner says why, and it all comes back when the signal does.") + plan_before = len(http("GET", f"/sites/{SITE}/plan")[1]["connectors"]) + _, o1 = run.act("system", "live signal lost", "POST", "/demo/signal_outage", {"rungs": ["live"]}) + _, o2 = run.act("system", "cached forecast lost too", "POST", "/demo/signal_outage", {"rungs": ["cached"]}) + await run.wait_sim(6) + p9 = http("GET", f"/sites/{SITE}/plan")[1] + run.step("system", f"mode live -> {o1['detail']['mode_after']} -> {o2['detail']['mode_after']}; plan still covers {len(p9['connectors'])} cars (was {plan_before}); mode events on the ops feed: {len(run.events('mode'))}") + run.check("ladder stepped down in order", (o1["detail"]["mode_after"], o2["detail"]["mode_after"]) == ("cached", "tariff")) + run.check("no car lost its plan", len(p9["connectors"]) >= plan_before - 1, f"{len(p9['connectors'])} vs {plan_before}") + run.check("plan frames still flowing with the degraded mode", p9["mode"] == "tariff", p9["mode"]) + run.ask("what happens if the grid API dies?") + _, o3 = run.act("system", "signal restored", "POST", "/demo/signal_outage", {"restore": True}) + run.check("back to live when the signal returns", o3["detail"]["mode_after"] == "live", o3["detail"]["mode_after"]) + + # 10 ----------------------------------------------------------------------------------------------------- + run.scenario(10, "The lunchtime rush", "Two waves of twenty cars arrive within minutes, all wanting 8–10 kWh inside two hours — more cars than bays, more demand than the feed can give. " + "Promise: nobody is refused, the shortfall is shared instead of starving one car, the site never crosses its feed, cars that are done are asked to move, and the slackest cars get a move-by time.") + mark = len(run.frames) + _, w1 = run.act("ops", "first wave: 20 arrivals with 2 h deadlines", "POST", "/demo/oversubscribe") + _, w2 = run.act("ops", "second wave: 20 more", "POST", "/demo/oversubscribe") + d = w2["detail"] + run.step("system", f"{d['connectors_active']} of {st['n_connectors']} bays active, {d['waiting']} cars waiting for a bay; if every plugged car drew full power that would be " + f"{d['demand_kw_if_all_full_power']} kW on a {d['feed_kw']} kW feed; {d['kwh_due_in_2h']} kWh due inside 2 h") + await run.wait_sim(30) + peaks = [(f["site_kw"], f["feed_kw"] - f["building_load_kw"]) for f in run.meters(mark)] + worst = max(peaks, key=lambda p: p[0] - p[1]) + st10 = run.status() + mv, done, moved = run.events("move_by", mark), run.events("done", mark), [e for e in run.events("unplug", mark) if e["detail"].get("moved")] + run.step("system", f"30 min later: peak EV draw {worst[0]} kW vs headroom {worst[1]:.1f} kW; {len(mv)} move-by times issued, {len(done)} cars finished, " + f"{len(moved)} done cars moved on for a waiting car, {st10['waiting']} still waiting") + run.check("metered EV load never exceeded feed minus building", all(kw <= hr + 0.05 for kw, hr in peaks), f"worst {worst[0]} vs {worst[1]:.1f} kW") + run.check("nobody refused: every arrival is charging or queued", w1["detail"]["added"] == 20 and w2["detail"]["added"] == 20) + run.check("the site really was full: a queue formed", d["waiting"] > 0, f"{d['waiting']} waiting") + run.check("move-by times were issued to the slackest cars", len(mv) > 0, f"{len(mv)} move_by events") + im10 = http("GET", f"/sites/{SITE}/impact")[1] + run.check("the day's reported peak (EV + building) stays within the feed", im10["peak_kw"] <= st["feed_kw"] + 0.05, f"{im10['peak_kw']} vs {st['feed_kw']} kW") + run.ask(f"{st10['waiting']} cars are waiting — who moves first?" if st10["waiting"] else "what happens when more cars arrive than bays?") + + run.meta["impact"] = http("GET", f"/sites/{SITE}/impact")[1] + run.meta["worst_overshoot_kw"] = round(run.worst_overshoot(), 3) + run.meta["meter_frames"] = len(run.meters()) + run.meta["ended"] = datetime.now().isoformat(timespec="seconds") + task.cancel() + + failed = [(s["n"], c["name"]) for s in run.scenarios for c in s["checks"] if not c["ok"]] + n_checks = sum(len(s["checks"]) for s in run.scenarios) + (OUT / "run.json").write_text(json.dumps({"meta": run.meta, "scenarios": run.scenarios}, indent=1, default=str), encoding="utf-8") + (OUT / "run.md").write_text(render_md(run, n_checks, failed), encoding="utf-8") + im = run.meta["impact"] + print(f"\nday so far: {im['sessions']} sessions, {im['kwh']} kWh, {im['saved_kgco2']} kg CO2 and ${im['saved_usd']} saved (est.), " + f"peak {im['peak_kw']} vs {im['baseline_peak_kw']} kW charge-now, renewable-hour share {100 * im['renewable_share']:.0f}%") + print(f"safety: over {run.meta['meter_frames']} meter frames the EV load exceeded feed minus building by at most {run.meta['worst_overshoot_kw']} kW") + print(f"{n_checks - len(failed)}/{n_checks} checks passed; transcript -> {OUT / 'run.md'}, {OUT / 'run.json'}") + if failed: + print("FAILED:", failed) + sys.exit(1) + print("SCENARIOS OK") + + +def render_md(run, n_checks, failed): + m = run.meta + st, z = m["site"], m["zero_hours"] + out = ["# Noonshift — ten real-world scenarios, recorded", "", + f"Run {m['started'][:16].replace('T', ' ')} against `{m['api']}` · site `{SITE}`: {st['n_connectors']} bays, feed {st['feed_kw']} kW, " + f"block {st['block_kw']} kW, R = ${st['employee_rate_usd_per_kwh']}/kWh, package {st['package']} · sim clock ×{m['speed']:.0f} · " + f"zero-carbon hours today {f'{z[0]:02d}:00–{z[-1] + 1:02d}:00' if z else 'none'}", "", + f"**{n_checks - len(failed)} of {n_checks} checks passed.** Every step below is a real request to the API or a frame from `/ws`; " + f"nothing is mocked. Reproduce with `SIM_SPEED=120 python -m uvicorn noonshift.api:app --port 8000` then `python scripts/scenarios.py`.", ""] + for s in run.scenarios: + out += [f"## {s['n']}. {s['title']}", "", f"*{s['story']}*", "", "| sim | who | what happened |", "|---|---|---|"] + for stp in s["steps"]: + req = f" `{stp['request']['method']} {stp['request']['path']}`" if "request" in stp else "" + out.append(f"| {stp['sim']} | {stp['who']} | {stp['text'].replace('|', '/')}{req} |") + out += ["", "**Promise kept?**", ""] + out += [f"- {'✅' if c['ok'] else '❌'} {c['name']}" + (f" — {c['detail']}" if c["detail"] else "") for c in s["checks"]] + if "assistant" in s: + a = s["assistant"] + out += ["", f"**Ask the site:** *{a['question']}*", "", "> " + a.get("answer", "").replace("\n", "\n> "), "", + "" + ("offline template answer (no model key)" if a.get("fallback") else "model answer") + + (f" · sources: {', '.join(a['sources'])}" if a.get("sources") else "") + + (f" · suggested: {', '.join(x['label'] + ' → ' + x['path'] for x in a.get('suggested_actions', []))}" if a.get("suggested_actions") else "") + ""] + out.append("") + im = m["impact"] + out += ["## The day so far", "", f"{im['sessions']} sessions · {im['kwh']} kWh metered · **{im['saved_kgco2']} kg CO₂** and ${im['saved_usd']} saved vs charging at plug-in (estimate) · " + f"peak {im['peak_kw']} kW vs {im['baseline_peak_kw']} kW charge-now · renewable-hour share {100 * im['renewable_share']:.0f} %", "", + f"**Safety, whole run:** over {m['meter_frames']} meter frames the EV load exceeded feed − building by at most {m['worst_overshoot_kw']} kW " + f"({'never' if m['worst_overshoot_kw'] <= 0.05 else 'see above'}).", ""] + return "\n".join(out) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_assist.py b/tests/test_assist.py index 22c4823..fa245c3 100644 --- a/tests/test_assist.py +++ b/tests/test_assist.py @@ -47,6 +47,16 @@ def test_snapshot_has_the_keys_and_fits_the_cap(snap): "idle_min", "cap_observed"} <= set(row) +def test_a_bay_named_in_the_question_always_makes_the_snapshot(snap): + """56 cars plugged, 15 rows: the operator asks about a bay that did not make the relevance cut.""" + shown = {r["connector"] for r in snap["connectors"]} + plugged = [c.id for c in S["sim"].active() if c.session] + missing = next((cid for cid in plugged if cid not in shown), None) + assert missing, "fixture should have more plugged bays than rows" + assert missing in {r["connector"] for r in assist.snapshot(pin={missing})["connectors"]} + assert f"Bay {missing}" in assist.fallback(f"why is bay {missing} slow?", assist.snapshot(pin={missing}))["answer"] + + def test_snapshot_is_json_and_sorted_rows_lead_with_the_relevant(snap): json.dumps(snap, sort_keys=True) # what goes into the user turn urgent = [r for r in snap["connectors"] if r["urgency"] or r["boost"]] @@ -77,6 +87,7 @@ def test_every_starter_question_gets_a_grounded_fallback(snap): ("the backend dies", "safe share"), ("can a bug overcharge the circuit", "hard constraint"), ("why would a bay sit at 1.4 kW?", "6 A floor"), + ("what happens when a driver leaves before the time they gave?", "first-hour floor"), ]) def test_hard_questions_route_to_the_right_template(snap, q, needle): out = assist.fallback(q, snap) diff --git a/tests/test_day.py b/tests/test_day.py index ab727e8..fc85433 100644 --- a/tests/test_day.py +++ b/tests/test_day.py @@ -7,9 +7,10 @@ from pathlib import Path import pytest +from fastapi import HTTPException from noonshift import scheduler -from noonshift.api import (S, aligned, demo_boost, demo_early_unplug, demo_oversubscribe, demo_signal_outage, site_impact, +from noonshift.api import (S, aligned, demo_boost, demo_early_unplug, demo_oversubscribe, demo_signal_outage, meter_history, site_impact, unplug, step, urgency) from noonshift.models import OutageIn, UrgencyIn from noonshift.sim import DYN_VALID_MIN, Sim, load_sessions, safe_share_kw @@ -343,3 +344,52 @@ def test_metered_peak_never_reports_above_the_feed(day): happened. The instantaneous sum is checked above; the reported KPI must agree with it.""" assert any(h["start"].minute % 5 for h in day["hist"].values()), "the seed has mid-slot plug-ins, or this test proves nothing" assert day["site"].peak_kw <= S["site"]["feed_kw"] + 1e-6, day["site"].peak_kw + + +def test_driver_unplug_ends_the_session_with_an_honest_receipt(mid_morning): + s = next(c.session for c in S["sim"].active() if c.session["status"] == "charging") + before = s["kwh_delivered"] + out = asyncio.run(unplug(s["id"])) + assert out.status == "ended" and out.kw_now == 0.0 and out.kwh_delivered == pytest.approx(before, abs=1e-3) + assert s["connector_id"] not in S["plan"] or not S["sim"].connectors[s["connector_id"]].session + with pytest.raises(HTTPException): + asyncio.run(unplug(s["id"])) # already ended + + +def test_a_burst_of_plug_ins_never_draws_above_headroom_before_the_plan_catches_up(mid_morning): + """The live server: POST /sessions and /demo/oversubscribe plug cars in, then await (event broadcast, a threaded + solve) before the new plan lands; the control loop can tick in that gap. Newcomers used to hold the 1.833 kW + static share each on top of a plan that already filled the feed (20 of them = +37 kW for a minute, seen in + scripts/scenarios.py). Now a newcomer's provisional limit is its share out of headroom nobody holds yet.""" + sim = S["sim"] + for c in [c for c in sim.active() if c.session["status"] == "charging"][:16]: # 16 x 7 kW: a plan that fills the 110 kW headroom + asyncio.run(urgency(c.session["id"], UrgencyIn(level="now"))) + held = sum(c.limit_kw for c in sim.active()) + ids = list(range(9000, 9020)) + for i in ids: # the second wave, as the handler does it, with no resolve yet + sim.arrive({"id": i, "connector_id": None, "arrival": sim.now, "departure": sim.now + timedelta(hours=2), + "user_stated_departure": sim.now + timedelta(hours=2), "kwh_needed": 9.0, "kwh_delivered": 0.0, + "boost": False, "status": "pending", "ended_at": None}) + new = [c for c in sim.active() if c.session["id"] in ids] + assert len(new) >= 5, "the wave should have found some free bays" + assert held + len(new) * new[0].safe_kw > headroom_now(), "precondition: the old behaviour would have overshot here" + assert sum(c.limit_kw for c in sim.active()) <= headroom_now() + 1e-6 + sim.tick(1) # the control loop's tick in the gap + assert sum(c.kw for c in sim.connectors.values()) <= headroom_now() + 1e-6 + + +def test_meter_history_anchors_start_to_the_first_minute_it_records(mid_morning): + """Live server: resolve() captures `now`, then awaits a threaded solve; a tick can run in between, so the hist + entry's `start` can be a minute earlier than the first draw it records. Every sample then sits one minute early + and a 0 -> 7 kW ramp at a slot boundary leaks into the previous slot's 5-min average (the scenario recording + showed a 151.8 kW "peak" on a 150 kW feed while no minute ever exceeded headroom).""" + sim = S["sim"] + t0 = sim.now + c = next(c for c in sim.connectors.values() if not c.session) + sim.arrive({"id": 9100, "connector_id": c.id, "arrival": t0, "departure": t0 + timedelta(hours=4), "user_stated_departure": t0 + timedelta(hours=4), + "kwh_needed": 9.0, "kwh_delivered": 0.0, "boost": False, "status": "pending", "ended_at": None}) + sim.tick(1) # the tick that ran while resolve() was awaiting: draw unrecorded + S["hist"][9100] = {"start": t0, "baseline": [0.0] * 288, "kw_min": []} # resolve() lands with its stale `now` + sim.tick(1) # first recorded draw is [t0+1, t0+2) + meter_history() + assert S["hist"][9100]["kw_min"] and S["hist"][9100]["start"] == t0 + timedelta(minutes=1)