From e9ebdc0f94ede8726a1786f64018cdbea3e49d26 Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 04:54:54 +0530 Subject: [PATCH] feat(WP1): shared-savings price, three urgency bands, first-hour floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scheduler.price(slack, *, r, saving_usd, kwh, alpha, urgent): business.md §7b, R - alpha*S/E clamped to [0, R]; urgent or < 1 h slack pays exactly R. No hardcoded prices left in noonshift/. - POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}; /boost is the alias of "now". Per-car floor_alpha / priority in solve_lp (priority car's shortfall weighs prio-fold in the fairness term and costs prio * M_SHORT, so it gives way last). - First-hour floor: min(need, 3.5 kWh) 60 min after plug-in, elastic through the floor slack. prove.py: CO2 -69.3 % -> -64.6 %, bill -3.3 % -> -2.9 %, peak -1.0 %, 0 fallbacks. The floor alone costs 4.8 pts (16.0 -> 14.9 kg). - Correctness fix the floor exposed: slot 0 only has its remaining minutes ahead (dur[0]); floor checkpoints are times since arrival, pro rata inside the slot containing the mark. Without it a mid-slot re-solve after every plug-in event booked energy that could no longer be delivered. - site.json: employee_rate_usd_per_kwh 0.25, driver_share 0.5, noonshift_share 0.2. Contract files regenerated (additive). - 10 new tests (63 total); smoke.py exercises soon + priority. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- .docs/implementation-plan.md | 62 ++++++++++++++++++++++ AGENTS.md | 11 ++-- data/site.json | 2 +- docs/openapi.json | 99 +++++++++++++++++++++++++++++++++++- docs/ws-frames.json | 18 +++++++ noonshift/api.py | 75 +++++++++++++++++++++------ noonshift/models.py | 12 ++++- noonshift/scheduler.py | 99 +++++++++++++++++++++++++----------- scripts/smoke.py | 17 ++++++- tests/data/site.json | 2 +- tests/test_day.py | 59 ++++++++++++++++++++- tests/test_impact.py | 20 +++++++- tests/test_scheduler.py | 41 ++++++++++++++- 13 files changed, 459 insertions(+), 58 deletions(-) create mode 100644 .docs/implementation-plan.md diff --git a/.docs/implementation-plan.md b/.docs/implementation-plan.md new file mode 100644 index 0000000..e3ddb94 --- /dev/null +++ b/.docs/implementation-plan.md @@ -0,0 +1,62 @@ +# Implementation plan — WP1–WP7, refined + +`prompt.md` is the spec. This file is the delta: the assumptions it asked us to confirm, the places where the spec +as written would not work against the code as it is, and the order. Every PR description links here. + +Before (main @ 379bf25, `python scripts/prove.py`): CO₂ −69.3 %, bill −3.3 %, peak −1.0 %, 530 solves, max 70 ms, +0 fallbacks, 53 tests green. + +## Assumptions the spec asked us to confirm (answered here, repeated in each PR) + +| # | Spec asked | Decision | Why | +|---|---|---|---| +| A1 | `employee_rate_usd_per_kwh` default `0.0`? | Code default **0.0** (free workplace charging). `data/site.json` ships **0.25** — the "flat employee rate" case in `business.md` §0b — so the demo shows the §7b math instead of three $0.00 chips. Set it to 0 for a free site. | Both cases are the customer; the demo has to show a number. | +| A2 | `contracted_peak_kw` default = `feed_kw`? | Yes. | Nothing better is known without the bill. | +| A3 | `package` default `pilot`? | Yes. | §9b. | +| A4 | `safe_share_kw` computed default | `min(p_max_kw, (feed_kw − max(building_load_kw)) / n_connectors)` = 1.83 kW on site-1. | `solutions.md` §5. | +| A5 | queue: a done car occupies the bay forever in the sim | The sim assumes a notified driver **moves within 10 sim-minutes when someone is waiting** (`Sim.MOVE_AFTER_MIN`). Real-world signal: OCPP StatusNotification. | Without it the queue can never drain and the WP3 test is untestable. | +| A6 | `/sessions/{id}/boost` = alias of `urgency now` | Yes: sets `boost`, `urgent`, deadline = now. WattWise's "Charge now" therefore forfeits the discount, per §4b. | One path, one price rule. | + +## Deviations from the spec text (each one is in the code as a comment too) + +1. **`price()` denominator.** Spec: pass `kwh_delivered`. At plug-in that is 0, so `α·S/ε` would clamp every new + session to $0. The receipt's saving is computed over the *planned* series (metered + remaining plan), so the + matching denominator is `kwh_needed` while charging and `kwh_delivered` once ended (an early leaver shifted less). +2. **`/price` preview (no session yet).** The spec is silent. Preview = `R − α × (today's site saving rate so far)` + for flex/green, `R` for boost. Labelled estimate; settled on the receipt. No LP call for a preview. +3. **`urgency now` does not move the sim's physical departure.** "Leaving now" means *charge at full power until I + unplug*; if the sim also unplugged the car a minute later there would be nothing to show. `soon` moves both the + deadline and the physical departure (the driver said when they leave). +4. **Never-worse-than-dumb window.** Asserted for the first hour after plug-in only: `min(need, 3.5 kWh)` by 60 min. + Beyond that the guarantee is the progress floor (≥ 50 % pro-rata at every 30-min checkpoint), which the day test + already checks; a dumb-charger bound over *every* rolling hour would forbid the very shifting the product sells. +5. **Fail-safe in the sim mirrors OCPP.** `Connector` gets `safe_kw`; `plug_in` applies it (the static profile); a + dynamic limit expires 15 sim-minutes after the last `set_limit` and the connector reverts to `safe_kw`. In normal + operation `apply_limits()` refreshes every minute, so nothing changes; when the loop dies, the sim degrades the way + a real charger does. This is what makes the kill-the-loop test mean something. +6. **Observation guard needs a physical car in the sim.** Sessions carry `car_kw` (what the car can actually draw; + sim only, defaults to the declared `max_kw`) separately from `max_kw` (what the planner believes). The guard moves + `max_kw` toward the meter; a test sets `car_kw = 3.3` on a bay the planner thinks is 7 kW. +7. **Assistant errors.** Anything that is not `RateLimitError` falls back (`degraded: true`) — including a 400 from an + SDK/parameter mismatch. A demo must never 500 because of the model call. +8. **`connectors_asap` = `["c37", "c38"]`.** The smoke test and the replayed day use `c01–c36` and the top free bays + (`c58–c60`); `c37/c38` collide with nothing and the smoke test now plugs a van into `c37` to prove the flag. +9. **Multi-day replay.** Only one real day is on disk. `scripts/replay_days.py` takes `data/days//{signal,sessions}.json` + and says which days are missing instead of inventing them. + +## Order and size + +| PR | WP | Touches | Tests that fail on old code | +|---|---|---|---| +| 1 | WP1 pricing + emergency + first-hour floor | `scheduler.py`, `api.py`, `models.py`, `data/site.json`, `smoke.py` | price ≤ R / urgent = R / §6b number; `now` full power; `priority` ≥ 90 % pro-rata; priority car 0 shortfall when oversubscribed; 3.5 kWh at 60 min; dumb-charger window | +| 2 | WP2 safe share + OCPP two profiles | `sim.py`, `api.py`, `scheduler.py`, `ocpp_gateway.py` | kill-the-loop; `full` mode = safe share; gateway self-check | +| 3 | WP3 vehicle / soc / guard / asap bays / done / queue | `models.py`, `api.py`, `sim.py` | 3.3 kW car meets deadline; guard trips; queue of 3 tightens the slackest and a bay frees | +| 4 | WP4 evidence scripts | `scripts/prove.py`, `fetch_data.py`, new `fit.py`, `replay_days.py`; `impact(health_usd=)` | script self-checks | +| 5 | WP5 ops dashboard | `web/`, plus `impact.csv`, `renewable_share`, status fields | lint + build | +| 6 | WP6 operator assistant | `assist.py`, `assist_knowledge.md`, `/assist`, chat drawer | `tests/test_assist.py`, smoke `/assist` | +| 7 | WP7 docs | README, ARCHITECTURE §7/§9, CHANGELOG, AGENTS, ROADMAP §0 | contract-file diff | + +## Out of scope (spec "what not to do", kept) + +No new front-end dependencies, no ChargePoint, no bookings ahead of arrival, no V2G, no separate chatbot service, +`SIM_SPEED` untouched, `wattwise/` touched only to keep it building. diff --git a/AGENTS.md b/AGENTS.md index cad8642..5ea6e75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # AGENTS.md — Project Memory (auto-maintained) -Last updated: 2026-09-13 | Sessions logged: 5 +Last updated: 2026-09-13 | Sessions logged: 6 ## 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`. @@ -7,7 +7,7 @@ Hackathon entry: "Noonshift" — a CPO-side, deadline-based EV charging schedule ## Stack & Commands - Python 3.12/3.13 · FastAPI · scipy 1.14 (HiGHS LP) · mobilityhouse/ocpp · asyncpg/Postgres (optional) · Docker Compose. Two Vite front-ends: `web/` (ops dashboard, React+JSX+Tailwind 3, :5173) and `wattwise/` (driver app, React+TS+Tailwind 4, :5174); `npm install && npm run dev` in each, both proxy `/api` and `/ws` to :8000. `npm run build` in each, then `docker compose up` serves them on :3000 / :3001. - `python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt` -- `python -m pytest` (52 tests, ~45 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` +- `python -m pytest` (63 tests, ~60 s) · `python scripts/prove.py` (slide 1) · `python -m noonshift.test_loop` · `docker compose up -d --build` - Data: `python -m noonshift.seed gen` (placeholders) · `python scripts/fetch_data.py signal|caiso|sessions --day ...` (real). ## Current State & Focus @@ -24,8 +24,8 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead → WebSocket frames (plan/meter/event) → driver PWA + ops dashboard. Ladder: live → cached → tariff-only → deadline-only → full power. Hardware limits stay on the charger. ## File Map -- `noonshift/scheduler.py` — `solve(cars, site, signal, tariff, now)`, `solve_lp()` (+info), `impact()`, `impact_detail()`, `price()`; module constants W_CARBON, ALPHA, FLOOR_EVERY, SPRINT_SLOTS, BUFFER_COST, M_SHORT, M_FLOOR, ASAP_EPS, E_MIN, MIN_KW. -- `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`. +- `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()`. - `noonshift/sim.py` — `Sim`, `Connector` (taper from 80% SoC), `load_sessions()`. - `noonshift/models.py` — pydantic REST bodies + WS frames (contract with front-end). `SignalHour` added for `GET /grid/signal` (hourly MOER g/kWh + $/kWh; frames unchanged). - `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`. @@ -67,6 +67,8 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - Open limitation: 1 charger per car for the whole dwell; cars > chargers → deadline becomes 'move-by' time (proposal §9.5). ## Decisions Log +- 2026-09-13 — Emergency = three bands (now/soon/priority), every band pays exactly R; first-hour floor min(need, 3.5 kWh) costs 4.8 pts of CO2 saving on the real day (69.4 → 64.6 %), kept — it is the trust promise (business.md §4). +- 2026-09-13 — First-hour floor beats the signal and the tariff block but never the feed (M_FLOOR 1.0 > block overage 0.83 $/kW-day); a 40-car mass arrival bursts the block, staggered real arrivals do not. - 2026-09-11 — Wedge = daytime workplace/destination sites, payer = site owner. - 2026-09-11 — One plug-in question + one Boost toggle; no pricing lanes. - 2026-09-11 — Elastic LP with progress floor; anti-herding = no broadcast; demo grid = California. @@ -77,6 +79,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 | WP1 pricing + emergency + first-hour floor | scheduler.py, api.py, models.py, data/site.json, tests/, scripts/smoke.py, .docs/implementation-plan.md | price() = §7b shared savings (never above R); urgency endpoint; slot-0 remaining-duration + time-anchored checkpoints fixed a lost-energy bug that the 3.5 kWh promise exposed 2026-09-13 | Open-source docs pass: README rewrite (Mermaid, quick start, results, OCPP 1.6J badge), LICENSE/CONTRIBUTING/CODE_OF_CONDUCT/SECURITY/CHANGELOG, issue+PR templates, .docs/ARCHITECTURE.md (6 validated diagrams), .docs/README.md index; all human docs moved to .docs/; business.md (break points, §4b emergency scale at R, §7b shared-savings formula), metrics.md, solutions.md (real-world fixes → code; drivers under-state stay 29/36; prove.py plans with perfect foresight) | README.md, .docs/, LICENSE, CONTRIBUTING.md, SECURITY.md, CHANGELOG.md, .github/ | All Mermaid blocks validated with mermaid@11 + jsdom; ROADMAP emergency pricing reconciled to "R, no quota". 2026-09-13 | ROADMAP.md (layman plan: driver inputs, emergency, incentives, Tier 0/1, order of work) + GitHub Actions CI | ROADMAP.md, .github/workflows/ci.yml, wattwise/src/context/WattwiseContext.tsx, web/src/pages/ImpactView.jsx | CI = pytest + prove gate + smoke against live uvicorn + contract-diff + lint/build both apps; lint fixes only (refs not read during render, Card hoisted) 2026-09-13 | team-plan.md integration pass: price preview, peak-avoided, fail-safe banner, smoke gate, pitch/ | api.py, models.py, web/, wattwise/, scripts/smoke.py, scripts/dev.ps1, pitch/, README | Baseline peak = sum of frozen per-session baselines clipped at the feed (label "est."); cost-savings slide left TODO for lack of a source diff --git a/data/site.json b/data/site.json index 0a186af..9d24b9f 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]} \ 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} \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index d8f5104..71b8ccf 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -60,9 +60,60 @@ } } }, + "/sessions/{sid}/urgency": { + "post": { + "summary": "Urgency", + "description": "business.md \u00a74b. now: deadline = now, full power (the ASAP path). soon: deadline = leave_at, the LP picks the\ncleanest slots inside the window (< 15 min away = now). priority: deadline unchanged, floor 90 % pro-rata and the\nshortfall weight doubled. Every band marks the session urgent, so price() returns exactly R.", + "operationId": "urgency_sessions__sid__urgency_post", + "parameters": [ + { + "name": "sid", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Sid" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UrgencyIn" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/sessions/{sid}/boost": { "post": { "summary": "Boost", + "description": "Alias for urgency \"now\" (WattWise's \"Charge now\" button).", "operationId": "boost_sessions__sid__boost_post", "parameters": [ { @@ -292,7 +343,7 @@ "/price": { "get": { "summary": "Price Preview", - "description": "Deadline sets the price: preview the tier for a ready-by time before the driver commits.", + "description": "What a ready-by would cost before plugging in. No session yet, so the discount is today's site saving rate so far\n(sum of measured session savings over delivered kWh) applied to this need: an estimate, settled on the receipt.", "operationId": "price_preview_price_get", "parameters": [ { @@ -1021,6 +1072,22 @@ "boost": { "type": "boolean", "title": "Boost" + }, + "urgency": { + "anyOf": [ + { + "type": "string", + "enum": [ + "now", + "soon", + "priority" + ] + }, + { + "type": "null" + } + ], + "title": "Urgency" } }, "type": "object", @@ -1127,6 +1194,36 @@ ], "title": "StatusOut" }, + "UrgencyIn": { + "properties": { + "level": { + "type": "string", + "enum": [ + "now", + "soon", + "priority" + ], + "title": "Level" + }, + "leave_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Leave At" + } + }, + "type": "object", + "required": [ + "level" + ], + "title": "UrgencyIn" + }, "ValidationError": { "properties": { "loc": { diff --git a/docs/ws-frames.json b/docs/ws-frames.json index 7074307..2a7fd19 100644 --- a/docs/ws-frames.json +++ b/docs/ws-frames.json @@ -53,6 +53,23 @@ "boost": { "title": "Boost", "type": "boolean" + }, + "urgency": { + "anyOf": [ + { + "enum": [ + "now", + "soon", + "priority" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Urgency" } }, "required": [ @@ -113,6 +130,7 @@ "unplug", "deadline", "boost", + "urgency", "dr", "demo", "mode", diff --git a/noonshift/api.py b/noonshift/api.py index ae99d55..1953df8 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -16,7 +16,7 @@ from . import db, scheduler, seed from .models import (WS_FRAMES, ConnectorMeter, ConnectorPlan, DemoOut, DrEvent, EventMsg, FlexHour, ImpactOut, LiveOut, MeterMsg, OutageIn, PlanMsg, PlanWindow, PricePreview, PriceTier, SessionIn, SessionOut, - SignalHour, StatusOut) + SignalHour, StatusOut, UrgencyIn) from .sim import SLOT, SPEED, Connector, Sim, load_sessions S = {} # site, signal, tariff, sim, plan, plan_at, baseline, impact, mode, ladder, live_lost_at, last_solve_at, dr, clients, next_id @@ -51,10 +51,30 @@ def pick_mode(now): return "full" +URGENCY_MIN = timedelta(minutes=15) # "soon" closer than this is "now": nothing can be scheduled in under 15 min +PRIORITY = {"floor_alpha": 0.9, "priority": 2.0} # business.md §4b low band: floor 90 % pro-rata, gives way last + + def car(c): s = c.session return {"connector_id": c.id, "arrival": s["arrival"], "departure": s["user_stated_departure"], - "kwh_needed": s["kwh_needed"], "kwh_delivered": s["kwh_delivered"], "p_max_kw": c.p_max_kw, "boost": s["boost"]} + "kwh_needed": s["kwh_needed"], "kwh_delivered": s["kwh_delivered"], "p_max_kw": c.p_max_kw, "boost": s["boost"], + **(PRIORITY if s.get("urgency") == "priority" else {})} + + +def site_rate(): + """R: what the driver pays per kWh today. 0 = free workplace charging (business.md §0b).""" + return float(S["site"].get("employee_rate_usd_per_kwh", 0.0)) + + +def session_price(s, slack): + """business.md §7b: R minus the driver's share of this session's measured saving. The receipt's saving covers the + planned series (metered + plan ahead), so the matching energy is the need while charging and the delivered kWh + once ended (an early leaver shifted less). Any urgency band pays exactly R.""" + imp = S["impact"].get(s["id"], {"saved_usd": 0.0}) + kwh = s["kwh_delivered"] if s["status"] == "ended" else s["kwh_needed"] + return scheduler.price(slack, r=site_rate(), saving_usd=imp["saved_usd"], kwh=kwh, + alpha=S["site"].get("driver_share", 0.5), urgent=bool(s.get("urgent"))) def new_sim(): @@ -160,7 +180,8 @@ def meter_msg(): connector_id=c.id, session_id=s["id"] if s else None, status=c.status if s else "idle", kw=round(c.kw, 2), kwh_delivered=round(s["kwh_delivered"], 3) if s else 0, kwh_needed=s["kwh_needed"] if s else 0, - departure_at=s["user_stated_departure"] if s else None, boost=s["boost"] if s else False)) + departure_at=s["user_stated_departure"] if s else None, boost=s["boost"] if s else False, + urgency=s.get("urgency") if s else None)) return MeterMsg(sim_time=sim.now, mode=S["mode"], site_kw=round(sum(c.kw for c in sim.connectors.values()), 2), building_load_kw=S["site"]["building_load_kw"][slot_of(sim.now)], feed_kw=S["site"]["feed_kw"], connectors=conns) @@ -211,7 +232,7 @@ def session_out(s): return SessionOut(session_id=s["id"], connector_id=s["connector_id"], status=s["status"], plan=PlanWindow(start=h0 + on[0] * SLOT if on else None, end=h0 + (on[-1] + 1) * SLOT if on else None, ready_by=s["user_stated_departure"]), - eta=eta, price=scheduler.price(slack), boost=s["boost"]) + eta=eta, price=session_price(s, slack), boost=s["boost"], urgency=s.get("urgency")) def get_session(sid): @@ -285,18 +306,37 @@ async def create_session(body: SessionIn): return session_out(s) -@app.post("/sessions/{sid}/boost", response_model=SessionOut) -async def boost(sid: int): +@app.post("/sessions/{sid}/urgency", response_model=SessionOut) +async def urgency(sid: int, body: UrgencyIn): + """business.md §4b. now: deadline = now, full power (the ASAP path). soon: deadline = leave_at, the LP picks the + cleanest slots inside the window (< 15 min away = now). priority: deadline unchanged, floor 90 % pro-rata and the + shortfall weight doubled. Every band marks the session urgent, so price() returns exactly R.""" s = get_session(sid) if s["status"] not in ("charging", "done"): raise HTTPException(409, f"session is {s['status']}") - s["boost"] = True - await event("boost", {"session_id": sid, "connector_id": s["connector_id"]}) + sim = S["sim"] + level, leave_at = body.level, body.leave_at + if level == "soon" and (leave_at is None or leave_at < sim.now + URGENCY_MIN): + level = "now" + if level == "now": + s["boost"], s["user_stated_departure"] = True, sim.now # the car leaves when it leaves; charge flat out until then + elif level == "soon": + s["user_stated_departure"] = s["departure"] = leave_at # the driver said when they leave: the sim believes them + s["urgent"], s["urgency"] = True, level + await event("boost" if level == "now" else "urgency", # "now" keeps the event name both front-ends already handle + {"session_id": sid, "connector_id": s["connector_id"], "level": level, "requested": body.level, + "leave_at": s["user_stated_departure"].isoformat()}) await db.save_session(s) - await resolve("boost") + await resolve(f"urgency:{level}") return session_out(s) +@app.post("/sessions/{sid}/boost", response_model=SessionOut) +async def boost(sid: int): + """Alias for urgency "now" (WattWise's "Charge now" button).""" + return await urgency(sid, UrgencyIn(level="now")) + + @app.get("/sessions/{sid}/live", response_model=LiveOut) def live(sid: int): s = get_session(sid) @@ -360,11 +400,15 @@ def site_status(site_id: str): @app.get("/price", response_model=PricePreview) def price_preview(departure_at: datetime, kwh_needed: float = 8.0): - """Deadline sets the price: preview the tier for a ready-by time before the driver commits.""" - slack = (departure_at - S["sim"].now).total_seconds() / 3600 - kwh_needed / S["site"]["p_max_kw"] - tiers = [PriceTier(tier=t, min_slack_hours=m, usd_per_kwh=scheduler.price(m)["usd_per_kwh"]) - for t, m in (("green", 4.0), ("standard", 1.0), ("boost", 0.0))] - return PricePreview(slack_hours=round(slack, 2), price=scheduler.price(slack), tiers=tiers) + """What a ready-by would cost before plugging in. No session yet, so the discount is today's site saving rate so far + (sum of measured session savings over delivered kWh) applied to this need: an estimate, settled on the receipt.""" + sim = S["sim"] + slack = (departure_at - sim.now).total_seconds() / 3600 - kwh_needed / S["site"]["p_max_kw"] + ended = [s for s in sim.sessions.values() if s["status"] != "pending" and s["kwh_delivered"] > 0] + rate = sum(S["impact"].get(s["id"], {"saved_usd": 0.0})["saved_usd"] for s in ended) / sum(s["kwh_delivered"] for s in ended) if ended else 0.0 + quote = lambda h: scheduler.price(h, r=site_rate(), saving_usd=rate * kwh_needed, kwh=kwh_needed, alpha=S["site"].get("driver_share", 0.5)) + tiers = [PriceTier(tier=t, min_slack_hours=m, usd_per_kwh=quote(m)["usd_per_kwh"]) for t, m in (("green", 4.0), ("standard", 1.0), ("boost", 0.0))] + return PricePreview(slack_hours=round(slack, 2), price=quote(slack), tiers=tiers) @app.get("/grid/signal", response_model=list[SignalHour]) @@ -451,7 +495,8 @@ async def demo_boost(): raise HTTPException(409, "nobody to boost") s = cands[0] was = s["user_stated_departure"] - s["boost"] = True + s["boost"] = s["urgent"] = True + s["urgency"] = "now" s["user_stated_departure"] = s["departure"] = sim.now + timedelta(hours=1) await event("boost", {"session_id": s["id"], "connector_id": s["connector_id"]}) await db.save_session(s) diff --git a/noonshift/models.py b/noonshift/models.py index 4c1e45e..307d2bf 100644 --- a/noonshift/models.py +++ b/noonshift/models.py @@ -15,6 +15,14 @@ class SessionIn(BaseModel): kwh_needed: float | None = None +Urgency = Literal["now", "soon", "priority"] # business.md §4b: three bands, one price (R) + + +class UrgencyIn(BaseModel): + level: Urgency + leave_at: datetime | None = None # "soon" only; < 15 min away is treated as "now" + + class Price(BaseModel): tier: Literal["green", "standard", "boost"] usd_per_kwh: float @@ -34,6 +42,7 @@ class SessionOut(BaseModel): eta: datetime | None # when the plan reaches kwh_needed price: Price boost: bool + urgency: Urgency | None = None # set by POST /sessions/{id}/urgency (or /boost = "now"); price is then exactly R class LiveOut(BaseModel): @@ -137,6 +146,7 @@ class ConnectorMeter(BaseModel): kwh_needed: float departure_at: datetime | None # what the driver told us boost: bool + urgency: Urgency | None = None class MeterMsg(BaseModel): @@ -152,7 +162,7 @@ class MeterMsg(BaseModel): class EventMsg(BaseModel): type: Literal["event"] = "event" sim_time: datetime - name: Literal["plug_in", "unplug", "deadline", "boost", "dr", "demo", "mode", "day_reset"] + name: Literal["plug_in", "unplug", "deadline", "boost", "urgency", "dr", "demo", "mode", "day_reset"] detail: dict diff --git a/noonshift/scheduler.py b/noonshift/scheduler.py index b6846a5..9a6833e 100644 --- a/noonshift/scheduler.py +++ b/noonshift/scheduler.py @@ -2,6 +2,8 @@ Conventions the caller (api.py) relies on: - Every list "per slot" is aligned: index 0 is the 5-min slot containing `now`, HORIZON slots follow, wrapping the day. + Only the remainder of slot 0 is ahead of `now`; the LP counts it that way (`dur[0]`), or a mid-slot re-solve after + every plug-in event books energy that can no longer be delivered. - Fail-safe rungs shape the inputs, not the call: tariff-only => signal == {"moer": [], "kind": None}; deadline-only => additionally tariff["price_per_kwh"] == []. Full-power rung never calls solve(). - The charge-immediately baseline is solve() with every departure = now (all cars ASAP, see below). @@ -10,21 +12,27 @@ minimise sum p * SLOT_H * (price[t] + W_CARBON * moer[t] / 1000) + M_SHORT * s[i] + overage_usd_per_kw * over 1. energy sum_{t < deadline} p * SLOT_H + s[i] >= e_rem[i] (elastic: the LP is never infeasible) 2. floor delivered + planned energy by each 30-min checkpoint since arrival >= ALPHA * pro-rata * (need - s[i]) - (fairness + early-unplug safety; anchored to arrival so a rolling re-solve cannot defer it) + (fairness + early-unplug safety; anchored to arrival so a rolling re-solve cannot defer it); + per-car floor_alpha (urgency "priority") replaces ALPHA + 2b. first hour energy 60 min after plug-in >= min(need, FIRST_HOUR_KWH): never worse than a dumb charger in the + hour an emergency is most likely (business.md §4); elastic through the same floor slack, so it beats + the signal and the tariff block but never the feed (rule 5 is hard: the building peak is the customer's) 3. sprint the last 30 min before the deadline carry a BUFFER_COST surcharge, so a feasible car finishes early and the buffer is spent only by cars that would otherwise fall short (and by re-solves after a bad forecast) 4. charger 0 <= p <= p_max_kw 5. site sum_i p[i,t] <= feed_kw - building_load_kw[t] block sum_i p[i,t] - over <= block_kw cap sum_{t < deadline} p * SLOT_H <= e_rem[i] (never plan more than the car can take) - fairness s[i] / e_rem[i] <= z, z priced at M_SHORT * mean(e_rem): a linear shortfall penalty alone starves - whole cars when the lot is oversubscribed; minimising the worst fraction spreads the shortfall instead + fairness priority[i] * s[i] / e_rem[i] <= z, z priced at M_SHORT * mean(e_rem): a linear shortfall penalty alone + starves whole cars when the lot is oversubscribed; minimising the worst fraction spreads the shortfall + instead. A priority car (urgency) also pays priority * M_SHORT per kWh short, so it gives way last. ASAP cars (deadline already passed, or Boost): slot cost is only the earliest-first tie-break, so they charge now. When every car is ASAP (the baseline call) the block-overage term is dropped: charge-immediately means exactly that. """ import logging import math import time +from datetime import timedelta import numpy as np from scipy.optimize import linprog @@ -38,6 +46,11 @@ W_CARBON = 0.05 # $/kg CO2 (= $50/t) trading carbon against tariff; prove.py sweeps this ALPHA = 0.5 # progress floor: every car holds >= half its pro-rata energy at every checkpoint FLOOR_EVERY = 6 # checkpoint every 30 min. Hourly looked smoother on the Gantt but let a car sit at 0 kWh for 59 min +FIRST_HOUR_KWH = 3.5 # business.md §4: what a dumb 7 kW charger gives in 30 min; every car has it 60 min after plug-in +FIRST_HOUR = timedelta(hours=1) +FIRST_HOUR_SLOTS = 12 +FLOOR_STEP = timedelta(minutes=5 * FLOOR_EVERY) +SLOT = timedelta(minutes=5) SPRINT_SLOTS = 6 # last 30 min before the deadline are a buffer: used only when the car would otherwise fall short BUFFER_COST = 1.0 # $/kWh surcharge on buffer slots, >> any tariff spread and << M_SHORT M_SHORT = 10.0 # $/kWh shortfall penalty, >> any per-kWh cost, so shortfall is the last resort @@ -82,7 +95,10 @@ def solve_lp(cars, site, signal, tariff, now): price = _per_slot(tariff.get("price_per_kwh")) avail = np.maximum(site["feed_kw"] - _per_slot(site.get("building_load_kw")), 0.0) overage = tariff.get("block_price", 0.0) * tariff.get("overage_multiplier", 2.0) / DAYS_PER_MONTH - slot_cost = SLOT_H * (price + W_CARBON * moer / 1000) + EPS * np.arange(H) + t0 = now.replace(minute=now.minute - now.minute % 5, second=0, microsecond=0) # start of slot 0 + dur = np.full(H, SLOT_H) # hours per slot; slot 0 = what is left of it + dur[0] = max(1 / 3600, (t0 + SLOT - now).total_seconds() / 3600) + slot_cost = dur * (price + W_CARBON * moer / 1000) + EPS * np.arange(H) S = lambda i: n * H + i # shortfall column of car i OVER = n * H + n @@ -108,6 +124,8 @@ def row(coeffs, rhs): soc = done / need if need > 0 else 1.0 if soc >= TAPER_SOC: # already tapering: the car cannot draw p_max any more, and it only gets slower p_max *= max(TAPER_MIN, (1 - soc) / (1 - TAPER_SOC)) + fa = float(car.get("floor_alpha", ALPHA)) # urgency "priority": 0.9 + prio = max(1.0, float(car.get("priority", 1.0))) # urgency "priority": 2.0 d = _slots_until(car["departure"], now) asap = d == 0 or bool(car.get("boost")) if asap: @@ -120,33 +138,50 @@ def row(coeffs, rhs): continue end = d if asap or d <= SPRINT_SLOTS else d - SPRINT_SLOTS # rule 3: 30-min buffer for t in range(d): - c[base + t] = ASAP_EPS * t if asap else slot_cost[t] + (BUFFER_COST * SLOT_H if t >= end else 0.0) + c[base + t] = ASAP_EPS * t if asap else slot_cost[t] + (BUFFER_COST * dur[t] if t >= end else 0.0) hi[base + t] = p_max - c[S(i)], hi[S(i)] = M_SHORT, e_rem + c[S(i)], hi[S(i)] = M_SHORT * prio, e_rem e_rems.append(e_rem) - row([(base + t, -SLOT_H) for t in range(d)] + [(S(i), -1.0)], -e_rem) # rule 1 (elastic) - row([(base + t, SLOT_H) for t in range(d)], e_rem) # cap + row([(base + t, -dur[t]) for t in range(d)] + [(S(i), -1.0)], -e_rem) # rule 1 (elastic) + row([(base + t, dur[t]) for t in range(d)], e_rem) # cap e_fast = max(0.0, TAPER_SOC * need - done) # energy below the taper knee if not asap and e_rem > e_fast > 0: # the slow tail (above the knee) draws ~TAPER_AVG * p_max: the fast part must be done early enough to # leave it time, or the tail spills past the deadline (winter replay: 22 cars 0.2 kWh short) tail = math.ceil((e_rem - e_fast) / (TAPER_AVG * p_max) / SLOT_H) if 0 < tail < end: # elastic through the floor slack, never through the total shortfall - row([(base + t, -SLOT_H) for t in range(end - tail)] + [(F(i), -1.0)], -e_fast) - row([(S(i), 1.0), (Z, -e_rem)], 0.0) # s[i] / e_rem[i] <= z + row([(base + t, -dur[t]) for t in range(end - tail)] + [(F(i), -1.0)], -e_fast) + row([(S(i), prio), (Z, -e_rem)], 0.0) # prio * s[i] / e_rem[i] <= z if not asap: - # rule 2 (elastic), anchored to arrival: checkpoints every FLOOR_EVERY slots since plug-in, target - # ALPHA * pro-rata of the whole need, counting energy already delivered. Anchoring to `now` instead + # rule 2 (elastic), anchored to arrival: a checkpoint every FLOOR_EVERY slots since plug-in, target + # fa * pro-rata of the whole need, counting energy already delivered. Anchoring to `now` instead # lets a 5-min rolling re-solve defer the floor forever (the first checkpoint is always 30 min away). - elapsed = max(0, int((now - car.get("arrival", now)).total_seconds() // 300)) - L = elapsed + end - for j in range(FLOOR_EVERY, L + 1, FLOOR_EVERY): - t = j - elapsed - 1 - if t < 0 or t >= end: + # Checkpoints are times, not slot counts: the slot containing a mark counts pro rata, so "60 min after + # plug-in" means 60 min whichever minute of a slot the re-solve fires in. + arrival = car.get("arrival", now) + horizon_end = t0 + end * SLOT # deadline minus buffer + span = (horizon_end - arrival).total_seconds() + + def before(mark): + """Energy delivered by `mark`: -hours of each slot that lie in [now, mark), as LP coefficients.""" + out = [] + for t in range(min(end, math.ceil((mark - t0).total_seconds() / 300))): + h = (min(mark, t0 + (t + 1) * SLOT) - max(now, t0 + t * SLOT)).total_seconds() / 3600 + if h > 0: + out.append((base + t, -h)) + return out + + m = 1 + while span > 0 and arrival + m * FLOOR_STEP <= horizon_end: + mark = arrival + m * FLOOR_STEP + m += 1 + if mark <= now: continue - frac = j / L - row([(base + tt, -SLOT_H) for tt in range(t + 1)] + [(S(i), -ALPHA * frac), (F(i), -1.0)], - done - ALPHA * frac * need) + frac = (mark - arrival).total_seconds() / span + row(before(mark) + [(S(i), -fa * frac), (F(i), -1.0)], done - fa * frac * need) + mark = arrival + FIRST_HOUR # rule 2b + if now < mark <= horizon_end: + row(before(mark) + [(F(i), -1.0)], done - min(need, FIRST_HOUR_KWH)) c[F(i)], hi[F(i)] = M_FLOOR, need pure_asap = len(info["asap"]) == n reserved = sum(min(MIN_KW, float(cars[i]["p_max_kw"])) for i in finish) # finishing cars draw MIN_KW in slot 0 @@ -208,7 +243,7 @@ def _enforce_min_kw(plan, cars, cap, now): def solve(cars, site, signal, tariff, now) -> dict[str, list[float]]: - """cars: [{connector_id, arrival, departure, kwh_needed, kwh_delivered, p_max_kw, boost}] + """cars: [{connector_id, arrival, departure, kwh_needed, kwh_delivered, p_max_kw, boost, floor_alpha?, priority?}] site: {feed_kw, building_load_kw: [per slot], block_kw} signal: {moer: [gCO2/kWh per slot], kind: "marginal"|"average"|None} tariff: {price_per_kwh: [per slot], block_price, overage_multiplier} @@ -251,12 +286,18 @@ def impact(plan, baseline, signal, tariff) -> dict[str, float]: return {"saved_usd": d["saved_usd"], "saved_kgco2": d["saved_kgco2"]} -def price(slack_hours: float) -> dict: - """Deadline sets the price. Three tiers by slack (dwell minus charge time). Unknown slack => standard.""" +def tier_of(slack_hours) -> str: + """Three labels by slack (dwell minus charge time), for the UI. Unknown slack => standard.""" if slack_hours is None or slack_hours != slack_hours: - return {"tier": "standard", "usd_per_kwh": 0.25} - if slack_hours >= 4: - return {"tier": "green", "usd_per_kwh": 0.18} - if slack_hours >= 1: - return {"tier": "standard", "usd_per_kwh": 0.25} - return {"tier": "boost", "usd_per_kwh": 0.40} + return "standard" + return "green" if slack_hours >= 4 else "standard" if slack_hours >= 1 else "boost" + + +def price(slack_hours, *, r=None, saving_usd=0.0, kwh=0.0, alpha=0.5, urgent=False) -> dict: + """business.md §7b: the driver pays R minus a share of what the schedule measurably saved, never more than R. + usd_per_kwh = R - alpha * max(S, 0) / E, clamped to [0, R]. Urgent (any emergency band) or < 1 h slack => exactly R. + R = None means free workplace charging (business.md §0b). Old callers `price(slack)` keep working.""" + r = max(0.0, float(r or 0.0)) + tier = tier_of(slack_hours) + discount = alpha * max(saving_usd, 0.0) / kwh if kwh > 0 and not urgent and tier != "boost" else 0.0 + return {"tier": tier, "usd_per_kwh": round(min(r, max(0.0, r - discount)), 4)} diff --git a/scripts/smoke.py b/scripts/smoke.py index 8504922..bb78f95 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -81,9 +81,22 @@ async def pump(): # 6. the four ops demo buttons. Two more (un-boosted) drivers first, so that after "early unplug" takes # one of them, "boost" still finds a car that isn't already boosted; early in the sim day ours may be alone. - for cid in (free[-2], free[-3]): - http("POST", "/sessions", {"connector_id": cid, "departure_at": departure, "kwh_needed": 9.0}) + others = [http("POST", "/sessions", {"connector_id": cid, "departure_at": departure, "kwh_needed": 9.0})["session_id"] + for cid in (free[-2], free[-3])] await asyncio.sleep(1.5) + + # 6a. the emergency bands (business.md §4b): "soon" moves the deadline, "priority" keeps it; both pay exactly R + soon_at = (sim_now + timedelta(hours=1, minutes=30)).replace(second=0, microsecond=0).isoformat() + u1 = http("POST", f"/sessions/{others[0]}/urgency", {"level": "soon", "leave_at": soon_at}) + u2 = http("POST", f"/sessions/{others[1]}/urgency", {"level": "priority"}) + assert u1["urgency"] == "soon" and u1["plan"]["ready_by"].startswith(soon_at[:16]) and not u1["boost"] + assert u2["urgency"] == "priority" and u2["plan"]["ready_by"].startswith(departure[:16]) + assert u1["price"]["usd_per_kwh"] == u2["price"]["usd_per_kwh"] == b["price"]["usd_per_kwh"], "every band pays R" + await asyncio.sleep(2.5) + assert frames_of(frames, "event", name="urgency", level="soon") and frames_of(frames, "event", name="urgency", level="priority") + mine = {c["session_id"]: c for c in frames_of(frames, "meter")[-1]["connectors"]} + assert mine[others[0]]["urgency"] == "soon" and mine[others[1]]["urgency"] == "priority" + print(f"[driver] urgency soon -> ready_by {u1['plan']['ready_by']} ${u1['price']['usd_per_kwh']}/kWh; priority -> ${u2['price']['usd_per_kwh']}/kWh; ops sees both") for path, body in (("/demo/early_unplug", None), ("/demo/boost", None), ("/demo/oversubscribe", None), ("/demo/signal_outage", {"rungs": ["live"]})): d = http("POST", path, body) diff --git a/tests/data/site.json b/tests/data/site.json index 0a186af..9d24b9f 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]} \ 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} \ No newline at end of file diff --git a/tests/test_day.py b/tests/test_day.py index b00f80d..95a3766 100644 --- a/tests/test_day.py +++ b/tests/test_day.py @@ -9,8 +9,9 @@ import pytest from noonshift import scheduler -from noonshift.api import S, aligned, demo_boost, demo_early_unplug, demo_oversubscribe, demo_signal_outage, site_impact, step -from noonshift.models import OutageIn +from noonshift.api import (S, aligned, demo_boost, demo_early_unplug, demo_oversubscribe, demo_signal_outage, site_impact, + step, urgency) +from noonshift.models import OutageIn, UrgencyIn from noonshift.sim import Sim, load_sessions EARLY = {7, 23} # seed.py: told us ~17:00, actually leave ~13:00 @@ -88,6 +89,16 @@ def test_early_leavers_hold_the_progress_floor(day): assert s["kwh_delivered"] >= pro_rata_floor(s, s["departure"]) - scheduler.MIN_KW * scheduler.SLOT_H, (sid, s["kwh_delivered"]) +def test_first_hour_floor_holds_for_every_car_on_the_day(day): + """business.md §4 rule 1 and rule 3, as one assertion: 60 min after plug-in every car has min(need, 3.5 kWh), which + is what a dumb 7 kW charger gives in 30 min. Beyond the first hour the guarantee is the 50 % pro-rata floor (tested + above); a dumb-charger bound over *every* rolling hour would forbid the shifting the product exists to do.""" + for sid, h in day["hist"].items(): + s = day["sessions"][sid] + first_hour = sum(h["kw_min"][:60]) / 60 + assert first_hour >= min(s["kwh_needed"], scheduler.FIRST_HOUR_KWH) - 0.1, (sid, first_hour, s["kwh_needed"]) + + def test_every_session_has_a_finalised_receipt(day): for sid, s in day["sessions"].items(): assert day["hist"][sid].get("final"), sid @@ -170,3 +181,47 @@ def test_demo_signal_outage_walks_the_ladder_and_keeps_planning(mid_morning): assert out.detail["mode_after"] == "full" and all(c.limit_kw == c.p_max_kw for c in mid_morning.active()) out = asyncio.run(demo_signal_outage(OutageIn(restore=True))) assert out.detail["mode_after"] == "live" + + +# ---- urgency bands (business.md §4b) ---- +def charging_now(sim): + return sorted((c.session for c in sim.active() if c.status == "charging" and not c.session["boost"]), + key=lambda s: s["kwh_delivered"] / s["kwh_needed"]) + + +def test_urgency_now_is_full_power_at_todays_rate(mid_morning): + s = charging_now(mid_morning)[0] + out = asyncio.run(urgency(s["id"], UrgencyIn(level="now"))) + assert out.urgency == "now" and out.boost and out.price.usd_per_kwh == S["site"]["employee_rate_usd_per_kwh"] + assert S["plan"][s["connector_id"]][:6] == [S["site"]["p_max_kw"]] * 6, "full power from the next slot" + assert s["user_stated_departure"] == mid_morning.now and s["urgent"] + + +def test_urgency_soon_moves_the_deadline_and_under_15_min_is_now(mid_morning): + a, b = charging_now(mid_morning)[:2] + leave = mid_morning.now + timedelta(hours=2) + out = asyncio.run(urgency(a["id"], UrgencyIn(level="soon", leave_at=leave))) + assert out.urgency == "soon" and not out.boost and a["user_stated_departure"] == a["departure"] == leave + assert out.plan.ready_by == leave and out.price.usd_per_kwh == S["site"]["employee_rate_usd_per_kwh"] + assert sum(S["plan"][a["connector_id"]][24:]) == 0, "nothing planned after the new deadline" + out = asyncio.run(urgency(b["id"], UrgencyIn(level="soon", leave_at=mid_morning.now + timedelta(minutes=10)))) + assert out.urgency == "now" and out.boost, "under 15 minutes nothing can be scheduled: full power now" + + +def test_urgency_priority_keeps_the_deadline_and_raises_the_floor(mid_morning): + s = charging_now(mid_morning)[0] + was = s["user_stated_departure"] + out = asyncio.run(urgency(s["id"], UrgencyIn(level="priority"))) + assert out.urgency == "priority" and not out.boost and s["user_stated_departure"] == was + assert out.price.usd_per_kwh == S["site"]["employee_rate_usd_per_kwh"], "any band pays exactly R" + from noonshift.api import car + c = car(mid_morning.connectors[s["connector_id"]]) + assert c["floor_alpha"] == 0.9 and c["priority"] == 2.0 + + +def test_flexible_driver_pays_less_than_r_and_never_more(day): + """§7b on the replayed day: every non-urgent receipt is at or below R, and the day's flexible cars earned a discount.""" + from noonshift.api import session_out + prices = [session_out(s).price.usd_per_kwh for s in day["sessions"].values()] + r = S["site"]["employee_rate_usd_per_kwh"] + assert max(prices) <= r and min(prices) < r diff --git a/tests/test_impact.py b/tests/test_impact.py index f2d9f4c..c6fd877 100644 --- a/tests/test_impact.py +++ b/tests/test_impact.py @@ -72,4 +72,22 @@ def test_empty_and_ragged_inputs(): @pytest.mark.parametrize("slack,tier", [(4, "green"), (10, "green"), (3.99, "standard"), (1, "standard"), (0.99, "boost"), (0, "boost"), (-2, "boost"), (math.nan, "standard"), (None, "standard")]) def test_price_tiers_are_total(slack, tier): - assert price(slack)["tier"] == tier and price(slack)["usd_per_kwh"] > 0 + assert price(slack)["tier"] == tier and price(slack)["usd_per_kwh"] == 0.0, "old signature: free site, label only" + + +# ---- business.md §7b: R − α·S/E, never above R; urgency pays R ---- +def test_price_never_exceeds_r_and_urgent_pays_r(): + assert price(6, r=0.25, saving_usd=5.0, kwh=10.0)["usd_per_kwh"] == 0.0, "clamped at zero when the share exceeds R" + assert price(6, r=0.25, saving_usd=-3.0, kwh=10.0)["usd_per_kwh"] == 0.25, "a bad forecast never surcharges" + assert price(6, r=0.25, saving_usd=0.0, kwh=10.0)["usd_per_kwh"] == 0.25, "zero saving => R" + assert price(6, r=0.25, saving_usd=2.0, kwh=10.0, urgent=True) == {"tier": "green", "usd_per_kwh": 0.25} + assert price(0.5, r=0.25, saving_usd=2.0, kwh=10.0) == {"tier": "boost", "usd_per_kwh": 0.25} + assert price(6, r=0.25, saving_usd=2.0, kwh=0.0)["usd_per_kwh"] == 0.25, "unknown energy => no discount, not a free car" + assert price(6, r=None, saving_usd=2.0, kwh=10.0)["usd_per_kwh"] == 0.0, "free workplace charging stays free" + + +def test_price_reproduces_the_caltech_day_number(): + """metrics.md §6b: S = 2.20 USD over 378.9 kWh at alpha 0.5 => 0.3 c/kWh discount, green price 0.247 at R = 0.25.""" + out = price(5, r=0.25, saving_usd=2.20, kwh=378.9, alpha=0.5) + assert out["tier"] == "green" and out["usd_per_kwh"] == pytest.approx(0.247, abs=5e-4) + assert 0.25 - out["usd_per_kwh"] == pytest.approx(0.0029, abs=2e-4) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 29b8e31..013e8a1 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -239,7 +239,8 @@ def test_rounding_up_still_respects_site_limit(): def test_rounding_up_never_creates_block_overage_the_lp_did_not_choose(): - cars = [car(f"c{i}", 6, 10) for i in range(40)] # 400 kWh over 5.5 h: fits under the 100 kW block + # 400 kWh over 5.5 h fits under the 100 kW block; plugged in an hour ago, so no first-hour floors compete for it + cars = [car(f"c{i}", 6, 10, arrived_hours_ago=1.0) for i in range(40)] plan = solve(cars, site(), signal(), tariff(), NOW) assert no_sub_min(plan) assert max(site_kw(plan, t) for t in range(H)) <= 100 + 1e-6 @@ -253,3 +254,41 @@ def test_trim_drops_a_car_to_zero_not_below_min_kw(): assert no_sub_min(plan) for t in range(H): assert site_kw(plan, t) <= 130 + 1e-6 + + +# ---- urgency (business.md §4b) and the first-hour floor (§4) ---- +def test_first_hour_floor_beats_a_dumb_charger_in_the_hour_that_matters(): + """Priya: 12 kWh over 9 h, expensive first two hours. Pro-rata alone gives 0.67 kWh at 60 min; she needs 3.5 to + reach the clinic. The floor is elastic (a car that needs less gets its whole need).""" + price = [0.40] * 24 + [0.10] * (H - 24) + plan = solve([car("a", 9, 12)], site(), signal(), tariff(price), NOW) + assert kwh(plan, "a", S.FIRST_HOUR_SLOTS) >= S.FIRST_HOUR_KWH - 1e-6 + small = solve([car("b", 9, 2.0)], site(), signal(), tariff(price), NOW) + assert kwh(small, "b", S.FIRST_HOUR_SLOTS) >= 2.0 - 1e-6 + late = solve([car("c", 9, 12, delivered=2.5, arrived_hours_ago=50 / 60)], site(), signal(), tariff(price), NOW) + assert kwh(late, "c", 2) >= 1.0 - 1e-6, "anchored to arrival: 50 min in with 2.5 kWh, the last kWh lands in two slots" + rush = [car(f"c{i}", 6, 10) for i in range(40)] # 140 kWh of first-hour floors: more than the block, less than the feed + plan = solve(rush, site(), signal(), tariff(), NOW) + assert 100 < max(site_kw(plan, t) for t in range(H)) <= 130 + 1e-6, "a mass arrival bursts the tariff block, never the feed" + first = sorted(kwh(plan, c["connector_id"], S.FIRST_HOUR_SLOTS) for c in rush) + assert sum(first) == pytest.approx(130.0, abs=1.0) and first[len(first) // 2] == pytest.approx(S.FIRST_HOUR_KWH, abs=1e-6), "the whole feed goes to first hours (ponytail: the residual is not min-max shared; staggered real arrivals never hit this)" + + +def test_priority_car_holds_90_percent_pro_rata_at_every_checkpoint(): + price = [0.40] * 60 + [0.10] * (H - 60) # cheap from 5 h: the fast 80 % fits there, so early slots hold only floors + plan = solve([dict(car("a", 8, 14), floor_alpha=0.9)], site(), signal(), tariff(price), NOW) + for hours in (1, 2, 4): + frac = hours * 12 / (96 - S.SPRINT_SLOTS) + assert kwh(plan, "a", hours * 12) >= 0.9 * frac * 14 - 1e-6, hours + plain = solve([car("a", 8, 14)], site(), signal(), tariff(price), NOW) + assert kwh(plain, "a", 48) < 0.9 * (48 / 90) * 14 - 1.0, "a plain car holds only the 50 % floor" + + +def test_priority_car_has_zero_shortfall_while_the_others_share(): + cars = [car(f"c{i}", 2, 12) for i in range(40)] # 480 kWh wanted, 260 possible; 12 kWh fits in 2 h at 7 kW + cars[7] = dict(cars[7], priority=2.0) + plan, info = solve_lp(cars, site(), signal(), tariff(), NOW) + assert info["status"] == "optimal" and info["shortfall_kwh"]["c7"] == pytest.approx(0.0, abs=1e-3) + assert met(plan, "c7", 12.0) + others = [info["shortfall_kwh"][f"c{i}"] for i in range(40) if i != 7] + assert min(others) > 0 and max(others) - min(others) <= 0.1 * max(others), "the rest still share evenly"