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.23. Open API for HA/Python; Community 200 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 battery can still carry the kWh; Dump sizing before further reserve, placement near-cover → softcap-abandon-spill (flag/Darf-S1) → prep → far-cover — 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 together with profile max grid charge (default 8 kW): O11 /
charge_power_kw= min(inverter, connection × 0.80 − base load, max_grid_charge_kw) (base load = annual/8760 if no explicit field). Example: 12 kW connection + ~17 900 kWh/year → ≈ 7.6 kW (not 12; under Cap 8). Thin Decide withoutbattery.max_charge_kwin the body; otherwise the request caps inverter power — not the profile Cap. - 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 sweet-spot sizing live on plant outlook only. Optional battery.grid_surcharge_ct_kwh (0…50): supplier surcharge for display costs only (request → profile → 1.5 ct/kWh).
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 and not latched (live ≥ stop → 0 until live ≤ stop−H; exception: live < min_soc−0.5 pp → server opens latch rearm_below_floor for survival top-up; H = profile charge_hysteresis_pp clamp 1–20 pp, default 2; EMS field charge_stop_hysteresis_pp). Engine v1.24+ |
charge_stop_soc_pct | Stop SOC at prefix end (%) — Extract O4. SoT from floor(soc_projected_end_pct) after resolve refresh; no live+energy invent (missing projection → null, latch may sticky prior). Survival / min-SOC top-up: reserve floor (min_soc_pct) only when live SOC is below that floor; otherwise prefix-end trajectory / session stop (never a stop below live SOC). target_soc_pct = profile ceiling, not O4. Explain/Sparvo: on the stamped prefix charge segment, timeline end (soc_pct in ~a %→~b %) is the same int as stop SOC / O4 — one target. |
charge_latched / charge_stop_hysteresis_pp | v1.24 additive (EMS prefix + snap): sticky latch / hysteresis pp (stop−H). Below reserve floor (live < min_soc−0.5) Decide opens the latch (rearm_below_floor) for survival top-up; above floor unchanged hold until stop−H. Missing stop/SoC or live/stop/min_soc as JSON bool while prior latched → incomplete_hold / floor-rearm skipped (keep latch; never float(True)=1). Client numbers (load/pv hours, energy_next_24h, constraints max_hours*) and stop writers (sticky republish / snap / webhook / material key / timeline) likewise fail-closed. Kill HOURS_CHARGE_HYSTERESIS=0. Overlay display-only (no private charge_now 1→0). O12 while latched: no recharge_planned full cap — even if charge_now is wrongly 1 (fail-closed); Hold Cap + Adaptive remain |
| Lookback-Thin | v1.31: after Lookback, drop-first/relocate only lookback-eligible kWh (incl. under reserve_breach; Weak/Cover siblings frozen separately; merge lookback_added_kwh freezes absorbed cover kWh, coalesces same-hour siblings by parsed hour (naive→map/start0/Vienna tz) to absolute map Sigma (Cover Soft-Cap floor; str/isoformat/naive safe; omitted/0 map hours dropped except interim pin at 0 kWh; lookback=True only if Δ>ε), and keeps survival against Defer undo; Mixed cover_merged_kwh frozen/movable split and drop/relocate via split_cover_merged_on_relocate (floor ≤ energy); rebuild keeps floor and weak_pv on peelable movable; Soft-Cap stamp probe softcap_stamp_holds plan-level Cover/Mixed absolute / Weak-only Natural; Familie B Soft-Cap Natural only when Weak overlay filled with no Cover fill and no Lookback movable; week_had_* carried across PP forced re-sim; merge coalesce keeps Cover Soft-Cap floor and MUST/interim/spill/survival/freeze slot_role (prep/opp/bridge/dump + non-cover e.g. floor) from siblings; MUST sets charge_kind=must; absolute map drops unparseable/out-of-window non-spill; non-finite/JSON-bool sibling kWh → 0; Soft-Cap identity/peelable/trim (every exit incl. early-hold scrub + in-horizon soft_rel=None scrub)/softcap_stamp_holds/in-horizon map/LThin map+lookback_added/resolve Soft-Cap-adjacent charge maps/Econ-PP base+frozen/Soft-Cap spill+clip/same-day maps likewise; relocate-split/cover_merged likewise; day-consolidate _energy likewise); skip when lookback_applied=false; stress-delta conjunction; battery headroom+port like Lookback-Place; stress/s0 pass target_soc (clip parity); dest price math.isfinite (−inf/NaN skip); Soft-Cap stamps = Weak defer ∪ Near∪Dawn∪each sun→sun plan.hi, also Weak-off / Familie-B rebuild; own commit before Thin-Cover-Defer; residual note and lookback_reason_codes only when \(S≠∅\); fields lookback_thin_dropped_kwh / lookback_thin_moved_kwh. Kill HOURS_LOOKBACK_THIN=0 (default on). Decide + Familie B |
| Thin-Cover-Defer | v1.31 (feature since v1.30): after Lookback-Thin, relocate-only non-spill Cover/Weak onto a cheaper hour in Near or Weak [lo,hi] (residual-aware carry + port + battery headroom min(cap[,target])/η + partial port incl. R2/_partial_port_take_ok); dest price finite (−inf/NaN skip); dest avoids Spill ∪ Cap-Skip; freeze spill/lookback/survival/MUST/reserve_breach/latch; incomplete horizon → carry refuse; Soft-Cap stamp probe before dest (Cover/Mixed absolute via softcap_charge_rows_weak_only / Weak-only Natural, consolidate parity; stamps = Weak defer ∪ Near∪Dawn∪each sun→sun plan.hi, also when Weak is off) + post-move Soft-Cap check → revert + Weak trim (trim Σ-guard → revert, Neighborhood parity); Mixed cover_merged_kwh split/carried on relocate (else Soft-Cap Natural reopen); same-flag sibling merge; probe fail without a move: note thin_cover_softcap_dest_skip / Why; post-move Soft-Cap revert: note thin_cover_softcap_revert / Why; one commit with Thin-Now; Soft-Cap not reopened. Kill HOURS_THIN_COVER_DEFER=0 (default on). Decide-only |
| Thin-Now | v1.24 / carry heal v1.31: before plan_roles, drop only slot_role=cover Now if the battery carries residual-aware to a cheaper later today slot (net drain > room above floor → carry_fail; incomplete horizon → carry_fail); never Prep/Opp/Survival/MUST (reserve_breach); v1.25: never drop Now with softcap_spill (spill/Darf-S1); Hold rebuild fail → abort drop + restore prior Hold. Kill HOURS_THIN_NOW=0 (default on) |
| Neighborhood Rebalance | §6.8.18 Decide-only: after Thin-Now, forward-move expensive Cover/Weak to a later cheaper hour within radius default 18 h (clamp 4…24, HOURS_NEIGHBORHOOD_RADIUS_H); Soft-Cap stamp probe (Cover/Mixed absolute via softcap_charge_rows_weak_only / Weak-only Natural) + post-move Soft-Cap→revert (+ Weak trim); Mixed cover_merged_kwh split/carried; same-flag sibling merge; stamps = Weak defer ∪ Near∪Dawn∪each sun→sun plan.hi, also when Weak is off; dest avoids Spill ∪ Cap-Skip ∪ Hold; freeze like Thin-Defer; orthogonal to Econ-PP (backward) and Thin-Defer (same \(W\)). Kill HOURS_NEIGHBORHOOD_REBALANCE=0 (default on, Dual-Gate User-Go). Familie-B follow-up |
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 imminent bridge charge is needed (interim_period_active, slot h < 5) — deferred interim does not shrink the EMS period. 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 |
softcap_abandon_spill_applied / softcap_abandon_spill_placed_kwh / softcap_abandon_spill_skip | v1.26 default on: Soft-Cap abandon spill after Near Soft-Cap abandon (Recovery\ |
darf_softcap_s1 / darf_softcap_s1_placed_kwh | v1.26 HOURS_DARF_SOFTCAP_S1 default on: Darf Near budget=0 → same S1 spill cohort (Gate OR). reason_codes may include darf_softcap_s1. No S0 fallback on €-cap fail. Thin-Now does not drop softcap_spill Now. Interim ¬breach → darf_softcap_s1_skip_near. Kill =0 → Near-only |
charge_hours[].softcap_spill | Slot from Soft-Cap abandon spill (slot_role=cover; not Lookback-exempt; day-consolidate/rebalance/lookback-place fixed; Prep/Opp/Far no top-up on spill index; Weak apply keeps flag; Thin-Now and Thin-Cover-Defer keep/do not move spill) |
lookback_applied / lookback_passes / lookback_stress_remaining_kwh / lookback_placed_kwh / lookback_thin_dropped_kwh / lookback_thin_moved_kwh / lookback_placed_effective_kwh | v1.23: Lookback-Rebalance after Hold. v1.26: late-bias default on. Soft-Cap before Lookback; lookback=True peel-exempt. Kill HOURS_LOOKBACK_REBALANCE=0. Passes default 4. v1.31: Place battery headroom + Lookback-Thin; kill HOURS_LOOKBACK_THIN=0. v1.36: Dawn-Scope O2 first contiguous stress cluster + O5b Far-JIT 36 h (HOURS_LOOKBACK_DAWN_SCOPE, kill =0 → pre-heal identity); Fill-Day survival block on fill PV-days unless S_work ⊆ same sun→sun (HOURS_LOOKBACK_FILL_DAY_BLOCK, Decide + Familie B); Far Soft-Cap cohort + natural probe; day-stamp probe opens a hour at least 5 €/MWh under the night without extra curtailment (HOURS_LOOKBACK_FAR_SOFTCAP_GUARD; Soft-Cap stamps = Weak defer ∪ Near∪Dawn∪each sun→sun plan.hi; Familie B dawn_cap=None → guard no-op). Top-level Decide echo (additive): lookback_placed_kwh (place Σ), lookback_thin_*, lookback_placed_effective_kwh = Σ charge_hours[].lookback=True (final SoT; not place−drop). Snapshot parity. reason_codes: lookback_rebalance / lookback_residual_stress / lookback_fill_day_block / lookback_far_softcap_guard |
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.23 (O9 headroom to session stop/O4; latch clears rest): O5 = PulseSessionStop; O6–O8 free (portal/week glance). UI week glance: index = calendar day D0…D6 — not the index in the sparse day_plan list. A row with 0 kWh is not a planned hour, does not set charge_now, and is not the next charge. An older snapshot is counted the same way in the display, including the plant-page week glance, day chips, and explain clocks. The mapping starts on the Vienna issue day. A clock with no energy field stays a charge hour. |
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); formula min(inverter, connection×0.80−base, max_grid_charge_kw) default Cap 8 (hours_demand_v1.36) |
discharge_power_kw | Discharge cap in kW — Extract O12; missing → PicoC fallback 9. Surplus-Cap Hold (v1.20): cover gap → off-peak deficit before peaks in Hold with load-weighted slot Cap (hold_share / pack_reserve 0…0.2); Now ∈ Hold-Set → O12 = slot Cap — no second cover gate. SoC≤min in Hold → O12=0 (reserve_empty) before Cap. Peaks (evening/morning) are not in the hold and follow the same ramp. Near-autarky: no hold in \(H_{gate}\); follow-day hold/Weak ok. Not when the battery is full (≥99 %) and not in a PV-surplus hour. A later reserve-stretch gap alone is not enough. While actively charging (charge_now and not latched/grid-off) full cap only from the curve top; below that, the ramp. At or below the minimum, discharge is 0 even when PV surplus or charge_now skips the hold. Inside a taken hold above the minimum the slot cap stays. Latched/grid-off → no Vollcap (even with stale charge_now=1), Hold Cap still applies when Now ∈ Hold. Adaptive otherwise only outside Hold: full cap from the curve top (min plus 15 % of the band above it; at a 20 % floor that is 32 %), and below that the discharge falls as SoC falls and rises as SoC rises, including when a later or active charge is planned. 0 at or below the minimum, including when a later charge is planned, or when the user cap is 0 |
min_soc_pct | Minimum SoC (%) — Extract O13 |
adaptive_discharge | true = reserve ramp below the curve top, including when a charge is planned; 0 at or below the minimum; false = fixed max_discharge_kw. Independent of the peak-battery hold set |
discharge_reason | Why O12 is this value: cheap_hold · hold_share · pack_reserve · 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. Price signal / legacy cheap-clock only — not the surplus-Cap hold set (hold_hours) |
hour_is_cheap | current local hour ∈ cheapest N (cheap_take_hours) — independent of hold membership. Expensive off-peak hold hours may be false while O12 is still the slot Cap |
cheap_hold | true only when discharge_reason=cheap_hold (Weak/legacy ≈1 kW). For hold_share/pack_reserve = false |
hours_needed_cover / hours_needed_prep / hours_needed_opp / hours_needed_bridge | Breakdown: cover · dump (opp) · reserve (prep) · bridge — picker placement near-cover→softcap-abandon-spill (flag/Darf-S1)→prep→far-cover→bridge→opp (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). v1.31 heal: accept trough/emit load-first; clip upper=min(cap,target). Response shape unchanged, no new required field, discharge_reason / O12 unchanged. start/end are ISO strings — internal datetime clocks (lookback/survive) are converted before the week glance, the snapshot, and JSON. The week glance still counts kWh when the clock text uses a space |
plan_feasibility | ok / partial / no_feasible_slots; storage_undersized when capacity blocks scheduling; Soft-Cap/Dawn abandon in energy_abandoned_cover_kwh (not undersize) |
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 (owner-only). Why-Details: /plant/hours-why or /plant-outlook/hours-why (shared-user like Explain). Full skip list: hour line without “next charge”; once per day a separate “Next planned charge” bullet (or “no further grid charge”). pv_will_fill_today: skip gate in grid kWh (pack_leftover/η ≤ min_headroom) — Cover/Lookback parity |
charge_hours[].soc_projected_* / slot_role / survival | Per-slot explain (cover/prep/opp, SoC before/after). Portal/Sparvo: low-price charge hour · reserve hour · hold minimum charge level · opportunistic fill · bridge charge · grid charge (weak PV) |
day_plan | Per calendar day: N + starts (Portal/Sparvo week glance); from v1.14 also hold_hours[] (hold / battery-reserve slots, including hold-only days) |
hold_hours | Horizon hold slots (start, hour_local, discharge_cap_kw, reason=hold_share` |
direct_grid_hold_kwh | Estimated grid import during hold hours (load − PV − slot Cap; Cap = discharge_cap_kw / HOURS_HOLD_KW or battery-reserve 0…0.2) |
basis.reserve_breach / basis.econ_charge_ok / basis.charge_kind | v1.16: MUST = reserve breach; Darf = cheap third; weak_pv = Rentable Day-Fill (sun→sun, η-gate); charge_kind ∈ must/darf/weak_pv/interim/prep/opp/none. End users: never say “P33” — “cheap third” only when darf; weak PV: “grid charge when, after efficiency, cheaper than expensive evening grid” |
basis.week_status | v1.15+ Copy B: lines only for plan days (Hold/Charge/Charge+Hold) — Autark/idle/no_hold_gap/opp_* omitted from the list (days[] keeps all). Heading Thu 10.09.: (weekday + dd.mm.). Hold: one clock list with HH:00 X,X kWh or no import · total = displayed 1-dp kWh; wording Battery conserve / no battery charge from grid. hold_direct only when display total > 0 (micro-kWh dropped). Idle with residual → no_hold_gap (beats near no_charge_covered/interim_pass, not opp_*). why_code includes autark, no_hold_gap, hold/hold_direct, charge_*, no_charge_covered, grid_off, interim_pass, opp_off, opp_not_triggered. Hold with no import = battery conserve (slot Cap HOURS_HOLD_KW or battery-reserve HOURS_PACK_RESERVE_KW). Covered beats interim-pass hint. Every Decide recomputes over [now, now+H≤168). No grouped “Grid import:” line. Portal/Sparvo: Explain uses two folds (grid charging + until-morning/following days) with plan_timeline in the charge fold (API week_status field remains); week glance: short teaser echo; day-chart tip Charge > Hold |
basis.weak_pv_active / basis.weak_pv_dp | Weak-PV mix active; DP only after DA replan |
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 / cover_recovery_ok / cover_dawn_cap_hours / energy_cover_dawn_kwh (v1.13: recovery + dawn cap at hi(next_pv); response cover deficit is Dawn-clipped) |
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 near grid charging (cover/prep/dump/bridge = 0). Weak-PV (§6.8.15) may still charge after \(H_{gate}\) (no week-wide skip). Exception: cover_extended_for_reserve → stretch cover (stretch_reserve_outside_gate). Dual contract: engine = sun→sun; day_plan = calendar 00:00–23:59; \(H_{gate}\) never midnight. autark_notes: gate_h:… or trough hint. 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, charge_now=0. Server immediate; EMS immediate only with plant.plan_updated (else next Hours Decide). Also on incomplete_input / invalid_input (fail-closed false when unknown) — EMS prefix does not silently default to true |
basis.hours_control_paused / pause_reason | Cold-start / warm-up (hours_demand_v1.36 Dual-Cycle): true until the load profile is ready (meter warming or annual-only <72 h). Meter ready under Dual (HOURS_COLD_START_DUAL_CYCLE default on): calendar day ≥18/24 load hours and first completed PV day sun→sun (load fraction ≥50 %; PV meter densified or geo hist PAST_DAYS=7 cache_only — never Open-Meteo refresh from Dual Decide). Kill Dual=0 → Typical-only; total kill HOURS_COLD_START_PAUSE=0. OK publish with empty charges/holds, charge_now=0, latch false, charge_stop_soc_pct: null (snap+webhook explicit), O12 max/at_min (+ EV-flex ev_charge_block/ev_charge_limit), charge_kind=none, week_status why_code=cold_start, load_covered_by_soc_pv=false, Outlook/empty_reason=cold_start (week glance D0 Warm-up; Explain chip/headline Warm-up, not Covered/chip_none); pause ∩ grid-off → pause wins (reason_codes=cold_start_pause, UI charge_kind=none, week_status why/empty_hint/groups → cold_start never grid_off/hold/covered — display strip follows top-level/basis hours_control_paused, not sticky notes alone; pause with grid still on: same mute without inventing grid_off; no with-grid SoC; Outlook snapshot-first and live-hydrate force hours_needed*/energy_charge_kwh/next_charge_* to 0 fail-closed). Grid-off only: half-healed status=charge with hold evidence → Speicherschonen (hold), not autark. pause_reason: cold_start_meter_warm \ |
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 €, sweet-spot sizing and savings_vs_* are null — those live on plant outlook (payback hardware there is 160 €/kWh net × 1.20, installed). The annual average there is grid charge plus other grid import from the same run. The level follows the 90-day spot regime (70–150 % of the 12-month mean); the 12-month and 90-day average chips stay the raw spot windows. Kill STORAGE_HORIZON_PRICE_REGIME_SCALE. (hybrid UI + Outcome Guard: PV-only / with-grid-charge toggle, dual-annual costs, title/cap/€ from display_outcome_*, kill STORAGE_HORIZON_OUTCOME_GUARD default on — see plant-profile concept). Fixed-tariff tile (E-Control, form) is Portal/Sparvo outlook only. The query uses residual grid import in 100 kWh steps, not annual consumption. The energy price is stored only when it is below the all-in price in the cent column; a year total in euros is not stored as ct/kWh. The branded tile says that age, new-customer and promotion conditions are on the supplier page. The stored tariff leaves out an age limit, a consumption cap under 100,000 kWh/year, and a new-customers-only offer, and only inside that product’s own text. The company home page does not count. A 12-month price guarantee, the band up to 100,000 kWh, and a new-customer bonus stay. If a finished scan finds no eligible fix, that step shows 11.4 ct without this note. A failed scan keeps the previous row. Kill ECONTROL_TK_HOUSEHOLD_ONLY=0. A missing energy price, all-in price, or supplier fetches that postcode at once, including inside 23 h, and not a second time while that scrape is running; the tile then says it is being read instead of 11.4 ct. The daily fetch reads at most one step per hour, at an hour fixed from the postcode and the step. The outlook cache (120 s) re-reads that row on the next request. bis einschließlich N Jahre from 18 and N- bis M-Jährige are an age limit, including next to a price guarantee. The cent list is grid usage, grid loss, the grid standing charge, metering, and the levies. The energy standing charge is the €/month line. The all-in price is the year total for the step. The comparison chip stays energy versus dynamic. Energy and standing charge are net; gross only when the prices-page VAT toggle is on (expand: VAT hint + supplier link changeLink.link) — Hours Decide does not call the tariff calculator (compare import fail-closed for finite env/ct; opt-out is outlook only). Outlook payload (not Hours Decide): sweet_spot_pv_kwh / sweet_spot_bridge_kwh / recommendation_mode + dual-annual annual_grid_cost_{pv,bridge}_eur + display_outcome_{pv,bridge} (kill STORAGE_HORIZON_DUAL_ANNUAL default on); active bridge recommendation host-wide with STORAGE_BRIDGE_SWEETSPOT=1 (User-Go 2026-09-17); code default remains off. |
basis.charge_defer_enabled | true = multi-day arbitrage (global cheap hours beyond cover); large batteries → higher effective horizon |
missing | What is missing (when incomplete) |
Weak-PV / Rentable Day-Fill (v1.16)
On weak-PV days (load−PV until next morning sun exceeds usable storage) the engine plans day-by-day across 168 h (Rentable Day-Fill from hours_demand_v1.16, Surplus-Cap Hold from hours_demand_v1.20 — kill HOURS_HOLD_CONTINUOUS_CAP=0 → Cap0/Liberate as v1.18; PV Cap-Skip from hours_demand_v1.27, audit v1.28/v1.29): grid charge only when spot/efficiency beats later expensive direct buy (p/η < p_ersetzt − ε), target = sun→sun deficit (need_full = sum of positive net hours; surplus does not shrink that deficit). Cap-Skip: if PV-only (no Hold) reaches Cap before evening without residual import → need_full=0, no weak grid charge (note weak_pv_need_full_pv_cap_skip; kill HOURS_WEAK_PV_NEED_FULL_PV_CAP_SKIP=0 → legacy need_full fill; from v1.28 no DA DP after Cap-Skip; from v1.29 Lookback blocks Cap-Skip windows). Otherwise assign in W price-first under band ∩ η ∩ port>ε (SoC roll; net≤0 does not block grid when port>ε), hold/conserve even without charge (hold_share/pack_reserve/cheap_hold), Soft-Cap after inject (Natural-Baseline max(min+10 pp, natural[stamp]) — peels only charge that raises SoC above the no-grid path; Cover Soft-Cap stays absolute; fail-closed trim from plan.lo incl. plan.hi/Dawn; post-apply stamps = Near/Dawn ∪ Weak plan.hi; Peak-Cap refresh always after final fill incl. DP/\(H_{gate}\)/Soft-Cap — also empty fill after gate wipe + SoC resim day→day; Hold energy map always a dict ({} = missing key 0)). Cap0/fill_before: when charge_kwh_by_index is set, only mapped kWh (missing key = 0). Familie B: same Natural Soft-Cap at sun→sun end (no Dawn kill-gate; peel only calendar h < hi, post-midnight deferred + end_soc resim; Cover/Prep wipe only when calendar Weak existed) + Cap refresh + Cover/Prep energy map. Relocate: Weak-only Soft-Cap check = Natural; Cover/Mixed absolute; Mixed weak_pv budget splits the last hour (Cover remainder without weak_pv, sibling rows share start; hours_needed/EMS prefix = unique starts; compact EMS merges sibling energy). Near-autark_ok does not skip Weak — filter i < H_{gate}, then resim, then Soft-Cap with Dawn/> H_{gate} (Near stamps ≤ \(H_{gate}\) excluded). Redundant peel (default on): weak grid kWh that do not cut house import inside the same sun→sun window are dropped (typical case: the port remainder on a sunny hour). The measurement uses the served hold cap, not a full discharge. A storage purchase that does not cut later house import in that window is dropped. A purchase that still covers a later import stays. Necessary cheap hours before that stay. Holds are replanned from the remaining fill. Kill HOURS_WEAK_PV_REDUNDANT_PEEL=0. Forward rentability (default on): same measurement, scored by price — the charge stays only when the avoided house imports are more expensive than the purchase after efficiency (ε 3 €/MWh, no markup). A flat price tail after an already covered evening is dropped (note weak_pv_forward_unrentable; kill HOURS_WEAK_PV_FORWARD_RENT=0). A neighborhood move that shrinks that rentable remainder is reverted (neighborhood_forward_rent_revert). API slots stay calendar day 00:00–23:59. charge_kind=weak_pv only when Weak-PV charge hours were placed. Hold-only windows do not strip Darf cover; EMS hold_hours exclude concurrent charge hours. Optional DP polish only on the existing day-ahead replan (allow_weak_pv_dp; kill HOURS_WEAK_PV_DP), η-filtered and clipped to need_full. PicoC unchanged (live O12 / charge_now only). max_hours_today default 12 stays — horizon still plans fully; EMS/today slice sees at most 12 today slots. Applies to all plants/keys.
Econ-Ping-Pong (default ON): Weak kWh only. Plain cover, the weak remainder above cover_merged_kwh, and the lookback delta move to an hour at least 5 €/MWh cheaper inside the same sun window (HOURS_COVER_EARLY_PLACE, default on; morning sun to the next morning sun), not before the current hour. In the annual cost simulation, a dark stretch before the first sun does not outrank a later hour of the same day that is cheaper or the same price. A dearer later hour does not jump ahead of that night. That grid charge stays only when, after round-trip efficiency, it beats the prices it can still avoid before the next sun or in the rest of that day (ε 3 €/MWh); otherwise the house imports directly. The EMS plan does not use this order. When a weak-PV plan takes the day, that plan's order applies. The chart stays on the calendar day. In the week cost, only the lookback delta moves, scored on the full charge map. A frozen row already on that hour stays; the moved kWh sit beside it while the grid port is free. The chip “no earlier legal cheaper weak-PV charge hour” is emitted only by the weak pass. After Weak mix, necessary Weak kWh may be moved to earlier cheaper slots (move≠add; η/RENTABLE_EPS; Soft-Cap ceiling = plan target, never Cap; dest ∉ Cap-Skip/Occupied/Hold/Near-gate — Familie B: prior Near window also Occupied; forced apply keeps intended per-hour caps). Stamp probe: Cover/Mixed absolute / Weak-only Natural (softcap_stamp_holds, frozen Cover in port/SoC/stamp — Thin/NB parity); unsafe dest → note weak_econ_ping_pong_softcap_dest_skip only when no successful move (else drop like Neighborhood); peel after move → revert remains the safety net. Cover merge: post-apply Soft-Cap peels Weak remainder only (cover_merged_kwh floor) and picks mode plan-level via softcap_charge_rows_weak_only (Weak-only Natural / Cover/Mixed absolute, never always-Natural); peel-exempt lookback/survival/MUST (softcap_may_peel_row). Day-consolidate Weak budget = peelable Weak only (softcap_weak_peelable_kwh, floor excluded; Cover crumb < min_head keeps cover_merged_kwh → Mixed absolute); Soft-Cap gate always (Cover-only Soft-Cap break → reject; Soft-Cap already broken → Cover Soft-Cap-worsen pre-stamp Σ↑ → reject; survival delta may Soft-Cap-breach). Soft-Cap demote clamps cover_merged≤energy. Soft-Cap SoC path: roll_soc_hour/forward_soc_path/stamp/Lookback/O4/plan_roles/NB+Thin residual/Weak Soft-Cap end_soc resim/Econ-PP Soft-Cap SoC/Soft-Cap-Spill € scrub JSON-bool/NaN→0 (no float(True)=1). Kill HOURS_WEAK_EARLY_PLACE=0. Decide + Familie B. Survival Lookback (14b) stays separate.
Neighborhood Rebalance (default ON): After Thin-Now, expensive early Cover/Weak kWh may move to a later cheaper hour within radius \(R\) (default 18 h) (move≠add; residual-aware carry; Soft-Cap stamp probe Cover/Mixed absolute plan-level / Weak-only Natural + post-move Soft-Cap→revert). Orthogonal to Econ-PP (backward only) and Thin-Defer (same \(W\)). Kill HOURS_NEIGHBORHOOD_REBALANCE=0. Decide-only v1; Familie-B follow-up.
Explain plan_why_chain: Portal/Sparvo show citizen chips from Decide notes (autark; ¬Darf only ¬autark∧¬weak; Cap-Skip; forward rentability; PP move/revert/dest_skip — dest_skip only without a move; Lookback-Thin; Neighborhood move/softcap_dest_skip/revert/carry; dual-contract only D0=0∧later>0). Additive UI payload — PicoC unchanged. Decide top-level Thin echo: lookback_placed_kwh / lookback_thin_* / lookback_placed_effective_kwh. Explain UI: two independent folds — grid charging (plan/why) and “Until tomorrow morning · following days” (night + following days), both collapsed by default. Why-Details (Phase 1 + Lite): sibling “Details” link next to the week teaser → Sparvo /plant/hours-why · Portal /plant-outlook/hours-why?api_key_id= — snapshot narrative (use_live_resolve=False), shared-user access like Explain (not owner-only like Skipped-Hours); kill HOURS_WHY_DETAILS (default on). Hero/role colors/FAQ; per-segment later_code (start-SoC floor; Soft-Cap hint without double later; softcap-move/no_legal_later + dedupe); citizen conserve/η copy. CSS pin hx30. data-why-details arms on kill+href (incl. shell); visible after SSR//data hydrate when available. No re-Decide, no counterfactual; PicoC/Decide JSON unchanged.
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 %). Separate from that: relay charge hysteresis battery.charge_hysteresis_pp (default 2, clamp 1…20) → EMS charge_stop_hysteresis_pp / sticky latch (kill HOURS_CHARGE_HYSTERESIS=0). Cover/Bridge/Survival/Lookahead/Lookback/Consolidate/Interim/Spill/Rebalance plan under ceiling min(cap, target_soc). 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.
Planned house grid draw + supplier surcharge (since v1.19)
Since hours_demand_v1.19 (roles/Explain polish under engine hours_demand_v1.36): after final surplus Cap/Hold, Decide labels residual hours as planned house grid draw only when the hour has no battery charge, spot ≤ P33(calendar day), and spot ≤ p_ersetzt without efficiency factor (house buy ≠ storage charge). Explain grid-charge tile shows calendar-day roles (grid charge / conserve / PV + storage / empty min / house draw) with foot X/Y/Z (spot costs only for X/Y/Z; payload spot_cost_eur + tariff_markup_ct_kwh). Supplier surcharge (grid_surcharge_ct_kwh, default 1.5 ct/kWh net excl. VAT; resolve: request → profile → default; 0 stays 0) is the amount — whether it (and 20 % VAT) appears in Portal/Sparvo is controlled by account prefs display_markup_on/display_vat_on (default off = exchange net; guest/admin: browser localStorage). Public Decide/Hours JSON never includes prefs flags. Not in η/P33/Weak/Direct gates. HOURS_ECON_LEAD_BAND (default on, user go 2026-10-02): basis.notes includes econ_lead_band. Point price price_eur_mwh stays the model price. Kill =0 restores the previous price order. The bridge target hour counts only through D+2 when horizon days are supplied. Storage plan v1.38 (default on, all plants): decision_engine is hours_demand_v1.38 while any storage flag is on. Hold keeps the battery for later peaks, moves discharge onto dearer hours (move ≠ add), and may conserve a cheap night into the next morning across the full 168 h without buying extra kWh. Cross-day purchases stay limited to D+2. A top-up before each PV recovery (Decide and day-ahead replan, not the annual cost tile) stays under that morning's cap. The cap is a ceiling, not a fill up to +10 percentage points. The hour is its own row (prefill_breach). The search starts at the first hour of the PV block, or at the cursor on a night window, and is seated again after the early-cover move on the same storage roll as the timeline. A conserve hour discharges the battery only up to its cap. An hour that already grid-charges does not. That does not create a floor. The hour must be at least 5 €/MWh cheaper than the floor hour, otherwise the top-up is skipped. If that floor has no legal buy, the same sun window stays open for the next floor. Lookback uses a daytime hour the soft cap blocked when that hour is at least 5 €/MWh under the cheapest night hour and it does not curtail PV. The probe adds only the free grid port, discharges a conserve hour only up to its cap, and stops at the plan target SoC. JSON true/false is not a price, a kWh value, or a PV block: the shared price read, PV recovery, and sun windows treat it as missing. In the Decide price series, the Weak fill, and the day price it does not fill forward as 1 €/MWh. Neighborhood and thin-cover carry, and lookback-thin, do not count it as house load or charge power. Cover deficit, the autark gate, early cover, and the discharge move do not count it as capacity or charge power. The hold pack and the aligned PV series do not count it as kWh. Cold-start PV and the remaining day load do not count it as kWh. The night card does not count it as hours or kWh. The empty-reason does not count it as charge power. The cover window and the horizon load do not count it as kWh. Weak DP and the Decide mix do not count it as capacity, charge power, or a grid-connection limit, and cap-skip does not treat the pack as full. Must-run and prep hour indexes do not count true as hour 1, and the charged energy total does not count it as 1 kWh. Explain, stockpile, the session, the profile, Outlook, and the meter do not count it as an hour, a kWh value, a grid connection, an annual load, a kWp value, or 100 % efficiency. If the floor is already on the first search hour, the same sun window stays open for the next floor. A full grid port or a battery already at cap uses the next cheaper hour. Day consolidate does not drop it and does not move it past the shortfall. Negative prices (below 0, lead band 0 only) do not depend on the plant opportunistic switch. The day foot shows minimum-SOC grid draw separately; it is not in the planned total. After the prefill reseat, the first trough's pack depth in a PV day, divided by efficiency, sits on the cheapest reachable hour of that day. An earlier PV day opens only when that day cannot hold minimum SOC. A short tail at the horizon end stays its own block. An hour before a full battery does not cover a later trough. Gaps stay empty. Free battery room is counted after that hour's load. While minimum SOC holds, the slot caps stay unless they raise the grid bill; surplus charge is dropped. A charge hour does not discharge the battery in the same hour. Discharge across midnight runs through the end of the next calendar day, once per day after the same-day comparison. Kill HOURS_CHEAP_REACH_DEFER=0. Kills HOURS_HOLD_LATER_PEAK_RESERVE, HOURS_HOLD_DISCHARGE_PING_PONG, HOURS_HOLD_PRICE_PEAKS, HOURS_HOLD_MIDNIGHT_CARRY, HOURS_PREFILL_BREACH_TOPUP, HOURS_LOOKBACK_DAY_STAMP_PROBE, HOURS_OPP_NEGATIVE_PRICE, HOURS_COVER_EARLY_PLACE, HOURS_CHEAP_REACH_DEFER set to 0. All off and lead band on → hours_demand_v1.37. PicoC unchanged. Timeline ops all kinds: (PV, battery, grid, SOC) where {soc_pct} is a ready display token (~a %→~b % start→period end, end ~b %, or —); additive segment field soc_start_pct; soc_pct stays period end. Shared token builder (SSR/format_plan_soc_token + JS). On a grid-charge role, grid is the mean of charge kWh plus house residual, not the house residual alone. Live overlay does not rewrite timeline SOCs. Week-glance Why energy = horizon sum X+Y+Z. PicoC/day_plan unchanged. Applies to all plants/keys.
{
"decision_status": "ok",
"decision_engine": "hours_demand_v1.38",
"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
}
],
"basis": {
"cover_window_hours": 14,
"cover_window_hours_pv": 14,
"cover_extended_for_reserve": false,
"cover_recovery_ok": true,
"cover_dawn_cap_hours": 38,
"energy_cover_dawn_kwh": 4.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). Visibility 1A: trimmed Folgetage dates (REST_DAY_UI_MAX=7, incomplete trim) gatekeep PV nav and week glance — a calendar day appears everywhere or nowhere. Visible set = Folgetage date ∪ calendar days spanned by [hour_lo, hour_hi) ∪ local today (D0 never vanishes after morning sun). Hold→Folgetage by hour window [hour_lo, hour_hi). Client (hx57): prefs-chip helpers are top-level; replace Folgetage SSR only after ≥1 day paints — otherwise keep SSR / hide an empty shell (no hint-only card).
Portal / Sparvo — week glance
Feed-in (energy community): The PV line on plant outlook and Sparvo Plant shows how many kWh on the whole calendar day neither the load nor the battery can take (export_feed_kwh, AC, 1 decimal). That is the day total — already fed in plus still forecast — and it updates through the day. The battery chart for today still starts at the current hour (live SOC already includes the past). Feed-in adds the completed hours from SOC pings, plus the remainder from live SOC. Hours with no ping and a battery are not invented. Planned grid charge occupies the battery and lowers the part still ahead, and it leaves room for 110 % of the PV forecast when the minimum still holds on the raw forecast. Sparvo day chips and portal day buttons always show the kWh, including 0.0. All plants/keys. Not in the Hours JSON and not in the Loxone PV text.
On Plant outlook (portal) and Sparvo Plant, the Current week glance shows the 7-day plan from the same shared engine (day_plan, default horizon 168 h). No “Week N h” / “Today (EMS)” / “Next …” summary chips and no Cover/search-horizon layers line — the day strip, lead (“System looks … h ahead”), and EMS hint are enough; when today is 0 h but later days have grid charge, the next charge appears only in the why line (why_later). Plan timeline: Explain has two folds (grid charging + until-morning/following days) with chronological plan_timeline in the charge fold (no separate “This week” prose); the week glance echoes teaser plus at most 1–2 day lines — not the full clock prose from basis.week_status.lines (API field remains). Autark = no charge/hold and no day residual; idle with residual but no hold → no_hold_gap; Hold with no grid import = battery conserve (continuous slot Cap hold_share, Weak/cheap_hold ≈1 kW, or battery-reserve 0…0.2, hold_hours_local) — not the same as Autark. Covered beats interim-pass hint. Every Decide recomputes over now, now+H≤168). 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. Horizon-Hold: shared builder with Decide; annual sim = battery take min(net, Cap, s−floor) (Decide O12 = slot Cap). Hold only on a cover gap. The Explain “show Hold only when night load is uncovered” gate is UI-only and does not change the tile’s annual €; without storage there is no Hold. Personal € tiles stay noindex (/plant, /plant-outlook); discoverability is via [Sparvo llms.txt and SpotpriceAPI docs.
The Grid charging fold explains the plan as: current period (still X hours until morning sun/date-time) · in this period until-prose (until-none only when calendar today is truly autark — no charge/hold/residual import (incl. charge_by_date slots); no_hold_gap hides until-none; no phantom kWh without charge segments) · chronological timeline (plan_timeline: battery-conserve ranges with (battery: ~x kW, grid: ~y kW, SOC: ~a %→~b %) = SoC at segment start→period end, end-only end ~b %, unknown SOC: —; not the live gauge — Decide rolls from telemetry SoC; overlay updates “Now” only) + charge bullets with unique-start merge/kWh/reason/session stop SOC; autark silent) · “Afterwards…” heading only for events after period end. The former “This week” prose tile is gone in the UI (basis.week_status remains in the API; plan_timeline UI-only). Follow-day supply pie: PV · battery · grid house · optional grid→battery (flow denominator). “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 — inside the Until tomorrow morning · following days fold (night card; not above the charge fold). Battery-conserve (Hold) chip only when night load is not covered by storage+PV; follow-day holds alone do not show a Hold banner. “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 battery 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. Allow grid charging in the plant profile drives the bar: on → kWh includes planned grid charge up to morning sun (expected trajectory); off → without grid charge. Applies to all plants/keys. 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 enough complete meter days since activation exist we write their mean × 365 (ceiled to 100 kWh) into the plant profile — portal, Sparvo, telemetry and Hours persist the same value (not display-only). Recency-weighted typical stays for day / remaining-today load. 5. Cover sizing uses the gap until the next good PV (basis.cover_window_hours) — not the full week’s load. v1.13: Dawn-Cap clips Cover/Bridge at hi(next_pv) even under stretch (port = charge power−PV) — any weekday in the ≤168 h horizon, not weekends only; Soft-Cap layers overnight + Recovery daytime + Dawn (min of rooms — Dawn does not replace Near Soft-Cap); no Cover past the dawn stamp (later days → prep); energy_deficit_cover_kwh follows the Dawn-clipped budget. v1.12: if the next PV day holds the chrono trough with margin (basis.cover_recovery_ok), stretch and D+1 union are skipped; Soft-Cap remainder after Near is not placed as Far/D+1 cover or bridge unless Soft-Cap abandon spill (HOURS_SOFTCAP_ABANDON_SPILL or Darf-S1 HOURS_DARF_SOFTCAP_S1, Gate OR, default on; Kill =0) into the next Soft-Cap window; rebalance/consolidate must not move Cover past Near. 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, except three matching meter hours raise or lower those same clock hours over the next 24 hours (the cap applies only upward, at twice the typical hour; a single spike or dip does not count; a falling battery is not a quiet hour; the current hour counts only for its remainder when the cover refine has already shortened it). 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 and 13:00 before 16:00 when cheaper and feasible). Mid-price earlier hours are not dropped just because a later even-cheaper cover slot is already full — otherwise remaining need would spill into worse hours (e.g. keep 13:00 over 16:00 when 14/15 are taken). Feasibility: SOC headroom, port room charge_power_kw − PV, skip pv_fills_charge_power only when port≈0 / full battery (partial port 0 < port ≤ min_headroom stays chargeable). 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 battery headroom). Skip pv_fills_charge_power only when port≈0 (partial port stays chargeable). After the picker: chronological clip — no further grid charge once Stopp-SOC is 100 % / battery full. Hourly roll is load/PV first, then grid: a full battery at hour start does not block a net-drain hour (otherwise cheaper slots like 15:00 vanish in favour of dearer 16/17 top-ups). Same-day cover rebalance after the clip prefers cheaper hours (bidirectional, accept-if-better). If that first morning sun cannot hold min SoC through the following nights (typical weak weekend) and recovery is false, 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}\) (basis.carry_hours); lookback only vorrat_wuerdig (P20 ∧ absolute guard); Cap below target SoC; Dump sizing before further reserve (e_opp = fill − cover); placement near-cover → softcap-abandon-spill (flag/Darf-S1) → prep → far-cover → bridge → opp; 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).
The most expensive hours of a fully priced day get no grid charge into the battery. A missing hour or a day spread under 5 €/MWh means no ban. If 110 % of the PV forecast shows export within the next 24 hours (weather is usually better than the forecast) and the minimum still holds on the raw forecast without the grid charge before that export, that charge is dropped. Charge from that hour on stays. If the battery would otherwise fall below its minimum, at most one top-up remains: only the unmet load of that hour, capped by charge power, the removed kWh, and the room that still fits in the battery up to that hour. Energy a full hour before it would clip is not bought. The cheapest hour that covers the shortfall is used. If none covers it fully, the largest share that arrives is used. The current hour is shortened once. When cover refine already stored the remainder, that remainder stands. Discharge during expensive hours is unchanged. Kill HOURS_PRICE_PEAK_CHARGE_BAN=0. An hour total under the crumb threshold, whose grid port is larger, is dropped only when the floor hours across all windows up to the eighth sunrise are the same with and without it. A shift of the floor keeps it. A smaller port stays. Kill HOURS_CHARGE_CRUMB_DROP=0. Grid charge that would push later PV out of the battery at 110 % of the forecast is shortened only by those kWh. The same hour loses only what no longer fits after 110 % of the PV. The minimum is checked on the raw forecast. The current hour counts once for its remainder; when that remainder is already stored, it stays. Kill HOURS_PV_EXPORT_HEADROOM=0.
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.23 (O9 headroom to session stop/O4; latch clears rest): O1/O2/O9 from prefix fields. O3 = gated relay (charge_now), O4 = charge_stop_soc_pct (prefix end), O5 = PulseSessionStop → Fetch I5+I6, O1 = 0 when O9 = 0. EMS prefix carries additive charge_latched / charge_stop_hysteresis_pp (server SoT); Extract reads hysteresis pp for the local latch band. After 12 h without API: local fallback. Fetch v1.17 (SOC 5% bands; no smart-15 Decide).
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.23 (O9 headroom to session stop/O4; latch clears rest): 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.23 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)
As soon as day-ahead is complete and inference has flipped D+1 to price_source=day_ahead (status=received and received_for = tomorrow), Spotforecast immediately replans all active plants (mode=decide, snapshot + optional webhook plant.plan_updated). The poll runs hourly from ~13:00 Vienna with catch-up until ~23:00. While the poll is still waiting/skipped, Hours keeps the last Decide — D+1 on the prices chart stays forecast_d1 until received_for is set.
In addition, an SOC ping near min SoC (10 pp / 5 pp / floor) or on recovery or when a charge session reaches stop SoC (session-complete: only while snapshot charge_now==1, 120 s debounce after Decide; stop/charge_now fail-closed via coerce_charge_stop_soc_pct/coerce_charge_now, JSON bool → idle) or on hold-load drift (Now ∈ hold_hours, live import vs Cap-expected max(0, plan_rate−discharge_cap_kw), 120 s debounce; kill HOURS_HOLD_LOAD_REPLAN) runs the same internal Decide asynchronously (snapshot + PV day-pack + webhook). Sparvo/Portal prefer a fresh snapshot (≤900 s) with live-SoC overlay; Loxone Extract follows on the next Hours Decide (:00 / SOC band / I6) or webhook trigger.
| Topic | Behaviour |
|---|---|
| Webhook payload | Compact: charge_hours_today + pv_forecast_url / pv_hash — pull full plan / PV via GET |
| PV day-pack SoT | Day-pack in DB = model FC (last_pv_forecast_*, pv_hash). Served GET/loxone and Portal/Sparvo: D0 = calendar meter blend (actual+forecast) keyed by calendar date when PV meter samples exist; else FC. Day-pack not fresh on empty hours / calendar roll; Serve pads/strips/synthetic D0. Later days = FC |
plan_hash | Optional in the event — dedupe against your last pull |
| Materiality | Push fires when tomorrow-only slots or the PV day-pack change |
| Portal explain | Prefer fresh snapshot; live resolve only if missing/stale or profile grid_charge_allowed=false while the snap is still charged (Demand/charge_now/prefix_*/charge plan_hour_roles/energy_charge_kwh/nested day_plan) — then live Decide + publish_hours_and_pv_after_decide (no snapshot-first short-circuit). Overlay hides Now when SoC≥stop (chips/charge_days_*). Profile save triggers immediate shared Decide; incomplete/error → tombstone (profile_refresh, hours_unavailable, empty charges, discharge_power_kw=0/charge_now=0) + webhook; hydrate over tombstone publishes incl. webhook (no silent persist); load_raw_snapshot refreshes plant row (no identity-map stale); hydrate skips silent persist when issued_at advanced during resolve; if tombstone write also fails → flash plan=error_stale. Decide-OK without a persisted snap → flash error_stale. Grid charge on→off without plant.plan_updated → flash webhook_not_configured / webhook_failed. Live-telem strip forces charge_now=0 while the profile is off or cold-start pause (hours_control_paused) is on. Why-UI display-only: while the profile is off/paused, never narrate stale Demand/LoadNow (incl. charge roles/week_status); empty_reason: pause → cold_start (incl. pause∩grid-off); grid-off alone prefers grid_off over covered/hold. Empty day_plan on a fresh non-tombstone snapshot is valid (autark/covered) — no forced live Decide for empty list alone |
| Idempotency | Later timers that day skip plants whose Decide is already current (already_current) — not incomplete_input. Tombstone after profile save forces catch-up (profile_refresh_tombstone); while the tombstone sticks after a non-ok Decide, DA redispatches the empty EMS clear (not only on incomplete_input). Grid-off + still-charged snap after non-ok Decide → new tombstone + one webhook |
| Incomplete | Only when capacity/SOC are missing → API/incomplete_input or batch skip until profile/telemetry catches up; profile save still clears old charges in the snapshot (except error_stale) |
| Fill-day / prep | Day that fills the battery without grid and still has Cap need → not a Prep gap (fill_day_not_gap). Grid charge that does not cut later house import is dropped. One harmful hour does not delete a useful one. Storage kWh on an hour whose net load exceeds the discharge room at the start sit on earlier cheaper hours that still reach the floor. The kilowatt-hour clip leaves that charge in place for this move (HOURS_CHARGE_SURVIVES_USE) |
Next steps
- Plant Telemetry
- Loxone Hours
- Webhooks — push when the plan changes
Register for Community or code samples with your personal key.