diff --git a/AGENTS.md b/AGENTS.md index 50a1464..8fcb9d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # AGENTS.md — Project Memory (auto-maintained) -Last updated: 2026-09-13 | Sessions logged: 6 +Last updated: 2026-09-13 | Sessions logged: 7 ## Identity Hackathon entry: "Noonshift" — a CPO-side, deadline-based EV charging scheduler that shifts flexible charging into low-marginal-carbon hours at daytime long-dwell sites (workplace/destination/depot). Four-person team, lanes in `team-plan.md`. @@ -25,10 +25,11 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead ## File Map - `noonshift/scheduler.py` — `solve(cars, site, signal, tariff, now)`, `solve_lp()` (+info), `impact()`, `impact_detail()`, `price(slack, *, r, saving_usd, kwh, alpha, urgent)` (§7b: R − α·S/E, ≤ R), `tier_of()`; per-car optional `floor_alpha`, `priority`; constants W_CARBON, ALPHA, FLOOR_EVERY, FIRST_HOUR_KWH (3.5), SPRINT_SLOTS, BUFFER_COST, M_SHORT, M_FLOOR, ASAP_EPS, E_MIN, MIN_KW. Slot 0 carries only its remaining minutes (`dur[0]`); floor checkpoints are times since arrival, pro rata in the slot containing the mark. -- `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`; `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` (`/boost` = alias of `now`; `URGENCY_MIN` 15 min; `PRIORITY` = floor 0.9 / weight 2.0); `site_rate()` = `site.json: employee_rate_usd_per_kwh` (0 = free); `session_price()`; `car_kw(c)` = min(bay, `session.max_kw`); `need_estimate()` (bay history median → site median); `observe_caps()` (5 min under 80 % of limit → `max_kw` = metered × 1.05, event `cap_observed`); `move_by()` (waiting cars → slackest plugged cars get deadline = now + e_rem/p_max, one per waiting car); `site.json: connectors_asap` bays are always `boost`. +- `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`; `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` (`/boost` = alias of `now`; `URGENCY_MIN` 15 min; `PRIORITY` = floor 0.9 / weight 2.0); `site_rate()` = `site.json: employee_rate_usd_per_kwh` (0 = free); `session_price()`; `car_kw(c)` = min(bay, `session.max_kw`); `need_estimate()` (bay history median → site median); `observe_caps()` (5 min under 80 % of limit → `max_kw` = metered × 1.05, event `cap_observed`); `move_by()` (waiting cars → slackest plugged cars get deadline = now + e_rem/p_max, one per waiting car); `site.json: connectors_asap` bays are always `boost`; `hourly_ledger()` → `GET /sites/{id}/impact.csv` (24 rows: day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source); `StatusOut` carries the FM's site card (`contracted_peak_kw`, `package`, `employee_rate_usd_per_kwh`, `driver_share`, `noonshift_share`, `signal_kind/source`, `tariff_name`, `ladder`). - `noonshift/sim.py` — `Sim`, `Connector(id, p_max_kw, safe_kw)` (taper from 80% SoC; plug-in starts on `safe_kw`; a `set_limit` expires after `DYN_VALID_MIN`=15 min back to `safe_kw`), `load_sessions()`, `safe_share_kw(site)` = min(p_max, (feed − max building)/n) = 1.833 kW on site-1. `Sim.waiting` (arrivals with no bay; plugged FIFO as bays free); a `done` car with `idle_since` ≥ `MOVE_AFTER_MIN`=10 is unplugged when someone waits (A5); `session.car_kw` = the car's physical limit (sim only; `max_kw` is the planner's belief). - `noonshift/models.py` — pydantic REST bodies + WS frames (contract with front-end). `SessionIn` optional `vehicle{model,battery_kwh,max_kw}`, `soc_now`, `target_soc`; `SessionOut` +`kwh_needed`, `need_confidence`, `max_kw`, `urgency`; `ConnectorMeter` +`idle_min`, `need_confidence`, `p_max_kw`, `cap_observed`, `asap`, `move_by`, `urgency`; `MeterMsg.waiting`; `StatusOut` +`safe_share_kw`, `waiting`, `connectors_asap`; `EventMsg.name` +`urgency|done|cap_observed|move_by`. All additive. -- `web/src/context/GlobalStateContext.jsx` — ops app state: opens `/ws`, polls `/sites/site-1/impact|status`, derives the page shape (siteDetail/connectors Gantt on a 06–22 axis, sessions, alerts from events, chargersList); `triggerEvent()` maps demo buttons → `/demo/*`, prioritize → `/sessions/{id}/boost`. +- `web/src/context/GlobalStateContext.jsx` — ops app state: opens `/ws`, polls `/sites/site-1/impact|status` + `/grid/signal`, accumulates hourly kWh from meter frames; `data.status` (mode, modeCopy, ladder, safeShareKw, waiting, connectorsAsap, contractedPeakKw, package, rateR, α/β, signalKind/Source, tariffName), `portfolio`, `siteDetail`, `chargersList`, `alerts`, `ledgerUrl`; actions `setUrgency(sessionId, level, leaveAt)` → `/sessions/{id}/urgency`, `triggerEvent()` → `/demo/*`. `web/src/lib/ui.js` — `hhmm`, `MODE_COPY` (rung copy). `web/src/components/UrgencyControl.jsx` — Leaving now / soon (time input) / Prioritise with inline confirm. +- `web/src/pages/` — `DashboardOverview` (load vs contracted peak, CO2 first, renewable ring), `OpsDashboard` (Gantt marks urgent/asap/move-by + side panel), `SessionsView` (urgency/need/idle, High-risk highlight), `ChargersView` (plan cap + "meter" tag, static share, ASAP filter), `SchedulesView` (planned vs metered kW/h with MOER shading), `ImpactView` (KPIs, hourly bars by MOER bucket, EPA equivalents, Export ledger CSV), `TariffsView` (§7b split, package, TOU from signal), `AlertsView`. No pause/reoptimize buttons (never pause a car; the planner re-solves itself). - `.docs/ROADMAP.md` — plain-language plan (§0 status, §1 driver inputs, §3 emergency rules, §4 price flip, §5 evidence runs, §7 CI, §8 order). `.github/workflows/ci.yml` — backend job + web/wattwise matrix. - `wattwise/src/api/noonshift.ts` — driver app client + hand-copied types; `connectWs()`, `onSimDay()` (ready-by is built on the **sim** day and must be > sim now). - `wattwise/src/context/WattwiseContext.tsx` — driver state: `POST /sessions` on the highest free connector (replayed sessions use c01–c36), `/live` polled every 2 s for the receipt, meter/plan frames for kW/kWh/window; unplug/day_reset moves the session to History. @@ -81,6 +82,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 | 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 2026-09-13 | WP3 cars, needs, queues | models.py, sim.py, api.py, data/site.json, tests/test_day.py, scripts/smoke.py | vehicle form → per-car cap + kWh from SoC; observation guard believes the meter; connectors_asap c41/c42; done event + idle_min; Sim.waiting + move-by tightening; a notified done car moves within 10 min when someone waits 2026-09-13 | WP2 fail-safe an inspector accepts: static share under everything | sim.py, api.py, scheduler.py, ocpp_gateway.py, models.py, data/site.json, tests/test_day.py | `full` rung, `apply_limits` fallback and the linprog fallback all return `safe_share_kw`; kill-the-loop test: 6 h with no step() never exceeds headroom and after 15 min never exceeds n × share; `StatusOut.safe_share_kw` diff --git a/data/site.json b/data/site.json index 07429ec..0ab2c0b 100644 --- a/data/site.json +++ b/data/site.json @@ -1 +1 @@ -{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833, "connectors_asap": ["c41", "c42"]} \ No newline at end of file +{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833, "connectors_asap": ["c41", "c42"], "package": "pilot", "contracted_peak_kw": 150} \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index 627c939..58dc647 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -301,6 +301,80 @@ } } }, + "/sites/{site_id}/impact.csv": { + "get": { + "summary": "Site Impact Csv", + "description": "The audit-ready ledger (solutions.md \u00a712): LCFS takes the kWh column today; an hourly-EAC registry takes the rest.", + "operationId": "site_impact_csv_sites__site_id__impact_csv_get", + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + }, + { + "name": "from", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "From" + } + }, + { + "name": "to", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "To" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/sites/{site_id}/status": { "get": { "summary": "Site Status", @@ -717,6 +791,22 @@ "title": "Baseline Peak Kw", "default": 0.0 }, + "renewable_share": { + "type": "number", + "title": "Renewable Share", + "default": 0.0 + }, + "health_usd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Health Usd" + }, "note": { "type": "string", "title": "Note", @@ -1264,6 +1354,91 @@ "type": "array", "title": "Connectors Asap", "default": [] + }, + "n_connectors": { + "type": "integer", + "title": "N Connectors", + "default": 0 + }, + "p_max_kw": { + "type": "number", + "title": "P Max Kw", + "default": 0.0 + }, + "contracted_peak_kw": { + "type": "number", + "title": "Contracted Peak Kw", + "default": 0.0 + }, + "package": { + "type": "string", + "enum": [ + "capacity", + "clean-hours", + "pilot" + ], + "title": "Package", + "default": "pilot" + }, + "employee_rate_usd_per_kwh": { + "type": "number", + "title": "Employee Rate Usd Per Kwh", + "default": 0.0 + }, + "driver_share": { + "type": "number", + "title": "Driver Share", + "default": 0.5 + }, + "noonshift_share": { + "type": "number", + "title": "Noonshift Share", + "default": 0.2 + }, + "signal_kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "marginal", + "average" + ] + }, + { + "type": "null" + } + ], + "title": "Signal Kind" + }, + "signal_source": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Signal Source" + }, + "tariff_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Tariff Name" + }, + "ladder": { + "additionalProperties": { + "type": "boolean" + }, + "type": "object", + "title": "Ladder", + "default": {} } }, "type": "object", diff --git a/noonshift/api.py b/noonshift/api.py index 6877b6e..c9303c0 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -12,6 +12,7 @@ from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import PlainTextResponse from pydantic import TypeAdapter from . import db, scheduler, seed @@ -168,7 +169,8 @@ def session_impact(h, remaining=()): series = metered + list(remaining)[:288 - full] signal = {"moer": aligned(S["signal"]["moer"], h["start"]), "kind": S["signal"]["kind"]} tariff = dict(S["tariff"], price_per_kwh=aligned(S["tariff"]["price_per_kwh"], h["start"])) - return scheduler.impact({"s": series}, {"s": h["baseline"]}, signal, tariff) + signal["health_damage"] = aligned(S["signal"]["health_damage"], h["start"]) if S["signal"].get("health_damage") else None + return scheduler.impact({"s": series}, {"s": h["baseline"]}, signal, tariff, health=bool(S["signal"].get("health_damage"))) def meter_history(): @@ -448,10 +450,51 @@ def site_impact(site_id: str, from_: datetime | None = Query(None, alias="from") ss = [s for s in sim.sessions.values() if lo <= s["arrival"] < hi and s["status"] != "pending"] imps = [S["impact"].get(s["id"], {"saved_usd": 0.0, "saved_kgco2": 0.0}) for s in ss] actual, base = site_peaks() + ledger = hourly_ledger(lo, hi) + kwh = sum(r["kwh"] for r in ledger) return ImpactOut(from_=lo, to=hi, sessions=len(ss), kwh=round(sum(s["kwh_delivered"] for s in ss), 2), saved_usd=round(sum(i["saved_usd"] for i in imps), 2), saved_kgco2=round(sum(i["saved_kgco2"] for i in imps), 2), - peak_kw=actual, baseline_peak_kw=base) + peak_kw=actual, baseline_peak_kw=base, + renewable_share=round(sum(r["kwh"] for r in ledger if r["gco2_per_kwh"] == 0) / kwh, 4) if kwh else 0.0, + health_usd=None if S["signal"].get("health_damage") is None else round(sum( + S["impact"].get(s["id"], {}).get("health_usd") or 0.0 for s in ss), 2)) + + +def hourly_ledger(lo, hi): + """solutions.md §12: the hourly record behind every carbon claim, from metered per-minute kW and the day's signal. + Columns: hour, kWh, gCO2/kWh (hourly mean of the signal), kgCO2 (per-slot, exact), signal kind and source.""" + moer, kind, src = S["signal"]["moer"], S["signal"]["kind"], S["signal"].get("source", "") + kwh = [0.0] * 288 + for h in S.get("hist", {}).values(): + if not (lo <= h["start"] < hi): + continue + k0 = slot_of(h["start"]) + for i, kw in enumerate(h["kw_min"]): + k = k0 + i // 5 + if k < 288: + kwh[k] += kw / 60 + out = [] + for hour in range(24): + e = sum(kwh[hour * 12:(hour + 1) * 12]) + kg = sum(kwh[k] * moer[k] / 1000 for k in range(hour * 12, (hour + 1) * 12)) + out.append({"hour": hour, "kwh": round(e, 3), "gco2_per_kwh": round(sum(moer[hour * 12:(hour + 1) * 12]) / 12, 1), + "kgco2": round(kg, 4), "signal_kind": kind, "signal_source": src}) + return out + + +@app.get("/sites/{site_id}/impact.csv", response_class=PlainTextResponse) +def site_impact_csv(site_id: str, from_: datetime | None = Query(None, alias="from"), to: datetime | None = None): + """The audit-ready ledger (solutions.md §12): LCFS takes the kWh column today; an hourly-EAC registry takes the rest.""" + check_site(site_id) + sim = S["sim"] + lo, hi = from_ or sim.day_end - timedelta(days=1), to or sim.day_end + rows = ["day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source"] + day = lo.date().isoformat() + for r in hourly_ledger(lo, hi): + rows.append(f"{day},{r['hour']:02d},{r['kwh']:.3f},{r['gco2_per_kwh']},{r['kgco2']:.4f},{r['signal_kind']},{r['signal_source']}") + return PlainTextResponse("\n".join(rows) + "\n", media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="noonshift-{site_id}-{day}.csv"'}) def site_peaks(): @@ -476,11 +519,16 @@ def site_peaks(): @app.get("/sites/{site_id}/status", response_model=StatusOut) def site_status(site_id: str): check_site(site_id) + site = S["site"] return StatusOut(mode=S["mode"], last_solve_at=S["last_solve_at"], connectors_active=sum(c.status == "charging" for c in S["sim"].active()), - feed_kw=S["site"]["feed_kw"], block_kw=S["site"]["block_kw"], sim_time=S["sim"].now, - safe_share_kw=safe_share_kw(S["site"]), waiting=len(S["sim"].waiting), - connectors_asap=S["site"].get("connectors_asap", [])) + feed_kw=site["feed_kw"], block_kw=site["block_kw"], sim_time=S["sim"].now, + safe_share_kw=safe_share_kw(site), waiting=len(S["sim"].waiting), + connectors_asap=site.get("connectors_asap", []), n_connectors=len(site["connectors"]), p_max_kw=site["p_max_kw"], + contracted_peak_kw=site.get("contracted_peak_kw") or site["feed_kw"], package=site.get("package", "pilot"), + employee_rate_usd_per_kwh=site_rate(), driver_share=site.get("driver_share", 0.5), + noonshift_share=site.get("noonshift_share", 0.2), signal_kind=S["signal"].get("kind"), + signal_source=S["signal"].get("source"), tariff_name=S["tariff"].get("name"), ladder=S["ladder"]) @app.get("/price", response_model=PricePreview) diff --git a/noonshift/models.py b/noonshift/models.py index 69a7ecb..1606d11 100644 --- a/noonshift/models.py +++ b/noonshift/models.py @@ -81,6 +81,8 @@ class ImpactOut(BaseModel): saved_kgco2: float peak_kw: float = 0.0 # highest metered site kW (EV + building) so far today baseline_peak_kw: float = 0.0 # same, had every car charged at full power from plug-in + renewable_share: float = 0.0 # share of delivered kWh in slots where the marginal source was renewable (MOER = 0) + health_usd: float | None = None # metrics.md §4: avoided health damage, when the signal carries an index note: str = "estimate vs charge-immediately baseline" @@ -94,6 +96,17 @@ class StatusOut(BaseModel): safe_share_kw: float = 0.0 # per-connector static share every charger reverts to when the controller is gone waiting: int = 0 # cars that arrived with no free bay connectors_asap: list[str] = [] # fleet bays that are never deferred + n_connectors: int = 0 + p_max_kw: float = 0.0 + contracted_peak_kw: float = 0.0 # the building's contracted demand; default = feed_kw (Assumption A2) + package: Literal["capacity", "clean-hours", "pilot"] = "pilot" # business.md §9b (Assumption A3) + employee_rate_usd_per_kwh: float = 0.0 # R + driver_share: float = 0.5 # alpha + noonshift_share: float = 0.2 # beta + signal_kind: Literal["marginal", "average"] | None = None + signal_source: str | None = None + tariff_name: str | None = None + ladder: dict[str, bool] = {} class PriceTier(BaseModel): diff --git a/scripts/smoke.py b/scripts/smoke.py index 1a1d39d..176548f 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -123,7 +123,18 @@ async def pump(): print(f"[ops] ladder dropped to {st['mode']} and restored to live") imp = http("GET", "/sites/site-1/impact") - print(f"[ops] /impact sessions={imp['sessions']} kwh={imp['kwh']} saved=${imp['saved_usd']} {imp['saved_kgco2']} kg peak {imp['peak_kw']} kW vs baseline {imp['baseline_peak_kw']} kW") + assert 0 <= imp["renewable_share"] <= 1 and "health_usd" in imp + print(f"[ops] /impact sessions={imp['sessions']} kwh={imp['kwh']} saved=${imp['saved_usd']} {imp['saved_kgco2']} kg peak {imp['peak_kw']} kW vs baseline {imp['baseline_peak_kw']} kW renewable-hour share {100 * imp['renewable_share']:.0f}%") + + # 7. the facilities manager's fields and the audit ledger (WP5) + st = http("GET", "/sites/site-1/status") + assert st["contracted_peak_kw"] > 0 and st["package"] in ("capacity", "clean-hours", "pilot") and st["n_connectors"] == 60 and "ladder" in st + with urllib.request.urlopen(API + "/sites/site-1/impact.csv") as r: + csv_text, ctype = r.read().decode(), r.headers.get("Content-Type", "") + lines = csv_text.strip().splitlines() + assert ctype.startswith("text/csv") and lines[0] == "day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source" and len(lines) == 25, (ctype, lines[:2]) + assert sum(float(l.split(",")[2]) for l in lines[1:]) > 0, "the ledger carries today's metered kWh" + print(f"[ops] /status package={st['package']} contracted peak {st['contracted_peak_kw']} kW R=${st['employee_rate_usd_per_kwh']}/kWh signal={st['signal_kind']}; /impact.csv {len(lines) - 1} hourly rows") if imp["baseline_peak_kw"] < imp["peak_kw"]: # possible early in the day (sprints); not a failure, just say so print("[ops] note: managed peak above charge-now peak right now") task.cancel() diff --git a/tests/data/site.json b/tests/data/site.json index 07429ec..0ab2c0b 100644 --- a/tests/data/site.json +++ b/tests/data/site.json @@ -1 +1 @@ -{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833, "connectors_asap": ["c41", "c42"]} \ No newline at end of file +{"id": "site-1", "feed_kw": 150, "block_kw": 100, "connectors": ["c01", "c02", "c03", "c04", "c05", "c06", "c07", "c08", "c09", "c10", "c11", "c12", "c13", "c14", "c15", "c16", "c17", "c18", "c19", "c20", "c21", "c22", "c23", "c24", "c25", "c26", "c27", "c28", "c29", "c30", "c31", "c32", "c33", "c34", "c35", "c36", "c37", "c38", "c39", "c40", "c41", "c42", "c43", "c44", "c45", "c46", "c47", "c48", "c49", "c50", "c51", "c52", "c53", "c54", "c55", "c56", "c57", "c58", "c59", "c60"], "p_max_kw": 7.0, "building_load_kw": [20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 21.7, 23.3, 25.0, 26.7, 28.3, 30.0, 31.7, 33.3, 35.0, 36.7, 38.3, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 40.0, 38.3, 36.7, 35.0, 33.3, 31.7, 30.0, 28.3, 26.7, 25.0, 23.3, 21.7, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0, 20.0], "employee_rate_usd_per_kwh": 0.25, "driver_share": 0.5, "noonshift_share": 0.2, "safe_share_kw": 1.833, "connectors_asap": ["c41", "c42"], "package": "pilot", "contracted_peak_kw": 150} \ No newline at end of file diff --git a/web/src/components/AppShell.jsx b/web/src/components/AppShell.jsx index 3a1868b..a0cda69 100644 --- a/web/src/components/AppShell.jsx +++ b/web/src/components/AppShell.jsx @@ -1,74 +1,80 @@ import { NavLink, Outlet } from 'react-router-dom'; import { useGlobalState } from '../context/GlobalStateContext'; +import { MODE_COPY } from '../lib/ui'; + +const NAV = [ + { path: '/ops/overview', label: 'Overview' }, + { path: '/ops/sites', label: 'Live charging' }, + { path: '/ops/sessions', label: 'Sessions' }, + { path: '/ops/chargers', label: 'Chargers' }, + { path: '/ops/schedules', label: 'Plan' }, + { path: '/ops/impact', label: 'Energy & Impact' }, + { path: '/ops/alerts', label: 'Alerts' }, + { path: '/ops/tariffs', label: 'Tariffs & Package' }, +]; + +// The rungs below the current one that are switched off (from /sites/{id}/status.ladder), for the "why" line. +const downRungs = (ladder) => Object.entries(ladder).filter(([, ok]) => !ok).map(([r]) => r); export default function AppShell() { const { data, triggerEvent } = useGlobalState(); - - const navItems = [ - { path: '/ops/overview', label: 'Overview' }, - { path: '/ops/sites', label: 'Sites' }, - { path: '/ops/sessions', label: 'Sessions' }, - { path: '/ops/chargers', label: 'Chargers' }, - { path: '/ops/schedules', label: 'Schedules' }, - { path: '/ops/impact', label: 'Energy & Impact' }, - { path: '/ops/alerts', label: 'Alerts' }, - { path: '/ops/tariffs', label: 'Tariffs' }, - ]; + const { status } = data; return ( -