Hours Demand — “How many charge hours does my battery need — and when?”
POST /v1/decision/hours-demand — EMS hour plan from SOC, load, and PV. Loxone primary path: Hours & telemetry with Extract v1.17. Open API for HA/Python; Community 120 requests/day.
What this does (plain language)
Imagine your battery is at 35%. The house still needs power today. PV will deliver some. Spot prices move.
Hours Demand answers:
1. How many of the cheapest grid-charge hours you should release today (hours_needed_today) — the full plan is in hours_needed / day_plan 2. Which clock times those are (charge_hours_today / charge_hours) — including price in ct/kWh
Everyday example: “Today you need 3 charge hours. Best slots: 13:00, 14:00 and 03:00. Across the week the system plans further cheap hours — including reserve (P20 + absolute guard, Cap below target SoC) when a later day has a gap and the pack can still carry the kWh; optional Dump before further reserve — visible under Plant outlook / Sparvo plant.”
Your EMS (e.g. Loxone Spot Price Optimizer) gets the today count — and optionally the exact start times.
When to use
- You have a battery (SOC in %)
- You do not want to build the math yourself
Not needed if you already know “always exactly 4 hours” → Windows.
Two modes
| Mode | What happens | Analogy |
|---|---|---|
decide (default) | Balance + charge plan | ready proposal |
telemetry | Store SOC + meter only | status update; internal Decide near min SoC (down + up) |
For frequent meter/SOC pings prefer Plant Telemetry (same idea, separate quota).
Endpoint
POST https://api.spotpriceapi.com/v1/decision/hours-demand
Header:
X-API-Key: sf_live_YOUR_KEY
Content-Type: application/json
Community access or higher.
What to set in the portal first
Under location / plant profile ideally:
- Battery capacity (kWh)
- Grid connection rating (kW) — caps grid charge power: O11 /
charge_power_kw= connection × 0.80 − base load (base load = annual/8760 if no explicit field). Example: 12 kW connection + ~17 900 kWh/year → ≈ 7.6 kW (not 12). Thin Decide withoutbattery.max_charge_kwin the body; otherwise the request caps power. - PV size (kWp) and address/coordinates
Then the request can stay thin. Missing in both request and profile → incomplete_input.
Minimal example (Decide)
curl -X POST https://api.spotpriceapi.com/v1/decision/hours-demand \
-H "Content-Type: application/json" \
-H "X-API-Key: sf_live_YOUR_KEY" \
-d '{
"timezone": "Europe/Vienna",
"horizon_hours": 168,
"constraints": { "max_hours_today": 12 },
"battery": { "soc_pct": 35, "capacity_kwh": 10, "max_charge_kw": 5 },
"load": { "energy_next_24h_kwh": 18 },
"pv": { "mode": "estimate", "lat": 47.07, "lon": 15.44, "kwp": 6.0 }
}'
Omit horizon_hours → server default 168 (7-day look-ahead). Caps are slot counts, not look-ahead length. max_hours_horizon is optional — without a request override the server derives it from this battery + charge power (ceil(usable kWh / kW)+12, max 168, no default 36). Response: max_hours_horizon_effective. basis.storage_horizon is planning meta only (autonomy, defer, cap) — annual € and size ladder live on plant outlook only.
Reading the response
| Field | Plain meaning |
|---|---|
decision_status | ok = use the plan; else reason |
prefix_hours_needed_today | EMS: contiguous prefix today — Extract O1 (0 when O9=0) |
prefix_period_hours_today | EMS: period for prefix — Extract O2 |
prefix_energy_charge_kwh | EMS: plan kWh for prefix — basis for O9 (remaining) |
charge_now | 1 when current hour is in prefix (after Decide) |
charge_stop_soc_pct | Session stop SOC (%) — Extract O4. Survival / min-SOC top-up: reserve floor (min_soc_pct) only when live SOC is below that floor; otherwise trajectory/target (never a stop below live SOC) |
target_soc_pct | Profile target SOC (%) — not the same as stop SOC |
capacity_kwh | Battery capacity for SOC-delta O9 |
prefix_charge_hours_today | Compact array (start + energy_kwh) for Extract slot match |
hours_needed_today | Portal/Sparvo: hours still today in the Period window (until 23:00) |
period_hours_today | EMS: optimizer Period = hours remaining until 23:00 (00:00 → 23, 13:00 → 10, 22:00 → 1). At 23:00 it resets to 24. Not the plan horizon. With feature flag HOURS_INTERIM_ECON (default on, kill-switch =0) it may be shorter (cap 5 h) when an economic bridge charge is needed before the next cheap hour. Extract O2 reads prefix_period_hours_today (not this field). |
period_hours_today_default | (flag on) Classic Period until 23:00 — transparency when period_hours_today is shortened |
interim_* | (flag on) Econ bridge: interim_needed, interim_decision (charge\ |
hours_needed | Full plan over the horizon (cover + optional dump + reserve). Portal/Sparvo shows this separately from hours_needed_today (chip “today … h · … h in total”) |
energy_charge_kwh | Grid-charge target in kWh, rounded up to the next whole kWh (1.4 → 2). Includes the cover buffer (default 10 % on the shortfall; portal location / Sparvo Settings → Battery rules). Not max kW × hours. |
hours_needed_days | Always 7 numbers [today, D+1, …, D+6] (never null). Extract v1.17: O5–O8 free (portal/week glance). UI week glance: index = calendar day D0…D6 — not the index in the sparse day_plan list. |
next_charge_start / next_charge_hours | Next remaining slot (from now) plus hours on that calendar day. |
grid_charge_allowed | Top-level (EMS): master switch true/false — Loxone Extract O10. Same value in basis.grid_charge_allowed. |
energy_deficit_kwh | Need after placement (plan_feasibility.energy_requested_kwh, leftover prep excluded). Sized prep floor stays in basis.forward_prep |
charge_power_kw | Available grid-charge power in kW — Extract O11 (not the raw grid connection rating) |
discharge_power_kw | Discharge cap in kW — Extract O12; missing → PicoC fallback 9. Cheap-hold only when PV+pack cannot cover until the next morning sun: current hour is among today’s cheapest N and not charging → 0 kW. Not when the pack is full (≥99 %) and not in a PV-surplus hour. A later reserve-stretch gap alone is not enough. First-PV cover satisfied → normal discharge. While charge_now, full cap. Adaptive otherwise: full cap except near min SoC and no recharge. 0 also at/below min SoC with no recharge, or user cap 0 |
min_soc_pct | Minimum SoC (%) — Extract O13 |
adaptive_discharge | true = reserve-protect near min SoC when recharge is not worth it; false = fixed max_discharge_kw. Cheap-hold only when cover is unsatisfied |
discharge_reason | Why O12 is this value: cheap_hold · headroom · recharge_planned · reserve_hold · reserve_empty · adaptive_off · soc_unknown · user_off · ev_charge_block (wallbox charging, discharge 0%) · ev_charge_limit (wallbox charging, discharge × Sparvo %) |
cheap_take_hours | N = ceil(daily load / charge kW), cap max_hours_today (default 12). Daily load = typical today, else annual/365. Cheap-hold only when cover is unsatisfied |
hour_is_cheap | current local hour is in that set |
cheap_hold | true when O12 is 0 because of cheap-hold (cover gap + cheap hour + not charging) |
hours_needed_cover / hours_needed_prep / hours_needed_opp / hours_needed_bridge | Breakdown: cover · dump (opp) · reserve (prep) · bridge — picker placement cover→bridge→dump→prep (sizing ≠ placement, see concept §14.6) |
prep_extra_energy_kwh | Placed prep kWh (slot_role=prep, often 0). Sized floor stays in basis.forward_prep |
charge_hours_today / charge_hours | Today starts / full plan (v1.3: SOC-feasible only). Internally the engine may move same-day grid kWh onto a cheaper chargeable hour (P1-v2, server flag; elapsed hours stay). Response shape unchanged, no new required field, discharge_reason / O12 unchanged |
plan_feasibility | ok / partial / no_feasible_slots; storage_undersized when capacity blocks scheduling |
skipped_slots[] | Cheap hours skipped (skip_reason: e.g. soc_at_capacity, pv_self_charge_sufficient, pv_will_fill_today, pv_fills_charge_power) — always in response; Portal/Sparvo Grid charge lists them chronologically with dates. More than 6 rows: link to /plant/skipped-hours or /plant-outlook/skipped-hours |
charge_hours[].soc_projected_* / slot_role / survival | Per-slot explain (cover/prep/opp, SoC before/after). Portal/Sparvo: economical cover hour · reserve hour · hold minimum charge level · opportunistic fill · bridge charge |
day_plan | Per calendar day: N + starts (Portal/Sparvo week glance) |
charge_hours[].dump_risk_pct / dump_scenario_ct_kwh | From forecast hour: pct 0…100; scenario = dump when pct > 0, else same point price as price_ct_kwh — additive only |
basis.cover_window_hours / remaining_today_load_kwh | Cover length (may include reserve stretch); rest-of-today load (typical + same-day overlay minus so-far). Also cover_window_hours_pv / cover_extended_for_reserve |
basis.forward_prep / basis.prep_horizon_hours / basis.carry_hours | Reserve v1.8/v1.11: extra kWh, \(G_{min}\), Cap/P20/absolute, gap_day_ranges, data window \(H_{prep}\), carry \(H_{eff}\). v1.11: sized after cover path (notes may include sized_after_cover); energy_kwh ≤ v0. Prep gap = PV day; EMS fields = calendar day |
basis.autark_ok / basis.autark_notes | v1.10: PV+battery cover gate window \(H_{gate}\) = rest-of-today from decide + next calendar day until morning sun → no planned grid charging (cover/prep/dump/bridge = 0). Exception: cover_extended_for_reserve → stretch cover (stretch_reserve_outside_gate). autark_notes: gate_h:… or trough hint. Sparvo daily PV tile = full PV day; engine = hourly rest series. All plants/keys. Kill-switch HOURS_AUTARK_GATE=0 (default on) |
basis.grid_charge_allowed | Master switch from the plant profile (default on). Off → all hours_needed* 0, empty slots, charge_kind=grid_off |
basis.pv_fill_lookahead | Look-ahead audit: near/survival discount 0.70; prep/far-cover discount_prep_far 1.0; fills_safe when SoC is fresh |
basis.hourly_load_cover_kwh / hourly_pv_cover_kwh | Hourly load/PV in the cover window (from now) — Portal/Sparvo At window end rolls SoC chronologically (no bulk PV−load); surplus while full is clipped |
basis.load_refine | v1.9: short-term refine metadata (kind: partial_hour / meter_budget / recent_rate / client_override; partial_hour_frac; recent_hours_used; skipped for client load). Follow days unchanged |
soc_pct_at_decide / projected_soc_pct_*_at_decide | Optional: decide-time values when live SoC is mapped onto “Now” after telemetry (soc_live_overlay) |
basis.hourly_grid_charge_cover_kwh | Planned grid charge per cover hour (same indices as load/PV) — for plan-end “With grid” without time remapping |
basis.max_hours_horizon / basis.max_hours_horizon_effective | Cap for the full plan (hours_needed). Optional in the request — without override from this battery + charge power (need_h+12, max 168). effective = cap actually used. max_hours_today (default 12) is clamped to this cap. |
basis.storage_horizon | Planning meta only (autonomy, defer, cap). Annual €, size ladder, summer/winter cover days and savings_vs_* are null — those live on plant outlook. |
basis.charge_defer_enabled | true = multi-day arbitrage (global cheap hours beyond cover); large packs → higher effective horizon |
missing | What is missing (when incomplete) |
Cover vs dump vs bridge: If allow grid charging is off (grid_charge_allowed=false), all hours are 0 — regardless of cover/dump. Explain kind is then grid_off / reason grid_charge_disabled. Today 0 / week > 0 stays later — with-grid remains visible at the reserve stamp. With “cheap grid charging” (Dump/Opp) off and cover satisfied, hours_needed = 0. hours_needed_bridge only appears when Dump/Opp is on — no invented grid charge “just in case”. A positive cover shortfall gets the cover buffer (battery.cover_buffer_pct or profile, default 10 %). min_soc_pct / target_soc_pct must be 0…100 (else invalid_input); defaults 20 / 90. Set them in portal location / Sparvo Battery rules. Loxone can set the switch via telemetry ({"grid_charge_allowed":0|1}) and read the state on Extract O10 — see Loxone Hours.
{
"decision_status": "ok",
"decision_engine": "hours_demand_v1.11",
"prefix_hours_needed_today": 2,
"prefix_period_hours_today": 10,
"prefix_energy_charge_kwh": 4,
"charge_now": 1,
"charge_stop_soc_pct": 72,
"target_soc_pct": 90,
"capacity_kwh": 10,
"hours_needed_today": 2,
"hours_needed": 3,
"period_hours_today": 10,
"grid_charge_allowed": true,
"charge_power_kw": 4.5,
"discharge_power_kw": 9,
"discharge_reason": "recharge_planned",
"cheap_take_hours": 5,
"hour_is_cheap": false,
"cheap_hold": false,
"min_soc_pct": 20,
"adaptive_discharge": true,
"energy_charge_kwh": 4,
"hours_needed_days": [2, 1, 0, 0, 0, 0, 0],
"next_charge_start": "2026-08-17T19:00:00+02:00",
"next_charge_hours": 2,
"charge_hours_today": [
{
"start": "2026-08-17T19:00:00+02:00",
"end": "2026-08-17T20:00:00+02:00",
"price_ct_kwh": 6.1,
"energy_kwh": 2
}
]
}
In plain words: Charge 3 hours over the look-ahead; 2 of them remain from now today for the EMS. Grid-charge target 4 kWh (rounded up to a whole kWh). Period 10 is what a 13:00 Decide returns (rest until 23:00). Past hours of the calendar day do not count.
API vs. app: Decide/EMS use calendar days — hours_needed_today, period_hours_today, prefix_*, day_plan, charge_hours_today bucket by local 00:00–24:00 (Period = rest until 23:00). Only the Explain Following days card in portal/Sparvo uses PV days (morning sun→morning sun, rest_day_balances in the stored snapshot, not in the API response).
Portal / Sparvo — week glance
On Plant outlook (portal) and Sparvo Plant, Hours week glance shows the 7-day plan from the same shared engine (day_plan, default horizon 168 h). The EMS still only receives today’s hours — the week glance is transparency, not extra control. The Battery size & spot cost tile (including the Graz guest demo) uses the same reserve policy v1.8 (typical weeks, family B: Cap + port + predicate) — not a second engine. Personal € tiles stay noindex (/plant, /plant-outlook); discoverability is via Sparvo llms.txt and SpotpriceAPI docs.
The Grid charging card explains the plan as: current period (still X hours until morning sun/date-time) · in this period, planned grid-charge hours (with kWh) · then a list of later charges (date · day kWh · start times with kWh and session stop SOC per slot from soc_projected_end_pct; EMS O4 overrides the current prefix slot). “Minimum charge level” appears only in the ⓘ tip when the cover window is stretched past the first morning sun. Special cases without a cover plan (none / grid off / dump / bridge) keep chip + short copy. “No grid charging needed” only when the full 7-day plan is empty. Under Until tomorrow morning / Until this morning the night balance remains (load/PV/storage, without·with grid) stamped At HH:MM (first morning sun). Without grid in the UI means no extra charging into the battery; the backup reserve (min_soc_pct) stays protected in the simulation — the pack does not discharge below it; any shortfall is supplied directly from the grid. Following days lists PV days (morning sun → next morning sun) through forecast end. Between 00:00 and morning sun: the night only under Until tomorrow morning; first following day = today’s PV day from morning sun (not the night again). After morning sun / before midnight: following days from the next morning sun. Expandable: load/PV bars and without·with grid over the same sun→sun window; Usable battery · now (night card = live) or · At HH:MM (following day = level at PV-day start / morning sun) — fill of the usable band (capacity − minimum), not “full” just because kWh ≫ night load. When grid charge is planned on the tile’s calendar date, also Planned grid charge … · Effect at morning sun: +X % (hours listed as skipped are omitted; next-calendar-day overnight slots do not label the previous day’s tile). Without·with grid at the morning-sun stamp stays the real trajectory. ⓘ next to the morning-sun stamp explains without/with and the reserve. Last day may show forecast end if the 168h horizon cuts off before recovery. With grid stays visible, grayed when equal to without. The old More details tech block is gone. Week glance maps hours_needed_days onto the seven calendar columns (D0…D6); each day shows N h planned or kWh planned when charge slots exist — not “No charging” with a bar.
Load profile from the meter (short)
With regular Plant Telemetry:
1. After ≥1 full day of history: estimated day use from recency-weighted daily totals (recent days weigh more), WD/WE when ready. An incomplete today never counts as a full day (including for D+1). 2. Today: the estimate blends typical with meter-so-far using the WD/WE hour shape (not clock × 24). A hot morning must not zero remaining-today just because so-far already exceeds typical. Missing hour samples (None) are not treated as 0 kWh: sparse days (<12 hours) are skipped for the shape; never-seen hours get a flat prior — otherwise cover-window night load collapses to 0 and plan-end SoC looks unrealistically high. 3. One-off spikes (e.g. EV) are soft-capped (~2× median) so grid-charge planning does not overreact for a week. 4. Until enough history exists: fallback annual ÷ 365 (portal hint). Once a typical day exists we write typical × 365 (ceiled to 100 kWh) into the plant profile — portal, Sparvo, telemetry and Hours persist the same value (not display-only). 5. Cover sizing uses the gap until the next good PV (basis.cover_window_hours) — not the full week’s load. v1.9: short-term cover (today until morning sun) refines hourly load with partial hour and recent rate (last 2–4 meter hours); metadata basis.load_refine. Follow days stay on the typical profile. Cover slot rank (same on every calendar day): per-day price rank (1st/2nd/3rd cheapest chargeable hour), globally the cheapest day-heads until need is met — clock time does not block (e.g. 12:00 before 14:00 when cheaper and feasible). If a later cover hour on the same calendar day is already planned and cheaper, an earlier more expensive hour is dropped only when every hour strictly between them is already assigned or skipped (e.g. no Sat 08:00 right before 09:00; Tue 12:00 before 14:00 stays because 13:00 is still open). Feasibility: SOC headroom, port room charge_power_kw − PV, skip on pv_fills_charge_power / full pack. Port room per hour = profile charge_power_kw − PV (not a fixed 8 kW). Client load.hours[] bypasses refine. Before sunrise a 3-hour lookahead does not count as “already in PV”: the window ends at today’s first good hour (cover_window_hours_pv), not tomorrow 07:00. Interim looks for the next cheap hour only inside 24 h — not the cheapest slot of the week — and never in an hour whose PV already uses the full charge power. Cover, Dump and Prep in a PV hour only schedule the remainder charge power − PV (1 h: kWh≡kW; the EMS pulls only that delta from the grid) — including when the same hour is assigned more than once (already-planned grid kWh count; no fallback to pack headroom). Skip when PV saturates charge power (pv_fills_charge_power). After the picker: chronological clip — no further grid charge once Stopp-SOC is 100 % / pack full. If that first morning sun cannot hold min SoC through the following nights (typical weak weekend), the window stretches to the next real recovery (cover_extended_for_reserve), but not into the incomplete night stub at horizon end. Near vs far (v1.5): survival and hours until first morning sun only take the near need (basis.energy_cover_near_kwh, soft-cap morning SOC ≤ min+10 pp); stretch kWh schedule after that — no front-loading weekend reserve as a full charge-power hour today. v1.8: prep looks back from each gap day within carry \(H_{eff}\) (asis.carry_hours); lookback only orrat_wuerdig (P20 ∧ absolute guard); Cap below target SoC; Dump before further reserve; a source day already filled by PV without grid takes no prep kWh that day (including night). Far-cover must not use the same fill-day hours as a backdoor (only [trough−H_{eff}, trough]). Prep before every morning sun (not only the first night) must not lift morning SOC above min+10 pp; reserve fills only to the prep floor, not target SoC/100 %. Prep PV-days are morning sun→morning sun (brief midday dips are not a day boundary). An incomplete last PV-day clipped by the horizon with no next morning sun gets no \(L_D\); prior-day hours only when \(P_{ref}(D)\) is finite. Opportunistic fill (if enabled) only under your price cap within the look-ahead. With Dump off and cover satisfied: no grid-charge hours (and no bridge).
Typical battery flow
Often: Telemetry (SOC + meter)
Rarely: Decide (fetch a new plan)
EMS: Spot optimizer with hours_needed_today
Loxone step-by-step: Loxone Hours & Telemetry.
EMS integration (prefix vs. canonical)
The Decide JSON has two views of today's charge plan:
| Need | Prefix (Spot optimizer view) | Canonical (full plan) |
|---|---|---|
| Demand today | prefix_hours_needed_today | hours_needed_today |
| Period | prefix_period_hours_today | period_hours_today |
| kWh target | prefix_energy_charge_kwh | energy_charge_kwh |
| Slots | prefix_charge_hours_today (compact) | charge_hours_today (full) |
| Charging now? | charge_now or slot match | Slot match on charge_hours_today |
Extract v1.17: O1/O2/O9 from prefix fields (no loxone_*). O3 = relay (charge_now), O4 = charge_stop_soc_pct, O1 = 0 when O9 = 0. Txt3 = X Std, Y kWh. After 12 h without API: local fallback.
Optimizer vs. API slots
The API plans concrete slots; the Spot optimizer with Demand/Period picks the cheapest N hours — they may differ. Relay only from Extract O3. O1 goes to 0 when O9=0.
Non-contiguous plan: prefix_hours_needed_today may be less than hours_needed_today (island slots).
Common beginner mistakes
| Problem | Fix |
|---|---|
incomplete_input | Fill portal profile or send Cap/Pmax/SOC |
| Basic key | Register Community |
| Blindly shrinking the plan mid-day | Extract v1.17: O1=0 when O9=0 — relay only via O3 |
Using horizon_hours as optimizer Period | Period is remaining hours until 23:00 (23:00 → 24); Extract v1.17 maps prefix_period_hours_today |
| Past hours as today’s Demand | Slots start from now; hours_needed_today=0 means nothing left today |
| kW × hours as kWh | energy_charge_kwh is the ceiled target; Extract O3 |
Day-ahead replan (EMS / webhook)
After day-ahead + inference (~13:00–16:00 Vienna), Spotforecast replans all active plants internally (mode=decide, snapshot + optional webhook plant.plan_updated).
In addition, an SOC ping near min SoC (10 pp / 5 pp / floor) or on recovery (mirror steps back to headroom) runs the same internal Decide asynchronously (snapshot + webhook). Sparvo/Portal already resolve live; Loxone Extract follows on the next Hours Decide (:00 / SOC band / I6).
| Topic | Behaviour |
|---|---|
| Webhook payload | Compact: charge_hours_today only — pull the full plan via GET /v1/decision/hours-demand |
plan_hash | Optional in the event — dedupe against your last pull |
| Materiality | Push fires when tomorrow-only slots change (full plan counts) |
| Portal explain | Live resolve — may differ from snapshot until the next page load |
Next steps
- Plant Telemetry
- Loxone Hours
- Webhooks — push when the plan changes
Register for Community or code samples with your personal key.