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

ModeWhat happensAnalogy
decide (default)Balance + charge planready proposal
telemetryStore SOC + meter onlystatus 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 without battery.max_charge_kw in 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

FieldPlain meaning
decision_statusok = use the plan; else reason
prefix_hours_needed_todayEMS: contiguous prefix today — Extract O1 (0 when O9=0)
prefix_period_hours_todayEMS: period for prefix — Extract O2
prefix_energy_charge_kwhEMS: plan kWh for prefix — basis for O9 (remaining)
charge_now1 when current hour is in prefix (after Decide)
charge_stop_soc_pctSession 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_pctProfile target SOC (%) — not the same as stop SOC
capacity_kwhBattery capacity for SOC-delta O9
prefix_charge_hours_todayCompact array (start + energy_kwh) for Extract slot match
hours_needed_todayPortal/Sparvo: hours still today in the Period window (until 23:00)
period_hours_todayEMS: 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_neededFull 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_kwhGrid-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_daysAlways 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_hoursNext remaining slot (from now) plus hours on that calendar day.
grid_charge_allowedTop-level (EMS): master switch true/false — Loxone Extract O10. Same value in basis.grid_charge_allowed.
energy_deficit_kwhNeed after placement (plan_feasibility.energy_requested_kwh, leftover prep excluded). Sized prep floor stays in basis.forward_prep
charge_power_kwAvailable grid-charge power in kW — Extract O11 (not the raw grid connection rating)
discharge_power_kwDischarge 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_pctMinimum SoC (%) — Extract O13
adaptive_dischargetrue = reserve-protect near min SoC when recharge is not worth it; false = fixed max_discharge_kw. Cheap-hold only when cover is unsatisfied
discharge_reasonWhy 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_hoursN = 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_cheapcurrent local hour is in that set
cheap_holdtrue 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_bridgeBreakdown: cover · dump (opp) · reserve (prep) · bridge — picker placement cover→bridge→dump→prep (sizing ≠ placement, see concept §14.6)
prep_extra_energy_kwhPlaced prep kWh (slot_role=prep, often 0). Sized floor stays in basis.forward_prep
charge_hours_today / charge_hoursToday 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_feasibilityok / 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 / survivalPer-slot explain (cover/prep/opp, SoC before/after). Portal/Sparvo: economical cover hour · reserve hour · hold minimum charge level · opportunistic fill · bridge charge
day_planPer calendar day: N + starts (Portal/Sparvo week glance)
charge_hours[].dump_risk_pct / dump_scenario_ct_kwhFrom 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_kwhCover 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_hoursReserve 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_notesv1.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_allowedMaster switch from the plant profile (default on). Off → all hours_needed* 0, empty slots, charge_kind=grid_off
basis.pv_fill_lookaheadLook-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_kwhHourly 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_refinev1.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_decideOptional: decide-time values when live SoC is mapped onto “Now” after telemetry (soc_live_overlay)
basis.hourly_grid_charge_cover_kwhPlanned 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_effectiveCap 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_horizonPlanning 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_enabledtrue = multi-day arbitrage (global cheap hours beyond cover); large packs → higher effective horizon
missingWhat 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 dayshours_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:

NeedPrefix (Spot optimizer view)Canonical (full plan)
Demand todayprefix_hours_needed_todayhours_needed_today
Periodprefix_period_hours_todayperiod_hours_today
kWh targetprefix_energy_charge_kwhenergy_charge_kwh
Slotsprefix_charge_hours_today (compact)charge_hours_today (full)
Charging now?charge_now or slot matchSlot 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

ProblemFix
incomplete_inputFill portal profile or send Cap/Pmax/SOC
Basic keyRegister Community
Blindly shrinking the plan mid-dayExtract v1.17: O1=0 when O9=0 — relay only via O3
Using horizon_hours as optimizer PeriodPeriod is remaining hours until 23:00 (23:00 → 24); Extract v1.17 maps prefix_period_hours_today
Past hours as today’s DemandSlots start from now; hours_needed_today=0 means nothing left today
kW × hours as kWhenergy_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).

TopicBehaviour
Webhook payloadCompact: charge_hours_today only — pull the full plan via GET /v1/decision/hours-demand
plan_hashOptional in the event — dedupe against your last pull
MaterialityPush fires when tomorrow-only slots change (full plan counts)
Portal explainLive resolve — may differ from snapshot until the next page load

Next steps

Start with SpotpriceAPI

Register for Community or code samples with your personal key.

Live API status