Charging Hub: Energy Billing, a Shared Power Cap and Reservations
MediumAI-assisted coding · AI-Assisted Debugging
Charging Hub: Energy Billing, a Shared Power Cap and Reservations
You've joined the software team at a public EV charging hub: a row of chargers, one grid connection, and a service that simulates every car's charging session minute by minute and produces the receipt when the driver unplugs. Operations has two complaints about the numbers, the site engineers want the grid connection shared properly between cars, and the product team has two changes queued behind that. The hub also serves coaches, trucks and ferries, so batteries can be several MWh.
Codebase
| File | What it does |
|---|---|
hub/api.py | run(operations) entry point and the Dispatcher that routes each operation |
hub/hub.py | ChargingHub: chargers, sessions, status queries and the minute-by-minute simulation |
hub/allocation.py | allocate: how the site's power cap is divided between the cars drawing this minute |
hub/billing.py | Turns a session into a receipt: energy bill and idle fee |
hub/reservations.py | ReservationBook stub for Part 3 (the operations exist and answer "not supported") |
hub/models.py | Charger and Session dataclasses |
hub/units.py | Integer helpers: watt-hours to watt-minutes, ceiling division, half-up division |
hub/rules.py | Argument validation shared by the operations |
hub/errors.py | HubError and the error codes |
hub/config.py | Tariff and site constants (price, idle fee, grace period, reservation hold) |
tests/ | Existing unit and end-to-end tests (some of them currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh hub and returns one result per operation. Every operation is [name, ts, ...args]; ts is an integer number of minutes and never decreases. Errors are returned as strings, never raised.
Units (all integers, no floating point anywhere)
- Power is in watts (
W). Energy that flows is tracked in watt-minutes (wmin): one minute atPwatts delivers exactlyPwmin. Customers speak watt-hours:1 Wh = 60 wmin. Capacities and states of charge in operations are in Wh;delivered_whandenergy_whin results are whole Wh, rounded down. - Minute
mis the interval frommtom+1. A car plugged in attsdraws during minutets. - Money is integer cents. The energy bill is the exact energy times the price (35 c per kWh,
1 kWh = 60,000 wmin), rounded to the nearest cent with halves rounding up. The bill is computed from the exact watt-minutes, never from the rounded-down Wh.
| Operation | Arguments after ts | Returns | Errors (checked in this order) |
|---|---|---|---|
add_charger | charger_id, max_w | true | invalid arguments, charger exists |
set_cap | cap_w | true | invalid arguments |
plug_in | session_id, charger_id, capacity_wh, start_wh, max_w | true | invalid arguments, session exists, charger not found, charger busy |
unplug | session_id | a receipt (below) | session not found, already unplugged |
status | session_id | {"state","delivered_wh","rate_w","finished_at"} | session not found |
charger | charger_id | {"max_w","session","draw_w"} | charger not found |
site | none | {"draw_w","charging","full"} | none |
- Ids are non-empty strings.
max_w,capacity_whare integers>= 1;start_whis an integer>= 0and<= capacity_wh;cap_w >= 0. Booleans are not integers. Anything else isinvalid arguments, as is the wrong number of arguments. An unknown operation name returnserror: unknown operation. - Every operation whose
tsis an integer>=the previous one first advances the simulation tots(minutes< tsare simulated), even if the operation then fails. Atsthat is not an integer or that goes backwards returnsinvalid argumentsand does nothing. - A session id is never reused, even after unplug. A charger holds at most one session.
- A session draws while it is plugged in and not full. Its limit is
min(car max_w, charger max_w). A car that arrives withstart_wh == capacity_whis full from its plug-in minute (finished_at = ts). A car becomes full when it has received(capacity_wh - start_wh) * 60wmin; it never receives more, so in its last minute it takes only what it still needs.finished_atis the minute at which it became full (the end of its last drawing minute). status:stateis"charging","full"(plugged in, not drawing) or"unplugged".rate_wis the watts the session draws during minutets(0 unless charging).site: total watts drawn this minute, the number of charging cars, the number of full cars that are still plugged in.charger:sessionis the plugged-in session id ornull.- Receipt (
unplug):{"session", "energy_wh", "energy_cents", "idle_minutes", "idle_cents", "total_cents"}. The idle fee applies to a car that is full and still plugged in: the first 5 minutes afterfinished_atare free (the grace period); every further minute until unplug costs 40 c. Soidle_minutes = max(0, unplug_ts - finished_at - 5), and a car that was never full pays none.
Worked example
[["add_charger", 0, "c1", 7000],
["plug_in", 0, "s1", "c1", 20000, 12345, 9000],
["status", 66, "s1"],
["unplug", 74, "s1"]]returns
[true, true,
{"state": "full", "delivered_wh": 7655, "rate_w": 0, "finished_at": 66},
{"session": "s1", "energy_wh": 7655, "energy_cents": 268, "idle_minutes": 3, "idle_cents": 120, "total_cents": 388}]The car needs 7,655 Wh = 459,300 wmin at 7,000 W (the charger is the limit), so it is full in minute 65 (the 66th minute, which takes only 2,300 wmin) and finished_at is 66. 7,655 Wh at 35 c/kWh is 267.925 c, billed as 268. It stays 8 minutes; 5 are free, 3 are billed.
Part 1 — Fix the metering and the bill (OPS-1180, OPS-1194)
Goal: make a single car's session, its status and its receipt follow every rule above. Assume the cap is not binding in this Part (no set_cap, or a very large one).
Operations reports: receipts for some cars show more energy than the battery can hold, bills are a few cents off, idle fees start charging for the free grace minutes, and a car that has been unplugged still seems to use the site's power. Read hub.py and billing.py, reproduce each symptom with a test, and fix the causes (not the symptoms in the API layer). Existing behavior not mentioned above must not change.
Part 2 — Share the site cap with water-filling (SITE-77)
Goal: set_cap sets the site's power cap in watts (default: effectively unlimited). The cars that draw in a given minute must never exceed it in total, and no power may be wasted that a car could have used. The current allocator gives every car an equal share and clips it to the car's limit, so the watts that slow cars cannot use are lost.
- Each minute, the cap is divided among the drawing sessions by this exact rule. Consider the cars in ascending order of limit (ties: earlier plug-in first). With
Rwatts left andncars left, the share isR // n(integer division). If the next car's limit is<= share, it draws exactly its limit; subtract that fromRand continue. Otherwise every remaining car has a limit above the share: each drawsshare, and theR - share * nleftover watts are handed out one watt each to the remaining cars in plug-in order (earliestplug_infirst; every session has a plug-in order, and it is not the id order or the charger order). - A negative remainder cannot occur, and no car is ever given more than its limit. If the cap covers all limits, everyone draws their limit; with
set_cap 0nobody draws. - Rates stay the same until something changes: a car becomes full, a session is plugged in or unplugged, or
set_capis called. A car that fills up in a minute takes only what it still needs; the power it did not take in that last minute is not redistributed during that minute (it is shared from the next minute on).status,chargerandsitereport the rates of the same allocation the simulation uses, for minutets. set_captakes effect from minutetson.- Scale. The nightly replay simulates months of minutes with dozens of cars on a weak grid connection (a few hundred operations, hundreds of thousands of simulated minutes). It must finish in a couple of seconds, so the simulation cannot afford to visit every minute.
Worked example (limits 2000 / 9000 / 9000 W under a 10,000 W cap)
[["add_charger", 0, "c1", 9000], ["add_charger", 0, "c2", 9000], ["add_charger", 0, "c3", 9000],
["set_cap", 0, 10000],
["plug_in", 0, "a", "c1", 51000, 1000, 2000],
["plug_in", 0, "b", "c2", 51000, 1000, 9000],
["plug_in", 0, "c", "c3", 51000, 1000, 9000],
["site", 1], ["status", 1, "b"]]returns ... {"draw_w": 10000, "charging": 3, "full": 0}, {"state": "charging", "delivered_wh": 66, "rate_w": 4000, "finished_at": null}. The share is 10000 // 3 = 3333; car a can only use 2000, so R = 8000 is split between two cars: 4000 each. With three 9000 W cars and the same cap, the share is 3333 with 1 W left over, so the rates are 3334 / 3333 / 3333 in plug-in order.
Part 3 — Reservations and fleet priority (PROD-412)
Two new requirements, which change some Part 2 rules.
Fleet priority. plug_in takes an optional 7th argument, an options object. {"fleet": true} marks a fleet vehicle. Fleet vehicles are allocated first: the Part 2 rule is applied to the fleet cars alone with the whole cap, and the non-fleet cars then apply the same rule to the cap minus what the fleet cars draw (so they may get nothing). Within a tier, limits, ties and the remainder order are exactly as in Part 2.
Reservations. A reservation holds one charger for a driver who is on their way.
reserve(reservation_id, charger_id, arrive_at) returnstrue. Errors, in order:invalid arguments(ids must be non-empty strings,arrive_atan integer>= 0),reservation exists,charger not found,invalid arrival(arrive_at < ts),charger busy(a session is plugged in on it right now),charger reserved(it already has a pending reservation).- A reservation is pending from the moment it is made. It becomes claimed when a session plugs in with
{"reservation": reservation_id}, and released (a no-show) oncets >= arrive_at + 10. (The ten minutes areRESERVATION_HOLD_MINUTESinconfig.py.) reservation(reservation_id) returns{"state","charger","arrive_at","session"}withstateone of"pending","claimed","released", andsessionthe claiming session ornull. Error:reservation not found.- A plain
plug_in(noreservationoption) on a charger with a pending reservation fails withcharger reserved. Once the reservation is released the charger is free again. - Claiming:
plug_inwith a reservation option checks, aftersession existsandcharger not found, in this order:reservation not found,wrong charger(a different charger than the reserved one),reservation closed(already claimed or released),too early(ts < arrive_at), and thencharger busy. A plain plug-in checkscharger busyand thencharger reserved. Options may combine:{"fleet": true, "reservation": "r1"}. An options value that is not an object, has other keys, a non-booleanfleetor a non-stringreservationisinvalid arguments(checked first, with the other argument checks). - A claim marks the reservation claimed only if the plug-in succeeds.
Worked example
[["add_charger", 0, "c1", 7000],
["reserve", 0, "r1", "c1", 30],
["plug_in", 5, "walkup", "c1", 40000, 0, 7000],
["plug_in", 30, "owner", "c1", 40000, 0, 7000, {"reservation": "r1"}],
["reservation", 31, "r1"]]returns true, true, "error: charger reserved", true, {"state": "claimed", "charger": "c1", "arrive_at": 30, "session": "owner"}. Had nobody claimed it, the same walk-up at minute 39 would still be refused and at minute 40 would succeed.
Fleet example: 10,000 W cap, one fleet bus (limit 9000) and two other cars (limit 9000): the bus draws 9000 and the other two share the remaining 1000: 500 W each. Raise the cap to 15,000 and they draw 3000 W each.
Everything from Parts 1 and 2 keeps working: receipts, rounding, idle fees, water-filling inside each tier, and the fast replay with fleets and reservations in play.
Example 1
- Input:
- [["add_charger",0,"c1",7000],["plug_in",0,"s1","c1",40000,30000,6000],["status",50,"s1"],["unplug",100,"s1"]]
- Output:
- [true,true,{"state":"charging","delivered_wh":5000,"rate_w":6000,"finished_at":null},{"session":"s1","energy_wh":10000,"energy_cents":350,"idle_minutes":0,"idle_cents":0,"total_cents":350}]
- Explanation
- The car needs 10 kWh and draws 6 kW (the lower of its limit and the charger's), so it is full at minute 100 and pays 35 c per kWh.
Example 2
- Input:
- [...3 chargers of 9000 W..., ["set_cap",0,10000], 3 cars drawing from ts 0 with limits 2000, 9000, 9000, ["site",1]]
- Output:
- {"draw_w":10000,"charging":3,"full":0}
- Explanation
- Equal share is 3333 W, but the first car can only take 2000. The 1333 W it leaves is shared again, so the others draw 4000 W each and the whole cap is used.
Guidelines
- Python 3 standard library only
- No floating-point arithmetic: power is integer watts, energy is integer watt-minutes, money is integer cents
- Timestamps are integer minutes and never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 2 and Part 3 inputs simulate hundreds of thousands of minutes with dozens of cars drawing at once