Battery-aware runtime params#

Goal#

Feed live battery state of charge (SOC) back into EMHASS naive-MPC on every call so the optimizer plans against the real current state, not a stale assumption. Avoid the most common consumer bug: SOC unit mismatch (fraction vs percent). After this recipe, every MPC POST your orchestrator sends carries the current soc_init, validated and clamped to your configured bounds.

Prerequisites#

  • Battery is enabled in your static EMHASS config (see Step 1 below)

  • A battery SOC sensor reachable from your orchestrator (Node-RED, AppDaemon, etc.). Common sources: inverter Modbus register, manufacturer cloud API, HA sensor.battery_state_of_charge. Any source works as long as you can read a number.

  • An MPC orchestrator that already POSTs to /action/naive-mpc-optim. If you don’t have one yet, see MPC orchestration via Node-RED. This recipe adds the battery branch to that orchestrator.

Step 1: Check your static battery config#

Verify your EMHASS config.yaml (or Web Config) has the battery block with the canonical keys:

optim_conf:
  set_use_battery: true
plant_conf:
  battery_target_state_of_charge: 0.6
  battery_minimum_state_of_charge: 0.3
  battery_maximum_state_of_charge: 0.9
  battery_discharge_power_max: 5000
  battery_charge_power_max: 5000
  battery_discharge_efficiency: 0.95
  battery_charge_efficiency: 0.95

The single runtime param this recipe drives is soc_init:

Field

Type

Range

Notes

soc_init

float

[battery_minimum_state_of_charge, battery_maximum_state_of_charge]

Fraction 0..1, NOT percent. EMHASS logs a warning and silently falls back to battery_target_state_of_charge for values outside the configured min / max bounds.

Expected: EMHASS starts cleanly, GET /api/get-config returns the battery block, and a baseline MPC call without soc_init lands at battery_target_state_of_charge in the result.

Step 2: Read battery SOC from your source#

In the function node where your orchestrator builds runtime_params (Step 3 of the MPC orchestration recipe), read the SOC from wherever your stack exposes it. Examples:

// Pick ONE of these — adapt to your stack:
const soc_raw = flow.get("battery_soc_percent");          // typical HA / Modbus poll
// const soc_raw = msg.payload;                            // if previous node was a sensor read
// const soc_raw = global.get("home_battery").soc;         // global context store

Expected: soc_raw is a number (possibly in either percent 0-100 OR fraction 0-1 depending on source).

Step 3: Normalize to fraction (the dual-format trick)#

Different transport stacks publish SOC in different units. Use a single guard that handles both without branching:

// Robust against both fraction-form (Modbus, EMHASS-internal) AND percent-form (HA, cloud API).
// Anything ≤ 1 is treated as fraction and scaled; anything else is already-percent.
const soc_percent = soc_raw <= 1 ? soc_raw * 100 : soc_raw;

// EMHASS wants fraction:
const soc_init = soc_percent / 100;

Expected: regardless of whether the upstream sensor publishes 0.74 or 74, soc_init ends up as 0.74.

Step 4: Defensive validation#

Battery sensors regularly return garbage during startup, after BMS calibration, or during firmware updates (NaN, negative values, or > 100). Validate before sending:

let soc = parseFloat(soc_init) || 0;
if (soc < 0 || soc > 1 || isNaN(soc)) {
    node.warn(`Bad SOC reading: ${soc_raw}, defaulting to 0.5`);
    soc = 0.5;
}

// Optional: clamp to configured bounds. Saves a round-trip EMHASS warning log.
const SOC_MIN = 0.3;   // your battery_minimum_state_of_charge
const SOC_MAX = 0.9;   // your battery_maximum_state_of_charge
const soc_init_clamped = Math.max(SOC_MIN, Math.min(SOC_MAX, soc));

if (soc_init_clamped !== soc) {
    node.warn(`SOC ${soc} clamped to [${SOC_MIN}, ${SOC_MAX}]`);
}

Expected: any of NaN, -3, 123 get caught and either rejected (set to 0.5) or clamped to bounds; the node.warn lines appear in the Node-RED debug pane so you notice when a sensor goes bad.

Step 5: Attach to runtime_params#

Add soc_init to the runtime-params object the http-request node will send:

msg.payload = msg.payload || {};
msg.payload.soc_init = soc_init_clamped;
return msg;

Expected: when this function-node fires, msg.payload.soc_init is a fraction in [SOC_MIN, SOC_MAX], and the downstream http request node POSTs it to EMHASS as part of the MPC call. EMHASS’s response SOC_opt series now starts from this real-current value.

Caveats#

The following are observed-in-production patterns from months running this normalization across multiple sensor sources.

  • #1 bug: percent vs fraction. EMHASS works in fraction (0..1). Most sensor sources expose percent (0..100). Symptoms of getting it wrong: optimizer plans aggressive discharge (thinks battery is “full” at 80 because it sees 0.8 as 80% margin headroom), or plans aggressive charge (thinks battery is empty). See the Plan-output schema for the symmetric output-side scaling trap on SOC_opt.

  • Dual-format-aware code. The same battery in different transport stacks can publish fraction or percent. Production has seen the same inverter exposed as fraction over Modbus and as percent over the manufacturer’s cloud API on the same day. The Step-3 guard handles both — keep it even if your current source is one format only; it costs nothing and saves you a debugging session after a vendor firmware update flips the unit.

  • Defensive fallback matters. A NaN propagated into runtime_params.soc_init can crash the parsing layer between your orchestrator and EMHASS before EMHASS gets the chance to reject the bound-violation cleanly. Always validate upstream.

  • Stale sensor. If your battery sensor publishes only on change and your MPC ticks regularly (e.g. every 5-15 min), a long idle period can serve a stale SOC. Wire a delay node with last value semantics, or read a “last_updated” timestamp and reject readings older than ~2× MPC period.

  • Bound rejection is silent in the optimizer. EMHASS only logs a warning when soc_init is out of range; the solve continues with the fallback (battery_target_state_of_charge). Step 4 catches this client-side so the rejection is visible.

  • Hardware BMS still owns safety. EMHASS does not enforce battery safety limits — it computes a plan. Your battery’s BMS / inverter must still enforce its own thermal, voltage, and current limits. EMHASS plans things the hardware can refuse.

Credits#

  • SOC fraction-vs-percent gotcha discovered while building PR #835 plan-output schema doc. See docs/plan_output_schema.md (once #835 merges) for the symmetric output-side story.

  • Dual-format robustness + defensive fallback patterns extracted from author’s production Node-RED setup (months in service). Generic only — no private sensor names, IPs, or stack-specific identifiers copied.

  • Field names verified against src/emhass/utils.py:treat_runtimeparams and src/emhass/optimization.py battery constraints on 2026-05-11.