Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions .docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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)
Expand All @@ -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.

---

Expand All @@ -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.

---

Expand All @@ -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 = <site_state> + 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.
14 changes: 8 additions & 6 deletions .docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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`) |

---

Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
Loading