@enyo-energy/energy-app-sdk 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -11
- package/dist/cjs/implementations/appliance-command-forecast/appliance-command-forecast-validators.cjs +69 -1
- package/dist/cjs/implementations/appliance-command-forecast/appliance-command-forecast-validators.d.cts +21 -1
- package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.cjs +89 -3
- package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.d.cts +15 -0
- package/dist/cjs/types/enyo-appliance-command-forecast.d.cts +12 -0
- package/dist/cjs/types/enyo-data-bus-value.cjs +156 -5
- package/dist/cjs/types/enyo-data-bus-value.d.cts +382 -5
- package/dist/cjs/types/enyo-energy-manager-settings.cjs +48 -2
- package/dist/cjs/types/enyo-energy-manager-settings.d.cts +158 -2
- package/dist/cjs/version.cjs +1 -1
- package/dist/cjs/version.d.cts +1 -1
- package/dist/implementations/appliance-command-forecast/appliance-command-forecast-validators.d.ts +21 -1
- package/dist/implementations/appliance-command-forecast/appliance-command-forecast-validators.js +69 -2
- package/dist/implementations/energy-manager-settings/energy-manager-settings-validators.d.ts +15 -0
- package/dist/implementations/energy-manager-settings/energy-manager-settings-validators.js +89 -3
- package/dist/types/enyo-appliance-command-forecast.d.ts +12 -0
- package/dist/types/enyo-data-bus-value.d.ts +382 -5
- package/dist/types/enyo-data-bus-value.js +155 -4
- package/dist/types/enyo-energy-manager-settings.d.ts +158 -2
- package/dist/types/enyo-energy-manager-settings.js +48 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1646,10 +1646,14 @@ A closed, SDK-defined set of user-facing controls the active energy manager hono
|
|
|
1646
1646
|
| `BatteryEvDischargeMode` | `fixed-wh` \| `soc-limit` \| `intelligent` \| `block-discharge` \| `unmanaged` |
|
|
1647
1647
|
| `BatteryEvDischargeFixedWh` | number, **Wh** (only under `fixed-wh`) |
|
|
1648
1648
|
| `BatteryEvDischargeSocLimitPercent` | number, **%** floor (only under `soc-limit`) |
|
|
1649
|
+
| `BatteryPvPriorityTargetSocPercent` | number \| `null`, **%** target the battery is charged to from PV before other surplus consumers (absent / `null` = disabled) |
|
|
1650
|
+
| `BatteryDischargeBlockAboveHomeConsumptionW` | number \| `null`, **W** home consumption above which the battery may not discharge (absent / `null` = disabled) |
|
|
1649
1651
|
| `HeatpumpControl` | boolean |
|
|
1650
1652
|
| `HeatingRodControl` | boolean |
|
|
1651
1653
|
| `HeatingRodMode` | `pv-surplus-only` \| `boost` |
|
|
1654
|
+
| `HeatingRodBatteryDischargeSocLimitPercent` | number \| `null`, **%** floor the battery may feed the heating rod down to (absent / `null` = disabled) |
|
|
1652
1655
|
| `ChargerControl` | boolean |
|
|
1656
|
+
| `GridOptimizedEvCharging` | boolean, off by default — cost-optimised charging on a static / time-of-use tariff avoids high grid load (by EPEX spot) |
|
|
1653
1657
|
| `DefaultChargeMode` | `EnyoChargeModeEnum` |
|
|
1654
1658
|
| `PriceLimitMode` | `ct-per-kwh` \| `cheapest-share` (absent = no ceiling) |
|
|
1655
1659
|
| `PriceLimitCtPerKwh` | number, **ct/kWh** (`7` = 7 ct/kWh; only under `ct-per-kwh`) |
|
|
@@ -1694,7 +1698,11 @@ Three things to get right:
|
|
|
1694
1698
|
|
|
1695
1699
|
- **Absent is not `false`.** `undefined` means unsupported or never chosen; `false` means the user switched it off. Check `supported` first, then the value — collapsing the two steers hardware someone deliberately disabled.
|
|
1696
1700
|
- **How the battery may feed a charging EV is one mode plus its parameter.** `BatteryEvDischargeMode` picks the strategy; `BatteryEvDischargeFixedWh` and `BatteryEvDischargeSocLimitPercent` are read only under their own mode. `block-discharge` (actively hold the battery back) and `unmanaged` (do not intervene either way) are **not** synonyms.
|
|
1697
|
-
- **
|
|
1701
|
+
- **Battery-first PV surplus is a charging target, not a floor.** `BatteryPvPriorityTargetSocPercent` (`40` = charge the battery to 40 % first) only holds back consumers that run *on surplus* — an EV on PV, a `pv-surplus-only` heating rod, heat pump surplus boosts, surplus automations. Grid-backed modes and explicit user requests are unaffected. It is independent of `BatteryEvDischargeSocLimitPercent`, which is a floor for discharging into the car. Disabled while absent or `null`; write `null` to switch it off again.
|
|
1702
|
+
- **The heating rod may draw from the battery only down to a floor, and only when asked.** `HeatingRodBatteryDischargeSocLimitPercent` (`80` = the rod may use the battery while it holds more than 80 %) is disabled while absent or `null`. It applies under both heating rod modes — under `pv-surplus-only` the battery above the floor counts as supply alongside PV, still never a grid import. It needs `batteryControl` to be enforced, and should not lie below `BatteryPvPriorityTargetSocPercent`; the validator warns on both. A consumer held back by it carries the command reason `BatteryPvPriority` (category `PvSurplus`) with `context.batteryPvPriority.targetSocPercent` and `socPercent`.
|
|
1703
|
+
- **The battery can be blocked from discharging under a heavy load.** `BatteryDischargeBlockAboveHomeConsumptionW` (`11000` = no discharge while the house draws more than 11 kW) keeps a sauna or an oven from emptying the battery in minutes. It is in **watts** — the validator warns below 100 W, which catches a kW value. Home consumption means the household plus every appliance the energy manager does **not** track — the same quantity as `homeConsumptionW` in the home consumption timeseries. Tracked appliances (wallbox, heat pump, heating rod, …) and the battery's own charging are excluded, so a charging car alone never trips the block. The block wins over the EV and heating rod discharge permissions; charging is unaffected. Disabled while absent or `null`. A battery held by it carries `HomeConsumptionHigh` with `context.batteryDischargeBlock.thresholdW` and `powerW` set to the current consumption.
|
|
1704
|
+
- **Grid-optimised EV charging is about the grid, not the bill.** `GridOptimizedEvCharging` adds no charge mode. It only affects `cost-optimized` sessions while the tariff is static or time-of-use (no effect under a dynamic one): the grid energy PV does not cover is placed in the hours with the lowest EPEX spot price before the deadline — the spot price as a proxy for grid load — instead of as late as possible or in the tariff's cheap windows. PV still comes first, the deadline still holds, and a session's price ceiling still applies against the customer's own tariff. On a time-of-use tariff a low-load hour can be a more expensive one; that is the trade the owner opts into.
|
|
1705
|
+
- **Fifteen settings are gated by another** (`heatingRodMode` needs `heatingRodControl`, `priceLimitCtPerKwh` only applies under `priceLimitMode: 'ct-per-kwh'`, …). The tree is exported as `ENERGY_MANAGER_SETTING_DEPENDENCIES`, with two helpers over it: `getEnergyManagerSettingDependency(setting)` returns the direct gate and the value it must hold (or `null` for an ungated root), and `isEnergyManagerSettingActive(setting, values)` walks the whole chain — the gates are now two deep, so a Wh budget is live only when the mode is `fixed-wh` *and* battery control is on.
|
|
1698
1706
|
- **`priceLimitCtPerKwh` is in cents**, unlike the SDK's machine-readable price fields (`electricityPricePerKwh`, EUR/kWh). Divide by 100 when comparing. `validateEnergyManagerSettingsState()` warns on values outside a plausible ct band, which catches the mix-up.
|
|
1699
1707
|
- **A price ceiling is one mode plus its parameter**, the same shape as the battery-to-EV one. `PriceLimitMode` picks how the ceiling is expressed — an absolute `ct-per-kwh` or a relative `cheapest-share` — and each value is read only under its own mode; the mode being absent means no ceiling at all. A relative ceiling ranks the price intervals *known* for the day and treats the cheapest `n` % as importable (the same rule as the `cheapest-share-of-day` automation trigger), so its effective threshold moves when tomorrow's prices publish — re-evaluate it rather than resolving it once.
|
|
1700
1708
|
|
|
@@ -3514,33 +3522,58 @@ Both are for marking on the same power scale a consumer already draws from `maxC
|
|
|
3514
3522
|
|
|
3515
3523
|
### BatteryCommandForecast
|
|
3516
3524
|
|
|
3517
|
-
|
|
3525
|
+
A discriminated union on `mode`:
|
|
3526
|
+
|
|
3527
|
+
- `mode: 'auto'` — hand the battery back to its own logic over the horizon; no schedule. May carry an optional `reason` (`EnyoDataBusCommandReason`) saying **why** nothing is prescribed — e.g. `PriceSpreadTooSmall` when trading would not pay. Set it only when every Auto slot of the horizon shares the same reason; when they differ, leave it out rather than guess. Without it, an empty Auto forecast tends to read as "no forecast".
|
|
3528
|
+
- `mode: 'schedule'` — follow `relativeSchedule`, a list of `{seconds, direction, powerW}` setpoints, where `direction` is `charge`, `discharge` or `idle`.
|
|
3518
3529
|
|
|
3519
3530
|
The type is named `BatteryCommandForecast` to make the distinction with the existing [`BatteryForecast`](#batteryforecast) class (which forecasts state-of-charge from history) explicit.
|
|
3520
3531
|
|
|
3521
3532
|
```typescript
|
|
3522
3533
|
import {
|
|
3534
|
+
ApplianceForecastResolutionEnum,
|
|
3523
3535
|
BatteryCommandForecast,
|
|
3536
|
+
BatteryCommandForecastDirectionEnum,
|
|
3524
3537
|
BatteryCommandForecastModeEnum,
|
|
3538
|
+
EnyoCurrencyEnum,
|
|
3539
|
+
EnyoDataBusCommandReasonCategoryEnum,
|
|
3540
|
+
EnyoDataBusCommandReasonTypeEnum,
|
|
3525
3541
|
} from '@enyo-energy/energy-app-sdk';
|
|
3526
3542
|
|
|
3527
|
-
|
|
3543
|
+
// A planned cadence:
|
|
3544
|
+
const scheduled: BatteryCommandForecast = {
|
|
3545
|
+
resolution: ApplianceForecastResolutionEnum.FifteenMinutes,
|
|
3546
|
+
mode: BatteryCommandForecastModeEnum.Schedule,
|
|
3528
3547
|
relativeSchedule: [
|
|
3529
|
-
{ seconds: 0,
|
|
3530
|
-
{ seconds: 1800,
|
|
3531
|
-
{ seconds: 3600,
|
|
3548
|
+
{ seconds: 0, direction: BatteryCommandForecastDirectionEnum.Charge, powerW: 3000 },
|
|
3549
|
+
{ seconds: 1800, direction: BatteryCommandForecastDirectionEnum.Discharge, powerW: 2500 },
|
|
3550
|
+
{ seconds: 3600, direction: BatteryCommandForecastDirectionEnum.Idle, powerW: 0 },
|
|
3532
3551
|
],
|
|
3533
3552
|
estimatedSavings: { costSavings: 0.18, currency: 'EUR' },
|
|
3534
3553
|
};
|
|
3554
|
+
await forecasts.publishBatteryForecast('battery-1', scheduled);
|
|
3535
3555
|
|
|
3536
|
-
|
|
3556
|
+
// Nothing planned, and why:
|
|
3557
|
+
const auto: BatteryCommandForecast = {
|
|
3558
|
+
resolution: ApplianceForecastResolutionEnum.FifteenMinutes,
|
|
3559
|
+
mode: BatteryCommandForecastModeEnum.Auto,
|
|
3560
|
+
reason: {
|
|
3561
|
+
type: EnyoDataBusCommandReasonTypeEnum.PriceSpreadTooSmall,
|
|
3562
|
+
category: EnyoDataBusCommandReasonCategoryEnum.EnergyPrice,
|
|
3563
|
+
electricityPricePerKwh: 0.352,
|
|
3564
|
+
currency: EnyoCurrencyEnum.EUR,
|
|
3565
|
+
context: { arbitrage: { buyPricePerKwh: 0.352, breakEvenPricePerKwh: 0.527, sellPricePerKwh: 0.429, roundTripEfficiency: 0.757 } },
|
|
3566
|
+
},
|
|
3567
|
+
};
|
|
3568
|
+
await forecasts.publishBatteryForecast('battery-1', auto);
|
|
3537
3569
|
```
|
|
3538
3570
|
|
|
3539
|
-
|
|
3571
|
+
Invariants:
|
|
3540
3572
|
|
|
3541
|
-
- `
|
|
3542
|
-
- `
|
|
3543
|
-
- `
|
|
3573
|
+
- `mode: 'auto'` must not carry a `relativeSchedule`. A `reason` must have a valid `type`; its `context.arbitrage` prices must be EUR/kWh within a plausible band (a ct value such as `35.2` is rejected), `roundTripEfficiency` must lie in (0, 1], and each window's start must lie before its end.
|
|
3574
|
+
- `seconds`: finite, non-negative; first entry `= 0`; strictly increasing; a multiple of the `resolution` step.
|
|
3575
|
+
- `direction`: one of `charge` / `discharge` / `idle`. `idle` hands the slot to the battery's own logic — to block charging or discharging, use that direction with `powerW = 0` instead.
|
|
3576
|
+
- `powerW`: finite, non-negative. **MUST be `0` when `direction === 'idle'`.**
|
|
3544
3577
|
|
|
3545
3578
|
### HeatpumpForecast
|
|
3546
3579
|
|
|
@@ -4180,6 +4213,20 @@ New reason types came with this surface, so a skipped row can say *why* instead
|
|
|
4180
4213
|
|
|
4181
4214
|
**Battery vs. charging car.** While a car charges, the house battery is either held out of it or allowed to help, according to the owner's `batteryEvDischargeMode`. Two reason types (category `BatteryState`) say which: `BatteryReservedFromEv` for the hold (`Discharge 0`) and `BatterySupportsEvCharging` for the release (`mode=Auto`). Both carry `context.batteryToEv` (`EnyoDataBusCommandReasonBatteryToEvContext`) with the owner's `mode`, the hold `trigger` (`EnyoBatteryToEvHoldTriggerEnum`: `OwnerBlocked`, `SocLimitReached`, `AllowanceSpent`, `NoMeasurement`), `socLimitPercent`, `allowanceWh` and `remainingWh`. The energy manager re-checks a hold periodically by briefly releasing the battery; that re-check sets `batteryToEv.probe: true` and should not be listed as its own entry in a command history.
|
|
4182
4215
|
|
|
4216
|
+
**Battery first on PV surplus.** When a surplus consumer (EV on PV, heating rod, heat pump boost, surplus automation) is held back or reduced because the battery is charged first up to the owner's `batteryPvPriorityTargetSocPercent`, the energy manager states `BatteryPvPriority` (category `PvSurplus`) on that consumer: `context.batteryPvPriority.targetSocPercent` for the target, `socPercent` for the pack's current SoC and `powerW` for the surplus the battery is taking — e.g. "The battery charges to 40 % first (now 28 %)."
|
|
4217
|
+
|
|
4218
|
+
**Battery trading: why or why not.** The `arbitrage` context (`EnyoDataBusCommandReasonArbitrageContext`) carries the trough-against-peak arithmetic behind a battery buy: `buyPricePerKwh` / `buyStartIso` / `buyEndIso`, `sellPricePerKwh` / `sellStartIso` / `sellEndIso`, `breakEvenPricePerKwh` (losses, wear and margin included) and `roundTripEfficiency` (a fraction, `0.757`). Prices are **EUR/kWh like every machine-readable price in the SDK** — `0.336`, not `33.6`. Three uses:
|
|
4219
|
+
|
|
4220
|
+
- `PriceSpreadTooSmall` (category `EnergyPrice`) — the battery stays on Auto because buying in the trough and using the energy at the peak would not pay back: "Charging at 35.2 ct/kWh only pays off from 52.7 ct/kWh, but the peak averages 42.9 ct/kWh." Set `electricityPricePerKwh` to the buy price and all the figures; on a flat horizon send it without `arbitrage`. The trough is not "too expensive" — the gap is too small — so this is not `ElectricityPriceAboveThreshold`. Where PV makes Auto mean self-consumption, `SelfConsumptionOptimization` stays the right type.
|
|
4221
|
+
- `ElectricityPriceBelowThreshold` on a grid charge — the buy and the peak it is for: "Charging at 33.6 ct for the 17:00 peak at 43.7 ct."
|
|
4222
|
+
- `ScheduledOptimization` on a price hold (`Discharge 0`) — `sellStartIso` / `sellPricePerKwh`: "Stored energy kept for 17:00 (43.7 ct)." Not `ElectricityPriceBelowThreshold`, which would say power is cheap for a command that stops the battery.
|
|
4223
|
+
|
|
4224
|
+
**Installation limits.** Power capped by a limit the owner or installer set — not the grid operator — carries `InstallationLimit` (category `Contention`) with `powerW` set to the ceiling and `context.installationLimit` = `{source, limitW}`, where `source` is `EnyoInstallationLimitSourceEnum.DesiredGridLimit` (`desiredKwLimit`) or `ChargerLimit` (`chargerLimitW`). `limitW` is in **W** even for the desired limit, which is configured in kW. `GridOperatorPowerLimitationActive` stays reserved for limits the grid operator imposed.
|
|
4225
|
+
|
|
4226
|
+
**Grid-optimised charging.** A cost-optimised session placed in, or waiting for, a low-load hour because of `gridOptimizedEvCharging` carries `GridLoadOptimized` (category `Schedule`) with `context.placement` (chosen window and deadline) and `context.gridLoad` (`epexPricePerKwh`, `currency`). The EPEX price is a grid-load proxy, not what the customer pays, so the text must not promise a saving — e.g. "Charging from 02:00, when the grid is less busy — ready by 07:00."
|
|
4227
|
+
|
|
4228
|
+
**Battery vs. heating rod.** When the owner set `heatingRodBatteryDischargeSocLimitPercent`, two reason types (category `BatteryState`) say whether the battery feeds the rod: `BatterySupportsHeatingRod` while it may (with `powerW` for its contribution), `BatteryReservedFromHeatingRod` once it reaches the floor. Both carry `context.batteryToHeatingRod.socLimitPercent` and `socPercent` — e.g. "Heating rod no longer runs from the battery — it is at your 80 % limit."
|
|
4229
|
+
|
|
4183
4230
|
**Short-cycling protection.** When an appliance is kept running (or kept off) to honour its minimum on/off time, state `DeviceProtection` with `context.switching` (`EnyoDataBusCommandReasonSwitchingContext`): `hold` (`EnyoSwitchingProtectionHoldEnum.KeptOn` / `KeptOff`), `minOnSeconds`, `minOffSeconds` and `untilIso`. The text can then say "Kept running for another 4 minutes to protect the compressor" instead of a generic "Protecting the device".
|
|
4184
4231
|
|
|
4185
4232
|
A hold that keeps an appliance **off** after a stop has its own type, `RestartDelay` (category `DeviceProtection`): set `context.switching` with `hold: KeptOff`, `minOffSeconds` and `untilIso` (when it may restart), and `powerW` to the power waiting for it — e.g. "Short pause after switching off — charging resumes at 13:15 so the car isn't switched on and off too often."
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ApplianceCommandForecastValidationError = void 0;
|
|
4
4
|
exports.validateChargerForecast = validateChargerForecast;
|
|
5
5
|
exports.validateBatteryCommandForecast = validateBatteryCommandForecast;
|
|
6
|
+
exports.validateBatteryForecastReason = validateBatteryForecastReason;
|
|
6
7
|
exports.validateHeatpumpForecast = validateHeatpumpForecast;
|
|
7
8
|
exports.validateChargerSchedule = validateChargerSchedule;
|
|
8
9
|
exports.validateBatterySchedule = validateBatterySchedule;
|
|
@@ -69,7 +70,9 @@ function validateChargerForecast(forecast) {
|
|
|
69
70
|
* {@link BatteryCommandForecastModeEnum}: when `mode = 'auto'` no
|
|
70
71
|
* schedule is expected; when `mode = 'schedule'` the embedded
|
|
71
72
|
* {@link BatteryCommandForecastScheduled.relativeSchedule} is validated
|
|
72
|
-
* by {@link validateBatterySchedule}.
|
|
73
|
+
* by {@link validateBatterySchedule}. An Auto forecast's optional
|
|
74
|
+
* {@link BatteryCommandForecastAuto.reason} is checked by
|
|
75
|
+
* {@link validateBatteryForecastReason}.
|
|
73
76
|
*/
|
|
74
77
|
function validateBatteryCommandForecast(forecast) {
|
|
75
78
|
if (!forecast || typeof forecast !== 'object') {
|
|
@@ -84,10 +87,75 @@ function validateBatteryCommandForecast(forecast) {
|
|
|
84
87
|
if (forecast.relativeSchedule !== undefined) {
|
|
85
88
|
throw new ApplianceCommandForecastValidationError("BatteryCommandForecast with mode='auto' must not carry a relativeSchedule.");
|
|
86
89
|
}
|
|
90
|
+
if (forecast.reason !== undefined) {
|
|
91
|
+
validateBatteryForecastReason(forecast.reason, 'BatteryCommandForecast.reason');
|
|
92
|
+
}
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
95
|
validateBatterySchedule(forecast.relativeSchedule, forecast.resolution);
|
|
90
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Lowest and highest plausible price per kWh in a reason's `arbitrage`
|
|
99
|
+
* context, in EUR/kWh. Wide enough for negative and spiking dynamic prices,
|
|
100
|
+
* narrow enough to catch a ct value (`33.6`) in a EUR field.
|
|
101
|
+
*/
|
|
102
|
+
const ARBITRAGE_PRICE_MIN_PER_KWH = -1;
|
|
103
|
+
const ARBITRAGE_PRICE_MAX_PER_KWH = 5;
|
|
104
|
+
/**
|
|
105
|
+
* Validates the reason attached to an Auto {@link BatteryCommandForecast}.
|
|
106
|
+
* Throws on the first violation.
|
|
107
|
+
*
|
|
108
|
+
* Checks that the reason is an object whose `type` is an
|
|
109
|
+
* {@link EnyoDataBusCommandReasonTypeEnum} member and, when it carries
|
|
110
|
+
* `context.arbitrage`, that:
|
|
111
|
+
* - every price is a finite EUR/kWh figure within a plausible band (catches
|
|
112
|
+
* ct in a EUR field),
|
|
113
|
+
* - `roundTripEfficiency` lies in (0, 1] (catches `75.7` for `0.757`),
|
|
114
|
+
* - each window's start lies before its end, where both are given.
|
|
115
|
+
*
|
|
116
|
+
* @param reason - The reason to validate.
|
|
117
|
+
* @param fieldName - Field path used in error messages.
|
|
118
|
+
* @throws {ApplianceCommandForecastValidationError} On the first violation.
|
|
119
|
+
*/
|
|
120
|
+
function validateBatteryForecastReason(reason, fieldName) {
|
|
121
|
+
if (!reason || typeof reason !== 'object') {
|
|
122
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName} must be an object.`);
|
|
123
|
+
}
|
|
124
|
+
validateEnumField(reason.type, Object.values(enyo_data_bus_value_js_1.EnyoDataBusCommandReasonTypeEnum), `${fieldName}.type`, true);
|
|
125
|
+
const arbitrage = reason.context?.arbitrage;
|
|
126
|
+
if (arbitrage === undefined)
|
|
127
|
+
return;
|
|
128
|
+
for (const key of ['buyPricePerKwh', 'sellPricePerKwh', 'breakEvenPricePerKwh']) {
|
|
129
|
+
const price = arbitrage[key];
|
|
130
|
+
if (price === undefined)
|
|
131
|
+
continue;
|
|
132
|
+
if (!Number.isFinite(price)) {
|
|
133
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} must be a finite number; got ${price}.`);
|
|
134
|
+
}
|
|
135
|
+
if (price < ARBITRAGE_PRICE_MIN_PER_KWH || price > ARBITRAGE_PRICE_MAX_PER_KWH) {
|
|
136
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} is ${price}, outside ` +
|
|
137
|
+
`${ARBITRAGE_PRICE_MIN_PER_KWH}…${ARBITRAGE_PRICE_MAX_PER_KWH} — prices are per kWh in ` +
|
|
138
|
+
'the reason\'s currency (EUR/kWh), not ct/kWh.');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const efficiency = arbitrage.roundTripEfficiency;
|
|
142
|
+
if (efficiency !== undefined && (!Number.isFinite(efficiency) || efficiency <= 0 || efficiency > 1)) {
|
|
143
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.roundTripEfficiency is ${efficiency}; it is a fraction in (0, 1], ` +
|
|
144
|
+
'e.g. 0.757 rather than 75.7.');
|
|
145
|
+
}
|
|
146
|
+
for (const [start, end] of [['buyStartIso', 'buyEndIso'], ['sellStartIso', 'sellEndIso']]) {
|
|
147
|
+
const startIso = arbitrage[start];
|
|
148
|
+
const endIso = arbitrage[end];
|
|
149
|
+
for (const [key, iso] of [[start, startIso], [end, endIso]]) {
|
|
150
|
+
if (iso !== undefined && Number.isNaN(Date.parse(iso))) {
|
|
151
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${key} is not an ISO 8601 timestamp: ${iso}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (startIso !== undefined && endIso !== undefined && Date.parse(startIso) >= Date.parse(endIso)) {
|
|
155
|
+
throw new ApplianceCommandForecastValidationError(`${fieldName}.context.arbitrage.${start} must lie before ${end}.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
91
159
|
/**
|
|
92
160
|
* Validates a {@link HeatpumpForecast}. Throws on the first violation —
|
|
93
161
|
* the error message names the offending field / index.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AirConditioningForecast, AirConditioningForecastScheduleEntry, ApplianceForecastResolutionEnum, BatteryCommandForecast, BatteryCommandForecastScheduleEntry, ChargerForecast, ChargerForecastScheduleEntry, HeatingRodForecast, HeatingRodForecastScheduleEntry, HeatpumpForecast, HeatpumpForecastScheduleEntry, SmartPlugForecast, SmartPlugForecastScheduleEntry } from '../../types/enyo-appliance-command-forecast.cjs';
|
|
2
|
+
import { type EnyoDataBusCommandReason } from '../../types/enyo-data-bus-value.cjs';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown when a forecast payload passed to one of the validators (or to
|
|
4
5
|
* {@link EnergyAppApplianceEnergyManagerForecast.publishChargerForecast}
|
|
@@ -23,9 +24,28 @@ export declare function validateChargerForecast(forecast: ChargerForecast): void
|
|
|
23
24
|
* {@link BatteryCommandForecastModeEnum}: when `mode = 'auto'` no
|
|
24
25
|
* schedule is expected; when `mode = 'schedule'` the embedded
|
|
25
26
|
* {@link BatteryCommandForecastScheduled.relativeSchedule} is validated
|
|
26
|
-
* by {@link validateBatterySchedule}.
|
|
27
|
+
* by {@link validateBatterySchedule}. An Auto forecast's optional
|
|
28
|
+
* {@link BatteryCommandForecastAuto.reason} is checked by
|
|
29
|
+
* {@link validateBatteryForecastReason}.
|
|
27
30
|
*/
|
|
28
31
|
export declare function validateBatteryCommandForecast(forecast: BatteryCommandForecast): void;
|
|
32
|
+
/**
|
|
33
|
+
* Validates the reason attached to an Auto {@link BatteryCommandForecast}.
|
|
34
|
+
* Throws on the first violation.
|
|
35
|
+
*
|
|
36
|
+
* Checks that the reason is an object whose `type` is an
|
|
37
|
+
* {@link EnyoDataBusCommandReasonTypeEnum} member and, when it carries
|
|
38
|
+
* `context.arbitrage`, that:
|
|
39
|
+
* - every price is a finite EUR/kWh figure within a plausible band (catches
|
|
40
|
+
* ct in a EUR field),
|
|
41
|
+
* - `roundTripEfficiency` lies in (0, 1] (catches `75.7` for `0.757`),
|
|
42
|
+
* - each window's start lies before its end, where both are given.
|
|
43
|
+
*
|
|
44
|
+
* @param reason - The reason to validate.
|
|
45
|
+
* @param fieldName - Field path used in error messages.
|
|
46
|
+
* @throws {ApplianceCommandForecastValidationError} On the first violation.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validateBatteryForecastReason(reason: EnyoDataBusCommandReason, fieldName: string): void;
|
|
29
49
|
/**
|
|
30
50
|
* Validates a {@link HeatpumpForecast}. Throws on the first violation —
|
|
31
51
|
* the error message names the offending field / index.
|
package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.cjs
CHANGED
|
@@ -67,6 +67,15 @@ const PRICE_LIMIT_MAX_CT = 200;
|
|
|
67
67
|
* warned about for duplicating mode `block-discharge` rather than for being a unit slip.
|
|
68
68
|
*/
|
|
69
69
|
const DISCHARGE_BUDGET_MIN_PLAUSIBLE_WH = 100;
|
|
70
|
+
/**
|
|
71
|
+
* Below this, a
|
|
72
|
+
* {@link EnergyManagerSettingValues.batteryDischargeBlockAboveHomeConsumptionW}
|
|
73
|
+
* threshold is almost certainly kW entered into a W field.
|
|
74
|
+
*
|
|
75
|
+
* `11` would block the battery above 11 W — that is, whenever the house draws
|
|
76
|
+
* anything — where 11 kW was plainly meant. `0` is caught by the same check.
|
|
77
|
+
*/
|
|
78
|
+
const HOME_CONSUMPTION_BLOCK_MIN_PLAUSIBLE_W = 100;
|
|
70
79
|
/** Every {@link EnergyManagerBatteryEvDischargeModeEnum} value. */
|
|
71
80
|
const EV_DISCHARGE_MODES = new Set(Object.values(enyo_energy_manager_settings_js_1.EnergyManagerBatteryEvDischargeModeEnum));
|
|
72
81
|
/** Value enums checked per field, for the enum-valued settings. */
|
|
@@ -139,6 +148,21 @@ function gateValue(values, setting) {
|
|
|
139
148
|
* duplicates mode `block-discharge`, the second catches a kWh value in a Wh field).
|
|
140
149
|
* - `batteryEvDischargeSocLimitPercent` is a finite number in 0…100 (**error**),
|
|
141
150
|
* and not `100` (**warning** — a floor nothing can discharge past).
|
|
151
|
+
* - `batteryPvPriorityTargetSocPercent`, unless `null` (disabled), is a finite
|
|
152
|
+
* number in 0…100 (**error**), and neither `0` (**warning** — no priority at
|
|
153
|
+
* all, write `null`) nor `100` (**warning** — charging tapers near full, so
|
|
154
|
+
* surplus consumers can be held back for hours).
|
|
155
|
+
* - `heatingRodBatteryDischargeSocLimitPercent`, unless `null` (disabled), is a
|
|
156
|
+
* finite number in 0…100 (**error**), and not `100` (**warning** — a floor
|
|
157
|
+
* nothing can discharge past). Set while `batteryControl` is not `true`
|
|
158
|
+
* (**warning** — the floor cannot be enforced), or below
|
|
159
|
+
* `batteryPvPriorityTargetSocPercent` (**warning** — the rod drains what the
|
|
160
|
+
* PV priority refills).
|
|
161
|
+
* - `batteryDischargeBlockAboveHomeConsumptionW`, unless `null` (disabled), is
|
|
162
|
+
* a finite non-negative number (**error**), and not below 100 W (**warning**
|
|
163
|
+
* — catches a kW value in a W field; the battery would be blocked almost
|
|
164
|
+
* always).
|
|
165
|
+
* - `null` counts as unset for the "stored" checks below.
|
|
142
166
|
* - A parameter belonging to a mode other than the selected one is flagged by
|
|
143
167
|
* the gate check below, since {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}
|
|
144
168
|
* gates each on its own mode.
|
|
@@ -184,7 +208,7 @@ function validateEnergyManagerSettingsState(state) {
|
|
|
184
208
|
const values = state.values ?? {};
|
|
185
209
|
// ── value formats ──────────────────────────────────────────────────────
|
|
186
210
|
for (const key of ['batteryControl', 'batteryChargeFromGrid', 'heatpumpControl',
|
|
187
|
-
'heatingRodControl', 'chargerControl']) {
|
|
211
|
+
'heatingRodControl', 'chargerControl', 'gridOptimizedEvCharging']) {
|
|
188
212
|
if (values[key] !== undefined && typeof values[key] !== 'boolean') {
|
|
189
213
|
errors.push(`\`${key}\` must be a boolean when set.`);
|
|
190
214
|
}
|
|
@@ -271,6 +295,67 @@ function validateEnergyManagerSettingsState(state) {
|
|
|
271
295
|
'states that intent directly.');
|
|
272
296
|
}
|
|
273
297
|
}
|
|
298
|
+
if (values.batteryPvPriorityTargetSocPercent !== undefined
|
|
299
|
+
&& values.batteryPvPriorityTargetSocPercent !== null) {
|
|
300
|
+
const target = values.batteryPvPriorityTargetSocPercent;
|
|
301
|
+
if (typeof target !== 'number' || !Number.isFinite(target)) {
|
|
302
|
+
errors.push('`batteryPvPriorityTargetSocPercent` must be a finite number when set.');
|
|
303
|
+
}
|
|
304
|
+
else if (target < 0 || target > 100) {
|
|
305
|
+
errors.push(`\`batteryPvPriorityTargetSocPercent\` is ${target}; it is a percentage and must lie ` +
|
|
306
|
+
'between 0 and 100.');
|
|
307
|
+
}
|
|
308
|
+
else if (target === 0) {
|
|
309
|
+
warnings.push('`batteryPvPriorityTargetSocPercent` is 0, which gives the battery no priority at all — ' +
|
|
310
|
+
'write `null` to say that outright.');
|
|
311
|
+
}
|
|
312
|
+
else if (target === 100) {
|
|
313
|
+
warnings.push('`batteryPvPriorityTargetSocPercent` is 100. Charging tapers as the battery nears full, ' +
|
|
314
|
+
'so PV-surplus consumers may be held back for hours waiting for the last percent.');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (values.batteryDischargeBlockAboveHomeConsumptionW !== undefined
|
|
318
|
+
&& values.batteryDischargeBlockAboveHomeConsumptionW !== null) {
|
|
319
|
+
const threshold = values.batteryDischargeBlockAboveHomeConsumptionW;
|
|
320
|
+
if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
|
|
321
|
+
errors.push('`batteryDischargeBlockAboveHomeConsumptionW` must be a finite number or null when set.');
|
|
322
|
+
}
|
|
323
|
+
else if (threshold < 0) {
|
|
324
|
+
errors.push(`\`batteryDischargeBlockAboveHomeConsumptionW\` is ${threshold}; a consumption threshold ` +
|
|
325
|
+
'cannot be negative.');
|
|
326
|
+
}
|
|
327
|
+
else if (threshold < HOME_CONSUMPTION_BLOCK_MIN_PLAUSIBLE_W) {
|
|
328
|
+
warnings.push(`\`batteryDischargeBlockAboveHomeConsumptionW\` is ${threshold} W, so the battery would be ` +
|
|
329
|
+
'blocked almost always — this field is in watts, not kilowatts.');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (values.heatingRodBatteryDischargeSocLimitPercent !== undefined
|
|
333
|
+
&& values.heatingRodBatteryDischargeSocLimitPercent !== null) {
|
|
334
|
+
const floor = values.heatingRodBatteryDischargeSocLimitPercent;
|
|
335
|
+
if (typeof floor !== 'number' || !Number.isFinite(floor)) {
|
|
336
|
+
errors.push('`heatingRodBatteryDischargeSocLimitPercent` must be a finite number or null when set.');
|
|
337
|
+
}
|
|
338
|
+
else if (floor < 0 || floor > 100) {
|
|
339
|
+
errors.push(`\`heatingRodBatteryDischargeSocLimitPercent\` is ${floor}; it is a percentage and must lie ` +
|
|
340
|
+
'between 0 and 100.');
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
if (floor === 100) {
|
|
344
|
+
warnings.push('`heatingRodBatteryDischargeSocLimitPercent` is 100, a floor the battery is already at, ' +
|
|
345
|
+
'so the rod can never draw from it — write `null` to disable it outright.');
|
|
346
|
+
}
|
|
347
|
+
if (values.batteryControl !== true) {
|
|
348
|
+
warnings.push('`heatingRodBatteryDischargeSocLimitPercent` is set, but `batteryControl` is not true — ' +
|
|
349
|
+
'the energy manager cannot hold the battery at the floor.');
|
|
350
|
+
}
|
|
351
|
+
const priority = values.batteryPvPriorityTargetSocPercent;
|
|
352
|
+
if (typeof priority === 'number' && Number.isFinite(priority) && floor < priority) {
|
|
353
|
+
warnings.push(`\`heatingRodBatteryDischargeSocLimitPercent\` (${floor} %) is below ` +
|
|
354
|
+
`\`batteryPvPriorityTargetSocPercent\` (${priority} %) — the rod drains the battery ` +
|
|
355
|
+
'into a range the PV priority then refills.');
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
274
359
|
if (values.costOptimizedTargetTime !== undefined) {
|
|
275
360
|
if (typeof values.costOptimizedTargetTime !== 'string'
|
|
276
361
|
|| !TIME_RE.test(values.costOptimizedTargetTime)) {
|
|
@@ -290,7 +375,7 @@ function validateEnergyManagerSettingsState(state) {
|
|
|
290
375
|
// ── stored but unsupported ─────────────────────────────────────────────
|
|
291
376
|
for (const setting of Object.values(enyo_energy_manager_settings_js_1.EnergyManagerSettingEnum)) {
|
|
292
377
|
const keys = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[setting];
|
|
293
|
-
const held = keys.filter((key) => values[key] !== undefined);
|
|
378
|
+
const held = keys.filter((key) => values[key] !== undefined && values[key] !== null);
|
|
294
379
|
if (held.length && !supported.has(setting)) {
|
|
295
380
|
warnings.push(`"${setting}" holds a value (${held.join(', ')}) but is not in \`supported\` — ` +
|
|
296
381
|
'the energy manager will ignore it.');
|
|
@@ -301,7 +386,8 @@ function validateEnergyManagerSettingsState(state) {
|
|
|
301
386
|
if (!dependency)
|
|
302
387
|
continue;
|
|
303
388
|
const key = setting;
|
|
304
|
-
const held = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[key]
|
|
389
|
+
const held = enyo_energy_manager_settings_js_1.ENERGY_MANAGER_SETTING_VALUE_KEYS[key]
|
|
390
|
+
.some((k) => values[k] !== undefined && values[k] !== null);
|
|
305
391
|
if (held && gateValue(values, dependency.requires) !== dependency.equals) {
|
|
306
392
|
warnings.push(`"${key}" holds a value, but it only takes effect while "${dependency.requires}" ` +
|
|
307
393
|
`is ${JSON.stringify(dependency.equals)} — it currently cannot.`);
|
package/dist/cjs/implementations/energy-manager-settings/energy-manager-settings-validators.d.cts
CHANGED
|
@@ -65,6 +65,21 @@ export interface EnergyManagerSettingsValidationResult {
|
|
|
65
65
|
* duplicates mode `block-discharge`, the second catches a kWh value in a Wh field).
|
|
66
66
|
* - `batteryEvDischargeSocLimitPercent` is a finite number in 0…100 (**error**),
|
|
67
67
|
* and not `100` (**warning** — a floor nothing can discharge past).
|
|
68
|
+
* - `batteryPvPriorityTargetSocPercent`, unless `null` (disabled), is a finite
|
|
69
|
+
* number in 0…100 (**error**), and neither `0` (**warning** — no priority at
|
|
70
|
+
* all, write `null`) nor `100` (**warning** — charging tapers near full, so
|
|
71
|
+
* surplus consumers can be held back for hours).
|
|
72
|
+
* - `heatingRodBatteryDischargeSocLimitPercent`, unless `null` (disabled), is a
|
|
73
|
+
* finite number in 0…100 (**error**), and not `100` (**warning** — a floor
|
|
74
|
+
* nothing can discharge past). Set while `batteryControl` is not `true`
|
|
75
|
+
* (**warning** — the floor cannot be enforced), or below
|
|
76
|
+
* `batteryPvPriorityTargetSocPercent` (**warning** — the rod drains what the
|
|
77
|
+
* PV priority refills).
|
|
78
|
+
* - `batteryDischargeBlockAboveHomeConsumptionW`, unless `null` (disabled), is
|
|
79
|
+
* a finite non-negative number (**error**), and not below 100 W (**warning**
|
|
80
|
+
* — catches a kW value in a W field; the battery would be blocked almost
|
|
81
|
+
* always).
|
|
82
|
+
* - `null` counts as unset for the "stored" checks below.
|
|
68
83
|
* - A parameter belonging to a mode other than the selected one is flagged by
|
|
69
84
|
* the gate check below, since {@link ENERGY_MANAGER_SETTING_DEPENDENCIES}
|
|
70
85
|
* gates each on its own mode.
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
import { EnyoAirConditioningApplianceModeEnum, EnyoAirConditioningOptimizationModeEnum } from './enyo-air-conditioning-appliance.cjs';
|
|
48
48
|
import { EnyoAutomationTriggerTypeEnum } from './enyo-automation.cjs';
|
|
49
49
|
import { EnyoChargeModeEnum } from './enyo-data-bus-value.cjs';
|
|
50
|
+
import type { EnyoDataBusCommandReason } from './enyo-data-bus-value.cjs';
|
|
50
51
|
import { EnyoSmartPlugApplianceStateEnum } from './enyo-smart-plug-appliance.cjs';
|
|
51
52
|
/**
|
|
52
53
|
* Optional metadata describing the estimated savings of a forecasted
|
|
@@ -262,6 +263,17 @@ export interface BatteryCommandForecastScheduleEntry {
|
|
|
262
263
|
export interface BatteryCommandForecastAuto extends ApplianceForecastMetadata {
|
|
263
264
|
/** Discriminator — set to {@link BatteryCommandForecastModeEnum.Auto}. */
|
|
264
265
|
mode: BatteryCommandForecastModeEnum.Auto;
|
|
266
|
+
/**
|
|
267
|
+
* Why no schedule is prescribed over the horizon — e.g.
|
|
268
|
+
* {@link EnyoDataBusCommandReasonTypeEnum.PriceSpreadTooSmall} when
|
|
269
|
+
* trading would not pay.
|
|
270
|
+
*
|
|
271
|
+
* Set it only when every Auto slot of the horizon shares the same reason;
|
|
272
|
+
* when they differ, leave it out rather than pick one. Optional, so a
|
|
273
|
+
* consumer that does not read it is unaffected — but without it an empty
|
|
274
|
+
* Auto forecast tends to read as "no forecast".
|
|
275
|
+
*/
|
|
276
|
+
reason?: EnyoDataBusCommandReason;
|
|
265
277
|
}
|
|
266
278
|
/**
|
|
267
279
|
* Forecast variant that declares an explicit relative schedule the
|