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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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 <sessions.json|csv>` — 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/<date>.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"]`.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ 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.
- `.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
- 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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading