Repository navigation
RFC 0001: Shared-plan coordination via an external registry sidecar (stateless EMHASS) #931
Replies: 9 comments 3 replies
|
@OptimalNothing90 This resonates a lot. I run EMHASS with three deferrables in production (EV charge floor, EV solar top-up, and a pool pump) on a single-phase-aware setup, plus a separate controller that actuates battery grid-charge. The contention you describe is exactly the failure mode I keep hitting: the moment you have more than one flexible load chasing the same PV surplus, isolated optimisers oscillate and step on each other. One thing worth separating out though: EMHASS already co-optimises competing loads when you model them as deferrables in a single optim call. So for me the new capability here isn't really "solve them together", it's the two things wrapped around that: Dynamic registration. Right now I regenerate the whole deferrable payload every MPC cycle from an external script because the loads come and go (car plugged in or not, pool hours seasonal). A register/refresh/withdraw API would let each consumer own its own demand lifecycle instead of me rebuilding the payload each run. That's the real ergonomic win. Either way I'd adopt this. Happy to be a test bed for a real multi-deferrable + external-actuator case if it helps shape the design. Two design details I'd want nailed down early: how recurrence/seasonality is expressed (my pool pump hours change month to mont |
|
@LesIT1 thanks, this is the steer I needed. The sidecar framing is better than mine and I'm switching to it. Agreed the co-optimization isn't the new part. EMHASS already solves competing deferrables in one call. What's missing is the two things you called out: dynamic registration, so each consumer manages its own demand instead of you rebuilding the payload every cycle, and persistence between cycles. And your worry is the right one. Making EMHASS itself stateful drags in concurrency, atomic writes and lifecycle bugs the project has stayed away from on purpose, and I don't want to fight that. So I'll rework the RFC around a thin registry sidecar that owns the state and the register/refresh/withdraw API. Each cycle it folds the active demands into a normal deferrable payload, calls EMHASS the way it already works, and reads the plan back. The solver never needs to know it's there. The one thing it needs from core is a machine-readable plan out of On your two details: register/refresh/withdraw with a required Which is why I'd take you up on the test-bed offer if it still stands. Between your setup (EV floor, solar top-up, pool pump, separate battery controller) and mine here (EV, PV, battery, and a heat pump running both heating and hot water), we'd cover most of the contention that actually matters. One thing I'm deliberately not deciding yet: where the sidecar lives. Standalone companion, a small community org, or something EMHASS just points at. I'd rather hear @davidusb-geek's preference there than assume, since the point is to keep it separate from core and not drop anything on him he didn't ask for. I'll revise RFC 0001 to the sidecar version next, with the JSON output as the only thing I'm asking of core. |
|
@OptimalNothing90 glad the sidecar framing landed. Folding active demands into a normal deferrable payload and calling EMHASS through the existing stateless paths is exactly the shape I was hoping for. It keeps the core's statelessness intact and puts all the lifecycle and concurrency work in one place where it can be reasoned about on its own. The machine-readable plan output from Your lifecycle and recurrence answers both work for me. A required On where the sidecar lives, I agree that's David's call and shouldn't be pushed onto the core. Happy to be a test bed for this once you have something to try. I'm running three deferrables plus a separate battery controller in production, so I can exercise the registration, refresh, and withdrawal paths against a real setup and feed back on the recurrence and lifecycle behaviour. Let me know if there's anything else you need from me as you revise the RFC. |
|
@LesIT1 glad it landed. I've updated the RFC in the thread above to the sidecar design (the original is collapsed at the bottom if you want to compare). Nothing needed from you right now. Once there's a sidecar MVP to try I'll ping you, so we can exercise the register/refresh/withdraw and recurrence paths against your real setup. After that it's David's call on the one core addition (the plan output) and where the sidecar lives. On tooling: I've been getting a lot out of cross-model review lately. Claude as the main driver, Codex as a PR reviewer; having a second model go over the diff catches a surprising amount before it ships. What are you running? If we're set up similarly it'd be worth coordinating that way on the sidecar and the plan-output PR. |
|
@OptimalNothing90 sounds good, I'll wait for the ping. On tooling, I'm running Claude as the main driver as well, but I've gone a slightly different way on review. Instead of a second vendor's model, I lean on an adversarial pass inside the same stack: a separate reviewer agent whose only job is to try to refute the change, a heavier reasoning model on the diff and a faster one for the mechanical edits. Anything headed upstream goes through a structured contribution flow with an early adversarial deep-dive before any of the PR is written, and that early pass is where most of the this-is-actually-wrong catches land. I am tweaking this workflow every day, using the days learnings to guide me, the idea being to make sure mistakes aren't repeated. So far it's going well, but there is always room for improvement. When I had access to Fable, I used it to do a lot of reviewing of my workflows and improved them that way too. Net effect feels close to what you get from Codex-as-reviewer, just kept within one model family. Glad to coordinate that way on the sidecar and the plan-output work. When there's a diff, I can run my adversarial pass over it and you run yours, then we compare what each side flags before it ships. |
|
@LesIT1 the promised ping: there's a sidecar MVP to try. It lives in my contributions repo for now, under What it does today:
Deliberately not in yet: priority (EMHASS can't rank loads), seasonal recurrence, per-source auth. EV is still the lossy energy-in-window mapping until an SoC-aware load type exists upstream. To be clear about where this stands: it's a first shot, not a finished thing. 150 unit/contract tests and the compose E2E pass, but it has not run against a real household, and I'd genuinely value a second pair of eyes on whether the shape matches what you had in mind when you argued for the sidecar — API surface, lifecycle, the aggregation step, any of it. If you'd rather tear into the design before touching the code, that's just as useful to me. Beyond that, the practical ask if the offer stands: exercising register/refresh/withdraw and the standing-load path against your multi-deferrable setup. Lifecycle works as we settled it in the RFC — mandatory Placement is still open pending David's steer; it stays in |
|
@mschaepers good timing, the offer stands. I'll take this in two passes. First the review you asked for: I'll run my adversarial pass over prototypes/flexd and feed back what it flags, API surface and lifecycle included. Then a shadow run against my real setup: flexd plus a second EMHASS instance alongside my production stack, registering the same three deferrables (EV floor, EV solar top-up, pool pump) with actuation left off, so I can compare its plans against what my current pipeline produces. One note from setting that up mentally: my production EMHASS is driven by an external MPC loop every 15 minutes, so flexd needs its own instance or the two callers will fight over the results. Worth a line in your docs, anyone with an existing caller will hit it. One question on sequencing: are you sending the /api/v1/plan PR upstream yourself, or do you want me to pick it up? Happy either way, just don't want it sitting unclaimed. I'll be back with review findings within the week. |
|
@mschaepers review's done, quicker than I expected to find time for. Short version: the shape matches what I argued for in June and a lot of the engineering held up under attack, the registry persistence, ownership guards and plan-slot attribution in particular. But I'd fix three things before this touches a real household, because they all fail silently on the primary delivery paths. Full write-up with file and line references, plus runnable repros for all three, is in OptimalNothing90/emhass-contributions#1. The headlines:
The issue has the rest: a dozen mediums (corrupt-state recovery, a not_before rule that silently drops across midnight, a couple of real races, string-compared timestamps), a tail of lows, and the list of what held up under attack, which is honestly the longer list. Two of the mediums need checking against the pinned EMHASS rather than flexd itself, I've flagged which, and I'm happy to help verify those. Once the top three are fixed I'll move to the shadow run against my setup as we discussed. |
|
An update on RFC 0001, following the two fix batches on the sidecar prototype after LesIT1's review and the discussion in #1157 about What changed in the RFC The sidecar is now described as the demand-lifecycle layer in front of a stateless core: registration, ownership, expiry, persistence across restarts, mapping onto runtime parameters, attribution of the result back to the demand, and an account of what the mapping had to change. It is not a second optimizer and not a coordinator between solvers. The acceptance criteria it has to meet before touching a real household are written down:
The first four are what the review in OptimalNothing90/emhass-contributions#1 found broken or fragile; they are now criteria rather than fixes. The single-writer assumption is gone The June text said that because Relation to #1157 and the The federated backend coordinates solvers by prices at the meter. RFC 0001 addresses a different problem: one optimizer, many consumers that come and go. The sidecar sits in front of whichever solving path the core offers, never takes part in price iteration, and if the core ever absorbs demand lifecycle it shrinks to transport adapters and the conformance tests behind the criteria above. That outcome would be fine.
On the context and what-if proposal @MMicieli, your demand / context / query split from August is now the sidecar's roadmap, in that order. Demands are what runs today. Context entries come after the shadow run as a second registration type on the same registry, with the same EV and multi-battery The June sentence calling the EV SOC model (N2) independent of multi-battery is withdrawn. Since then phase 1 of #610 shipped, phase 3 (#1150) is in design, and phase 2 is where an intermittently connected battery with a re-anchored SOC and a per-departure target belongs. N2 is therefore now an input to phase 2 rather than a competing model: Placement of the sidecar remains @davidusb-geek's call and nothing above changes the one-caller, recommend-only, no-coupling-in-core position. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC 0001: Shared-plan coordination via an external registry sidecar (stateless EMHASS)
Status: Draft (Issue-filed)
Discussion: #931
Upstream context: #824 (EV use-cases follow-up to #789)
Verification basis:
audits/2026-05-31-emhass-evcc-coordination-synthesis.md(facts F1–F16, build menu N1–N7)Author: OptimalNothing90
Date: 2026-06-02 · Revised: 2026-06-16
Revision note (2026-06-16): in-core registry → external sidecar
The first version of this RFC (preserved at the end of this document, collapsed) proposed
making EMHASS itself stateful, with a persistent registry living inside the core. In the
#931 discussion, LesIT1, who runs a multi-deferrable production setup, argued that the state
and its lifecycle belong outside EMHASS in a thin sidecar, leaving the core stateless and
recommend-only. That avoids the concurrency, atomic-write, and lifecycle-bug surface the
project has deliberately stayed away from, and it lines up with the #789 guard. This revision
adopts that. The registry moves out of EMHASS. The only thing EMHASS-core needs is a
machine-readable plan output, which is build item N1 in the coordination synthesis.
Everything else (registration, persistence, lifecycle, recurrence) lives in the sidecar.
Motivation
In a home with several flexible loads, they all compete for one scarce resource: PV surplus
and grid import/export headroom. Today each load is optimized on its own. evcc plans EV
charging by itself; a heat pump or hot-water tank runs on its own local surplus trigger;
EMHASS optimizes the home battery and its statically-configured deferrable loads. Nobody
allocates the scarce capacity across all of them. The result is first-come-first-serve
contention: a surplus appears, several loads jump on it independently, their combined draw
exceeds the surplus or a grid limit, loads trip or back off, and the system oscillates. It
happens even in summer, where surplus is finite at any given instant, and it gets worse in
the shoulder seasons.
EMHASS already solves the whole-house MILP that arbitrates this, and it already co-optimizes
competing deferrable loads inside a single call. Two things are missing, and neither is the
solve itself:
There is no persistent notion of "the flexible loads currently in play". A multi-source
setup (evcc, a heat pump, HA automations, later LLM agents) has no shared plan to
contribute to. Each source rebuilds and resubmits the whole problem every MPC cycle.
/action/*returns a plain-text acknowledgement; theschedule is published to Home Assistant or written to
opt_res_latest.csv(synthesis F11).Nothing hands a caller the structured plan back.
There are two ways to add the shared state. The original version of this RFC put it inside
EMHASS. The problem, raised by LesIT1 in #931 and consistent with #789, is that an in-core
registry makes EMHASS stateful: concurrency, atomic writes, file corruption, and demand
lifecycle all become core concerns the project has chosen to avoid. The alternative keeps
EMHASS stateless and puts the state in a sidecar: a small separate service that owns the
registry and renders the active demands down into an ordinary stateless optim call. The
solver never knows the registry exists. This RFC takes the sidecar route.
Architecture
Placement against the #789 guard (synthesis F16): the registry and any actuation are glue,
outside EMHASS core. EMHASS stays the MILP optimizer and nothing more. The sidecar submits
demands through the existing
/action/*runtime-params path — the same mechanism amulti-deferrable operator already uses by hand today — so no new inbound API is added to
EMHASS. The one core addition is the plan output (N1) so the sidecar can read the result back
without scraping a CSV.
EMHASS-core change (the only ask): N1, structured plan output
A read-only endpoint that returns the most recent optimization result as JSON.
GET /api/v1/plan— the latestopt_resas JSON records, carryingemhass_schema_version.docs/plan_output_schema.md, docs(schema): publish plan-output column schema + version constant #835); theversion follows
EMHASS_SCHEMA_VERSION.GET /api/v1/last-run(feat(api): add GET /api/v1/last-run JSON endpoint #851), which already returns run metadata.opt_resiscomputed and currently discarded (synthesis F11); this serializes what is already in hand.
The sidecar flow is: POST the aggregated demands to
/action/dayahead-optim(ornaive-mpc-optim) via runtime params, thenGET /api/v1/planto read the structured result.Because
/actionis awaited and EMHASS is a single writer, the plan returned by the GET isthe one the POST just produced.
The sidecar (external, not an EMHASS change)
Described here so the architecture is legible, but it is not part of the EMHASS ask. It owns:
register/refresh/withdraw/listfor demands. A requiredexpires_atplus idempotent refresh means a consumer that dies just ages out, with noorphaned demand wedged in the plan. This API is internal to the sidecar; it never reaches
EMHASS.
rename.
runtime-params payload for EMHASS.
whose hours shift monthly), rather than materialized instances.
A registered demand reuses the runtime-schema-family vocabulary (
param_definitions.json,runtime_params.json/ #915) rather than inventing a new shape: essentially a persistedruntime deferrable-load demand plus
priority,source, and a lifecycle.Threat model (split by layer)
Per the #808 maintainer comment, the concern is code injection. Splitting the design splits
the surface:
GET /api/v1/plan. It isread-only, takes no input, persists nothing, and returns the same data EMHASS already
publishes to HA. The injection-risk surface is unchanged from today.
deserialization of code), validated on load with hand-rolled checks, writes confined to the
sidecar's own data directory, ids never used to build filesystem paths, single-writer atomic
write. A bad demand persists only until its
expires_at. None of this touches EMHASS.The pivot's headline security effect: the new EMHASS attack surface drops from "stateful
persistence with a widened blast radius" to one read-only GET.
Backward compatibility
GET /api/v1/planis additive. With no caller, nothing changes./action/*, configuration, and the HA publish path are unchanged.Roadmap and sequencing (coordination synthesis N1–N7)
This RFC delivers N1 only. The rest is sequenced, not bundled:
accumulator (closes the lossy EV→deferrable mapping; L; independent of multi-battery);
load-priority MILP (EMHASS has no inter-load ranking today; its own sibling RFC); N5
soft-constraint / never-infeasible behavior for drop-in fidelity.
enablement, the register/refresh/withdraw API, persistence.
GET /api/v1/planPR. It is independently useful (machine-readable plan for any consumer, including the LLM-ready goal) regardless of the sidecar.
Known limitations (interim, with a closure path)
deferrable with an energy target and a window. It cannot express an SoC curve
("40% → 80% by 07:00") until N2 lands (synthesis F10). Until then the mapping is an
approximation, not a permanent ceiling.
objective rather than an explicit per-load priority, until the priority sibling lands.
Open questions
references. This is the maintainer's call to steer; the only hard requirement is that it
stays separate from and non-binding on EMHASS core.
(sidecar concern).
sourceowns itsentries so one can't overwrite another's. Sidecar concern; likely per-source namespacing or
a token.
deferred/reduced/rejected detail). Sidecar concern.
feature, not an EMHASS change.
Appendix: superseded original (in-core registry, S2)
Original RFC 0001 as posted 2026-06-02 — proposed an in-core stateful registry. Superseded 2026-06-16 by the sidecar design above, after #931 feedback. Kept for the discussion context LesIT1's comment responds to.
RFC 0001: EMHASS as a stateful shared-plan service (persistent flexible-load registry)
Status: Issue-filed
Discussion: #931
Upstream context: #824 (EV use-cases follow-up to #789)
Author: OptimalNothing90
Date: 2026-06-02
Motivation
In a home with several flexible loads, they all compete for the same scarce resource: PV surplus and grid import/export headroom. The loads differ in nature. Some are ad-hoc (an EV charge session, a washing machine, a dishwasher), some are predictable and recurring (heat pump, hot water, heating), and some are seasonal (a pool pump). Today each one is optimized on its own:
Nobody allocates the scarce capacity across all of them. The result is first-come-first-serve contention: a surplus appears, several loads jump on it independently, their combined draw exceeds the available surplus or a grid limit, loads trip or back off, and the system oscillates. It happens even in summer, where surplus is still finite at any given instant, and it gets worse in the shoulder seasons.
EMHASS already solves the whole-house MILP that could arbitrate this, but it does so statelessly, one request at a time. Each caller has to resend the entire problem on every call, and there is no persistent notion of "the flexible loads currently in play" or "the current plan". So a multi-source setup (evcc, a heat pump, HA automations, and eventually LLM agents) can't contribute demands to one shared plan. Each source would have to own and resubmit the whole problem, which is what produces the isolated, conflicting optimizations above.
There is an obvious alternative: keep EMHASS stateless and put a stateful aggregator in front of it, one that continuously collects every demand from every source and resubmits the full problem each cycle. The trouble is that the aggregator is the hard part. It has to know about, poll, and re-capture all the sources on every tick, which makes it a complex, tightly-coupled, central component. Holding the registry inside EMHASS inverts that. Each consumer becomes a simple trigger-based integration: it fires a register or withdraw when its own event happens (the EV is plugged in, the washing machine is started), and otherwise does nothing. EMHASS already holds the optimization, so it is the natural place to aggregate. The integration burden drops from "build a stateful all-source aggregator" to "POST one demand when your event fires". That is the main reason the state belongs in EMHASS rather than in every integrator.
The stateless model also blocks the LLM-ready direction. For EMHASS to be a first-class planning interface for AI tooling, agents need to query the current plan and propose or withdraw demands against a persistent shared state, instead of reconstructing the whole optimization problem on every interaction.
Proposed change
Add an optional, persistent flexible-load registry to EMHASS, turning it into a stateful shared-plan service while keeping it recommend-only.
The core primitive is generic: any consumer registers a demand through one uniform API, whether that demand is an EV charge need, a thermal target, or a generic deferrable load. The contention example above spans several consumers, but the mechanism itself is consumer-agnostic. The consumer-specific work stays out of scope here: how a given consumer (evcc for an EV, or a heat pump for a thermal target) builds and submits its demand, and how the returned plan then gets actuated. Each consumer is its own follow-on piece, a public usage example plus that consumer's own (often private) glue. Note that a demand (what the consumer needs) and actuation (how the plan is carried out) are separate concerns; actuation, including any device-signalling protocol, stays on the consumer side.
priority, asourceid, and a lifecycle status (active or expired).prioritythat EMHASS doesn't have yet. Until the sibling priority RFC lands, conflicts fall to the existing cost objective rather than to user priority. So this RFC on its own delivers overshoot-prevention and one shared plan; priority-based arbitration is left to the sibling.Notes on the demand space:
thermal_configstay as configuration; ad-hoc loads (EV session, washing machine, dishwasher) arrive through the registry; seasonal ones (pool pump) are standing entries enabled per season. All of them feed the one solve. The registry is additive, not a replacement for existing config, and it doesn't have to "own" the static loads in order to arbitrate against them.dishwasher-eco,pool-pump-summer) built on EMHASS's existing fixed-profile deferrable-load definitions, so recurring loads don't have to resend their shape every time.shiftabledemand with a window, not an immovable load: EV →[now, +3h](or by departure); washing machine →[now, +2h]; a dishwasher with a built-in night delay →[02:00, 06:00]. EMHASS schedules within the window. The genuinely immovable case (a dumb appliance with no delay, already running) is the rarecommittedmode, which is account-only: EMHASS can't move it but registers it so other loads yield.hybridmeans run on surplus or cheap power, but guaranteed done by a deadline. Which mode applies is the EMHASS user's per-load preference.expires_at, so stale entries drop automatically and a consumer that registers but never withdraws can't leave a zombie demand. Standing or seasonal loads stay alive by refreshing viaPUT, and the samePUTupdates a changing demand (a new SoC, a moved deadline).The registry is the concrete form of the long-parked "persistent flexible-load registry" idea (follow-up to #824) and the basis for the LLM-ready goal.
API / contract
New, additive endpoints (sketch; exact paths still open). They all live under the existing
/api/v1namespace, consistent with/api/v1/last-run. Transport here is REST; an MQTT intake is a possible follow-on (see Open questions).POST /api/v1/registry/loads— register a flexible load; returns its id and post-solve outcome.PUT /api/v1/registry/loads/{id}— update an existing load (new target/deadline/SoC), or refresh its timeout.DELETE /api/v1/registry/loads/{id}— withdraw a load.GET /api/v1/registry/loads— list active registered loads.GET /api/v1/plan(or an extension ofGET /api/v1/last-run) — the current shared plan as structured JSON, i.e. the recommendation.A registered load reuses the runtime-schema-family vocabulary rather than inventing a new one. It is essentially a persisted runtime deferrable-load demand plus
priority,source, and a lifecycle:{ "id": "ev-loadpoint-0", // server-validated; NOT used as a filesystem path "source": "evcc", "type": "ev | thermal | generic", "flexibility": "shiftable | committed | hybrid", // user preference; committed = run-now, account-only "energy_target_wh": 24000, // OR soc_target + capacity_wh "deadline": "2026-06-03T07:00:00+02:00", "window_start": "2026-06-02T20:00:00+02:00", "p_min_w": 1400, "p_max_w": 11000, "charge_only": true, "priority": 1, "expires_at": "2026-06-03T08:00:00+02:00" // mandatory; stale entries auto-drop, refreshable via PUT }The same object shape serves every consumer. An EV (shown above), a heat-pump thermal target, and a generic load (washing machine, dishwasher, pool pump) differ only in their field values, not in structure. A demand can also carry
"template": "<name>"in place of the inline fields, referencing a profile stored in EMHASS.param_definitions.jsonandruntime_params.json(the runtime-schema-family;runtime_params.jsonshipped as feat: add runtime_params.json (machine-readable runtime param schema) #915).docs/plan_output_schema.md, docs(schema): publish plan-output column schema + version constant #835) and is versioned byEMHASS_SCHEMA_VERSION.prioritydepends on a load-priority MILP capability that EMHASS doesn't have today (there is no inter-load ranking). That is proposed as a sibling RFC, not folded in here.Threat model
Per the #808 maintainer comment, the focus is code injection. The new surface here is persistence:
data_path, the same directory already used foropt_res_latest.csv. Loadids are server-generated or validated and are never used to build filesystem paths.eval. Registry mutations are pure data, validated against the demand schema./action/*runtime-params intake. Persistence does widen the blast radius, though: a bad demand persists across every solve until itsexpires_at, where a stateless POST only affects one solve. The mandatory timeout bounds that, and invalid entries are rejected at the API and never persisted.Backward compatibility
/action/*, configuration, and HA publish paths are unchanged.Open questions
expires_at(decided above). What is still open is the recurrence model for standing loads: the auto-refresh cadence, and calendar or seasonal enablement (a pool pump active only in summer, say), via explicit re-PUTor a declared schedule.template) are defined, versioned, and managed. Possibly a follow-on RFC rather than part of this one.sourceown its entries, so one source can't overwrite or withdraw another's, by accident or otherwise? Probably simple per-source namespacing or a token; TBD.All reactions