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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ 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`; `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/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`; `POST /assist` + `GET /assist/suggestions` (`dependencies=OPS`: `ops_auth` bearer when `OPS_TOKEN` is set, also on `/demo/*`); `S["events"]` deque(20) filled by `event()`; `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/assist.py` — operator assistant: `snapshot()` (plain dict from `S` via the REST builders; ≤ 15 connector rows + counts, last 20 events, signal/tariff summaries, ≤ 24 k chars), `ask(question, history, page, snap)` → (payload, status) — `claude-opus-5`, cached `SYSTEM` = RULES + `assist_knowledge.md`, adaptive thinking, effort low; AuthenticationError/no key → `fallback()`, RateLimitError → 429, connection/5xx → fallback `degraded`; `parse()` (`label -> /ops/<page>` lines + `[sources: …]`); `allow(ip)` 10/min; `suggestions(snap)` = 6 starters from state. `assist_knowledge.md` — static prefix, never time-varying.
- `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` + `/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/components/AssistDrawer.jsx` — floating "Ask" → right drawer (360 px, full width < sm); sessionStorage history in try/catch; chips from `/assist/suggestions`; sources tags, action buttons `navigate(path)`, "offline answer" tag on fallback/degraded; 429 shows retry-after. `web/src/lib/api.js` — `API` base URL.
- `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).
Expand All @@ -52,6 +54,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
- `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.
- Progress floor must be anchored to arrival, not `now`: a rolling re-solve otherwise defers it forever under a falling MOER.
Expand Down Expand Up @@ -82,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 | 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
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
Expand Down
Loading
Loading