From d1e6e405de948b1484062ccde3eac2dd6c795a2e Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 07:19:47 +0530 Subject: [PATCH] docs(WP7): README API block + assistant, ARCHITECTURE section 7 contracts and section 9 assistant, CHANGELOG, ROADMAP status README: every endpoint listed (price, urgency, impact.csv, grid/signal, assist), OPS_TOKEN note, one paragraph on the assistant under Demo. ARCHITECTURE: section 7 lists the additive REST/WS fields since WP1; section 4/5 no longer describe the pre-WP2 world (full rung = safe share, two OCPP profiles with expiry); new section 9 with a validated Mermaid sequence (mermaid@11 + jsdom, 7/7 blocks OK). CHANGELOG Unreleased: WP1-WP6 + the peak-reporting fix. ROADMAP section 0 flipped to Exists where done. AGENTS.md row. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- .docs/ARCHITECTURE.md | 47 ++++++++++++++++++++++++++++++++++--------- .docs/ROADMAP.md | 14 +++++++------ AGENTS.md | 1 + CHANGELOG.md | 11 ++++++++++ README.md | 20 +++++++++++++----- 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/.docs/ARCHITECTURE.md b/.docs/ARCHITECTURE.md index 0fc0844..8b60d86 100644 --- a/.docs/ARCHITECTURE.md +++ b/.docs/ARCHITECTURE.md @@ -146,12 +146,12 @@ stateDiagram-v2 note right of cached : same, last forecast ≤ 6 h old note right of tariff : tariff + deadlines, no carbon note right of deadline : earliest-deadline-first only - note right of full : every connector at p_max (no solve) + note right of full : every connector holds its static safe share (no solve) ``` `pick_mode()` in `api.py` walks the rungs top-down and returns the first healthy one; `resolve()` shapes the LP inputs to match (tariff-only ⇒ empty `moer`; deadline-only ⇒ also empty `price_per_kwh`; full ⇒ no solve). The demo button `POST /demo/signal_outage` flips the rungs. -**Known gap:** the `full` rung sets every connector to `p_max` at once. With 40 deferred cars that is a 280 kW spike on a 100 kW block. The fix — a static "safe share" profile that lives on the charger underneath the dynamic one, so chargers revert to it on their own when the backend disappears — is specified in [`solutions.md`](solutions.md) §5 and is the next scheduler-side change. +**The last rung is inspector-grade.** `full` no longer means "every connector at `p_max`" (40 deferred cars would have been a 280 kW spike on a 100 kW block). It is the static **safe share** `min(p_max, (feed − max building load) / n)` — 1.833 kW per bay on the demo site — which is also the OCPP stack-0 profile that lives on each charger underneath the dynamic plan (§5), so chargers revert to it on their own when the backend disappears. `sim.safe_share_kw()`, `tests/test_day.py::test_when_the_control_loop_dies_the_site_falls_back_to_the_static_share`, [`solutions.md`](solutions.md) §5. --- @@ -164,11 +164,12 @@ sequenceDiagram participant API as api.py CP->>GW: BootNotification GW-->>CP: Accepted + GW->>CP: SetChargingProfile (stack 0, static safe share, no expiry) CP->>GW: StartTransaction (idTag, meterStart) GW->>API: session on connector - loop every 5 min / on event + loop every 5 min / on event, re-sent at least every 10 min API->>GW: set_limit(kW) - GW->>CP: SetChargingProfile (TxDefaultProfile, stack 0, limit kW) + GW->>CP: SetChargingProfile (stack 1, plan kW, validTo = now + 15 min) CP-->>GW: Accepted end CP->>GW: MeterValues (kW, kWh) @@ -177,9 +178,9 @@ sequenceDiagram GW->>API: unplug → receipt ``` -`OcppConnector` replaces the simulator's `Connector` when `OCPP=1`; `set_limit` only takes effect once the charge point accepts the profile (`python -m noonshift.ocpp_gateway` runs the round-trip self-check against a fake charge point). Hardware max current is configured on the charger itself; the profile can only lower it. +`OcppConnector` replaces the simulator's `Connector` when `OCPP=1`; `set_limit` only takes effect once the charge point accepts the profile (`python -m noonshift.ocpp_gateway` runs the round-trip self-check against a fake charge point: static after boot, dynamic with `validTo`, revert to the safe share on expiry). Two profiles per connector: stack 0 is the static safe share with no expiry; stack 1 is the plan limit, valid 15 minutes and refreshed every 10, so a silent backend means the charger falls back to the share by itself. Hardware max current is configured on the charger itself; a profile can only lower it. Never `await self.call()` inside an `@after` handler — it deadlocks the message loop; schedule it as a task. -Not yet done: authentication on the websocket (OCPP security profile 1), two-profile stacking with expiry (§4 gap), a real charger. See [`SECURITY.md`](../SECURITY.md) and [`solutions.md`](solutions.md) §5, §13. +Not yet done: authentication on the websocket (OCPP security profile 1), a real charger. See [`SECURITY.md`](../SECURITY.md) and [`solutions.md`](solutions.md) §5, §13. --- @@ -203,9 +204,11 @@ Rules baked into the scripts: signal and sessions come from the same grid; `kwh_ ## 7. Contracts -- REST bodies and WS frames: `noonshift/models.py` → `docs/openapi.json`, `docs/ws-frames.json` (regenerated on start; CI fails if a diff is uncommitted). -- Scheduler: `solve(cars, site, signal, tariff, now) → {connector_id: [kW × 288]}`, `impact(...)`, `price(...)` — signatures frozen; extras are keyword-only. -- WS frames: `PlanMsg` (per-connector kW plan, site kW, mode, reason), `MeterMsg` (per-connector live kW, kWh, status), `EventMsg` (plug_in, unplug, done, boost, day_reset, …). +- REST bodies and WS frames: `noonshift/models.py` → `docs/openapi.json`, `docs/ws-frames.json` (regenerated on start; CI fails if a diff is uncommitted). Every change since the first release has been **additive**: new optional fields, new endpoints, never a renamed or removed field. +- Scheduler: `solve(cars, site, signal, tariff, now) → {connector_id: [kW × 288]}`, `impact(plan, baseline, signal, tariff, *, health=False)`, `price(slack_hours, *, r, saving_usd, kwh, alpha, urgent)` — positional signatures frozen; extras are keyword-only. Per-car optional keys: `floor_alpha`, `priority`, `boost`. +- REST (additive since WP1): `POST /sessions` accepts `vehicle{model, battery_kwh, max_kw}`, `soc_now`, `target_soc`; `POST /sessions/{id}/urgency {level, leave_at?}` (`/boost` = `now`); `SessionOut` carries `urgency`, `kwh_needed`, `need_confidence`, `max_kw`; `StatusOut` carries `safe_share_kw`, `waiting`, `connectors_asap`, `contracted_peak_kw`, `package`, `employee_rate_usd_per_kwh`, `driver_share`, `noonshift_share`, `signal_kind/source`, `tariff_name`, `ladder`; `ImpactOut` carries `renewable_share`, `health_usd`; `GET /sites/{id}/impact.csv` is the hourly ledger; `POST /assist` / `GET /assist/suggestions` (§9). +- WS frames: `PlanMsg` (per-connector kW plan, site kW, mode, reason), `MeterMsg` (per-connector live kW, kWh, status, `urgency`, `idle_min`, `need_confidence`, `p_max_kw`, `cap_observed`, `asap`, `move_by`; site `waiting`), `EventMsg` (plug_in, unplug, done, boost, urgency, cap_observed, move_by, day_reset, …). +- Auth: `OPS_TOKEN` bearer on `/assist*` and `/demo/*` when set; driver and read endpoints are open. --- @@ -219,3 +222,29 @@ Rules baked into the scripts: signal and sessions come from the same grid; `kwh_ | `tests/test_day.py` | a full simulated day through `api.step()` plus the four demo scenarios, on a frozen fixture day | | `scripts/smoke.py` | driver REST → `/ws` → ops, against a live server (CI) | | `scripts/prove.py` | the headline number, gated | +| `tests/test_assist.py` | the assistant: snapshot cap and keys, every starter answered offline, the parser, the per-IP limit, cache reuse (skipped without a key) | + +--- + +## 9. Operator assistant + +A facilities manager types a question in the ops dashboard; the answer is grounded in **today's live state** and a **static knowledge file**, and it never acts — each answer ends with up to two *label → page* buttons the operator clicks. + +```mermaid +sequenceDiagram + participant UI as AssistDrawer + participant API as POST /assist + participant Snap as assist.snapshot() + participant M as claude-opus-5 + UI->>API: question, last 6 turns, page + API->>Snap: build from S (≤ 15 connector rows, events, signal, tariff) + API->>M: system = [rules, knowledge] (cached) · user = + question + M-->>API: answer · label -> /ops/page · [sources: …] + API-->>UI: {answer, sources, suggested_actions} — or the template fallback when there is no key +``` + +- **Snapshot** (`noonshift/assist.py: snapshot()`): built from the same REST builders the dashboard uses, so the numbers agree; the 15 most relevant connectors (at risk → urgent → charging → done → by slack) plus counts; capped at ~6 k tokens. +- **Knowledge** (`noonshift/assist_knowledge.md`): hand-condensed from this file, the README, business §0b/§4b/§7b, metrics §2/§3/§6 and the hard questions. Nothing time-varying lives in it, so the two system blocks stay prompt-cache hits. +- **Model call**: `claude-opus-5`, adaptive thinking, low effort, 1024 output tokens. Errors most-specific first: bad or missing key → fallback; rate limit → 429 with `Retry-After`; connection or 5xx → fallback flagged `degraded`. +- **Fallback** (`fallback()`): keyword-routed templates filled from the snapshot (a bay by id, at-risk sessions, the ladder walk, safe share, CO₂ → km, urgency bands, queue and move-by, money per §7b, load vs block/feed, the clean window). The demo needs neither a key nor the internet; the UI labels these "offline answer". +- **Limits**: 10 questions per minute per client IP; `OPS_TOKEN` bearer when set. diff --git a/.docs/ROADMAP.md b/.docs/ROADMAP.md index cfc1bd9..06316cc 100644 --- a/.docs/ROADMAP.md +++ b/.docs/ROADMAP.md @@ -10,9 +10,9 @@ This file says what exists today, what is missing, and in what order to build it | Piece | Status | What it does | |---|---|---| -| Brain (`noonshift/scheduler.py`) | Exists, 53 tests | Decides, every 5 minutes, how much power each plugged-in car gets so every car is full by its ready-by time, the site never exceeds its feed, and charging lands in the cleanest / cheapest slots. | -| Engine (`noonshift/api.py`, `sim.py`) | Exists | Simulated day of 60 chargers, live plan/meter/event feed on `/ws`, four demo buttons, fail-safe ladder (live signal → cached → tariff → deadline → full power). | -| Ops dashboard (`web/`) | Exists, live | Gantt of every car, sessions, alerts, site load vs limit, impact, fail-safe banner. | +| Brain (`noonshift/scheduler.py`) | Exists, 92 tests across the suite | Decides, every 5 minutes, how much power each plugged-in car gets so every car is full by its ready-by time, the site never exceeds its feed, and charging lands in the cleanest / cheapest slots. | +| Engine (`noonshift/api.py`, `sim.py`) | Exists | Simulated day of 60 chargers, live plan/meter/event feed on `/ws`, four demo buttons, fail-safe ladder (live signal → cached → tariff → deadline → static safe share), urgency bands, queue with move-by, observation guard, hourly ledger. | +| Ops dashboard (`web/`) | Exists, live | Facilities-manager console: load vs contracted peak, Gantt with urgent/ASAP/move-by marks, urgency control, CO₂-first impact with EPA equivalents and ledger export, fail-safe banner in plain words, operator assistant drawer. | | Driver app (`wattwise/`) | Exists, live | "Ready by when?" → plan window → live kW → receipt. Price tier shown next to the choice. | | End-to-end check (`scripts/smoke.py`) | Exists | Driver actions → backend → what the ops screen sees. Prints `SMOKE OK`. | | Pitch (`pitch/`, `business.md`, `metrics.md`) | Exists | Deck outline, 4-minute demo script, hard questions, business model. | @@ -22,12 +22,14 @@ What the two apps exchange **today**: | Direction | Data | Status | |---|---|---| | Driver → Noonshift | connector (bay), ready-by time, kWh wanted | Exists (`POST /sessions`) | -| Driver → Noonshift | Boost ("charge now") | Exists (`POST /sessions/{id}/boost`) | -| Driver → Noonshift | a *later or earlier* ready-by time (same bay = update) | Partly (endpoint accepts it; no rules around it, no UI) | +| Driver → Noonshift | Boost ("charge now") | Exists (`POST /sessions/{id}/boost` = urgency `now`) | +| Driver → Noonshift | "Leaving now" / "Leaving soon" + time / "Prioritise" — three bands, one price | Exists (`POST /sessions/{id}/urgency`); ops UI exists, driver UI pending | +| Driver → Noonshift | car model / battery / max kW, SoC now, target SoC | Exists (`POST /sessions` `vehicle`, `soc_now`, `target_soc`); driver UI pending | | Noonshift → Driver | plan window, ETA, price tier, live kW, kWh delivered, "grid cleaner than X % of today", $ and kg saved (estimate) | Exists | | Noonshift → Driver | today's hourly grid carbon + tariff (for the "why this hour" graph) | Exists (`GET /grid/signal`) | | Noonshift → Ops | every plan, every meter tick, every event; site status, impact incl. peak vs charge-now peak | Exists | -| Ops → Noonshift | demo buttons; boost a connector | Exists | +| Ops → Noonshift | demo buttons; urgency on a driver's behalf | Exists | +| Ops ↔ Noonshift | questions about today's site, answered from live state + docs | Exists (`POST /assist`) | --- diff --git a/AGENTS.md b/AGENTS.md index f319d23..1fb496a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,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 | 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 2026-09-13 | WP4 evidence scripts | scripts/prove.py, fetch_data.py, fit.py, replay_days.py, scheduler.impact(health=) | timer baseline is the judge's number (−56 % CO2 over a dumb timer); constrained site shows the carbon-vs-tariff conflict honestly; forecast flag wired, unverified without creds diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb9e5c..5e449d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,24 @@ All notable changes to this project. Format follows [Keep a Changelog](https://k ## [Unreleased] ### Added +- **WP1 — pricing + emergency.** `scheduler.price()` is the shared-savings formula (business §7b): `R − α·S/E`, never above `R`, `R` from `site.json` (0 = free). `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` — three bands, one price (§4b); `/boost` is an alias of `now`. First-hour floor `min(need, 3.5 kWh)` and 30-min progress floors anchored to arrival; per-car `floor_alpha` / `priority`. +- **WP2 — inspector-grade fail-safe.** The `full` rung and the OCPP stack-0 profile are the static safe share `min(p_max, (feed − max building)/n)` = 1.833 kW on the demo site; the dynamic stack-1 profile expires after 15 min. Nothing the backend can do exceeds the feed. +- **WP3 — cars, needs, queues.** Vehicle form (`vehicle`, `soc_now`, `target_soc`), need estimate from bay history → site median with `need_confidence`, observation guard (`cap_observed`), always-ASAP fleet bays (`connectors_asap`), `done` events, a waiting queue with move-by times. +- **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. - 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. - `.docs/metrics.md` — every reported metric with its formula; money metrics derived from the shared-savings formula. - `.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 +- `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 +- README API block lists every endpoint; ARCHITECTURE §7 contracts and new §9 (operator assistant). - README rewritten for an open-source audience; OCPP badge corrected to 1.6J (the gateway implements 1.6J only). - All human-written documentation moved from the repo root to `.docs/` (`README.md` and `AGENTS.md` stay at root). - `ROADMAP.md`: emergency pricing reconciled with the team decision — every emergency band is priced at today's rate, no free quota. diff --git a/README.md b/README.md index bcae5a4..298f952 100644 --- a/README.md +++ b/README.md @@ -126,22 +126,32 @@ 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). +**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 Interactive docs at `http://localhost:8000/docs`. Contract files are committed and CI-checked: [`docs/openapi.json`](docs/openapi.json), [`docs/ws-frames.json`](docs/ws-frames.json). ``` -POST /sessions {connector_id, departure_at, kwh_needed?} -POST /sessions/{id}/boost charge now at today's rate +GET /price what a ready-by would cost before plugging in (shared-savings estimate) +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" 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 vs block, ladder mode -GET /sites/{id}/impact kWh, $, kg CO₂ vs charge-immediately +GET /sites/{id}/status site kW vs feed / block / contracted peak, ladder mode, safe share, waiting, package, R +GET /sites/{id}/impact kWh, $, kg CO₂ vs charge-immediately, renewable-hour share, health $ +GET /sites/{id}/impact.csv hourly ledger behind every carbon claim (day, hour, kWh, gCO₂/kWh, kgCO₂, signal) +GET /grid/signal today's hourly carbon + tariff GET /grid/flex-forecast shiftable load per hour (for the grid operator) POST /openadr/events demand-response event → less headroom in those slots -WS /ws plan / meter / event frames +POST /assist operator assistant: {question, history?, page?} → {answer, sources, suggested_actions} +GET /assist/suggestions six starter questions built from the current state +WS /ws plan / meter / event frames (additive only) ``` +Operator endpoints (`/assist*`, `/demo/*`) accept a bearer token when `OPS_TOKEN` is set; unset = open (demo). + ## Repository layout ```