iobroker.nexowatt-ui 0.7.0 → 0.7.2
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/ems/modules/heating-rod-control.js +355 -49
- package/ems/modules/storage-control.js +39 -18
- package/io-package.json +14 -2
- package/package.json +3 -2
- package/www/ems-apps.js +60 -9
- package/www/sw.js +1 -1
|
@@ -96,6 +96,8 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
96
96
|
this._stateCache = new Map();
|
|
97
97
|
/** @type {Map<string, {targetStage:number,lastIncreaseMs:number,lastDecreaseMs:number}>} */
|
|
98
98
|
this._stageCtl = new Map();
|
|
99
|
+
/** @type {{importSinceMs:number, dischargeSinceMs:number}} */
|
|
100
|
+
this._budgetProtect = { importSinceMs: 0, dischargeSinceMs: 0 };
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
_isEnabled() {
|
|
@@ -362,6 +364,15 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
362
364
|
await mk('heatingRod.summary.pvAvailableW', 'PV available after thermal (W)', 'number', 'value.power', 'W');
|
|
363
365
|
await mk('heatingRod.summary.appliedTotalW', 'Applied total (W)', 'number', 'value.power', 'W');
|
|
364
366
|
await mk('heatingRod.summary.budgetUsedW', 'Budget used (W)', 'number', 'value.power', 'W');
|
|
367
|
+
await mk('heatingRod.summary.budgetGateTotalW', 'Budget gate total (W)', 'number', 'value.power', 'W');
|
|
368
|
+
await mk('heatingRod.summary.budgetGateRemainingW', 'Budget gate remaining after EVCS (W)', 'number', 'value.power', 'W');
|
|
369
|
+
await mk('heatingRod.summary.budgetGatePvW', 'Budget gate PV for heating rod (W)', 'number', 'value.power', 'W');
|
|
370
|
+
await mk('heatingRod.summary.budgetGateEffectiveW', 'Budget gate effective for heating rod (W)', 'number', 'value.power', 'W');
|
|
371
|
+
await mk('heatingRod.summary.budgetGateSource', 'Budget gate source', 'string', 'text');
|
|
372
|
+
await mk('heatingRod.summary.gridImportW', 'Grid import used for heating rod gate (W)', 'number', 'value.power', 'W');
|
|
373
|
+
await mk('heatingRod.summary.gridImportLimitW', 'Allowed grid import in PV auto (W)', 'number', 'value.power', 'W');
|
|
374
|
+
await mk('heatingRod.summary.gridImportExceeded', 'Grid import above heating rod limit', 'boolean', 'indicator');
|
|
375
|
+
await mk('heatingRod.summary.storageDischargeExceeded', 'Storage discharge above heating rod limit', 'boolean', 'indicator');
|
|
365
376
|
await mk('heatingRod.summary.debugJson', 'Debug JSON', 'string', 'json');
|
|
366
377
|
await mk('heatingRod.summary.zeroExportActive', 'Zero/minus feed-in logic active', 'boolean', 'indicator');
|
|
367
378
|
await mk('heatingRod.summary.zeroExportCanProbe', 'Zero/minus feed-in probe allowed', 'boolean', 'indicator');
|
|
@@ -380,9 +391,18 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
380
391
|
try {
|
|
381
392
|
const ns = String(this.adapter.namespace || '').trim();
|
|
382
393
|
if (ns && this.dp) {
|
|
394
|
+
await this.dp.upsert({ key: 'hr.cm.active', objectId: `${ns}.chargingManagement.control.active`, dataType: 'boolean', direction: 'in' });
|
|
395
|
+
await this.dp.upsert({ key: 'hr.cm.budgetW', objectId: `${ns}.chargingManagement.control.budgetW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
396
|
+
await this.dp.upsert({ key: 'hr.cm.remainingW', objectId: `${ns}.chargingManagement.control.remainingW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
397
|
+
await this.dp.upsert({ key: 'hr.cm.pvCapRawW', objectId: `${ns}.chargingManagement.control.pvCapRawW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
383
398
|
await this.dp.upsert({ key: 'hr.cm.pvCapW', objectId: `${ns}.chargingManagement.control.pvCapEffectiveW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
399
|
+
await this.dp.upsert({ key: 'hr.cm.pvAvailable', objectId: `${ns}.chargingManagement.control.pvAvailable`, dataType: 'boolean', direction: 'in' });
|
|
384
400
|
await this.dp.upsert({ key: 'hr.cm.usedW', objectId: `${ns}.chargingManagement.control.usedW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
385
401
|
await this.dp.upsert({ key: 'hr.cm.pvSurplusNoEvRawW', objectId: `${ns}.chargingManagement.control.pvSurplusNoEvRawW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
402
|
+
await this.dp.upsert({ key: 'hr.cm.pvSurplusNoEvAvg5mW', objectId: `${ns}.chargingManagement.control.pvSurplusNoEvAvg5mW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
403
|
+
await this.dp.upsert({ key: 'hr.cm.gridW', objectId: `${ns}.chargingManagement.control.gridImportW`, dataType: 'number', direction: 'in', unit: 'W' });
|
|
404
|
+
await this.dp.upsert({ key: 'hr.cm.staleMeter', objectId: `${ns}.chargingManagement.control.staleMeter`, dataType: 'boolean', direction: 'in' });
|
|
405
|
+
await this.dp.upsert({ key: 'hr.cm.staleBudget', objectId: `${ns}.chargingManagement.control.staleBudget`, dataType: 'boolean', direction: 'in' });
|
|
386
406
|
for (let i = 1; i <= 10; i++) {
|
|
387
407
|
await this.dp.upsert({ key: `hr.user.c${i}.regEnabled`, objectId: `${ns}.heatingRod.user.c${i}.regEnabled`, dataType: 'boolean', direction: 'in' });
|
|
388
408
|
await this.dp.upsert({ key: `hr.user.c${i}.mode`, objectId: `${ns}.heatingRod.user.c${i}.mode`, dataType: 'string', direction: 'in' });
|
|
@@ -490,6 +510,58 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
490
510
|
return fallback;
|
|
491
511
|
}
|
|
492
512
|
|
|
513
|
+
_readBooleanAny(keys, staleMs, fallback = null) {
|
|
514
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
515
|
+
for (const key of list) {
|
|
516
|
+
if (!key) continue;
|
|
517
|
+
try {
|
|
518
|
+
if (this.dp && this.dp.getEntry && this.dp.getEntry(key)) {
|
|
519
|
+
if (typeof this.dp.isStale === 'function' && this.dp.isStale(key, staleMs)) continue;
|
|
520
|
+
const v = this.dp.getBoolean ? this.dp.getBoolean(key, null) : null;
|
|
521
|
+
if (v !== null && v !== undefined) return !!v;
|
|
522
|
+
}
|
|
523
|
+
} catch (_e) {
|
|
524
|
+
// ignore
|
|
525
|
+
}
|
|
526
|
+
const raw = this._readCacheRaw(key, null);
|
|
527
|
+
if (raw === null || raw === undefined) continue;
|
|
528
|
+
if (typeof raw === 'boolean') return raw;
|
|
529
|
+
if (typeof raw === 'number') return raw !== 0;
|
|
530
|
+
if (typeof raw === 'string') {
|
|
531
|
+
const t = raw.trim().toLowerCase();
|
|
532
|
+
if (['true', '1', 'on', 'yes', 'active', 'enabled'].includes(t)) return true;
|
|
533
|
+
if (['false', '0', 'off', 'no', 'inactive', 'disabled'].includes(t)) return false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return fallback;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_getBudgetGateCfg() {
|
|
540
|
+
const cfg = this._getCfg();
|
|
541
|
+
const zero = (cfg.zeroExport && typeof cfg.zeroExport === 'object') ? cfg.zeroExport : {};
|
|
542
|
+
const pickNum = (keys, def, minV = 0, maxV = 1e12) => {
|
|
543
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
544
|
+
for (const key of list) {
|
|
545
|
+
const raw = (cfg[key] !== undefined && cfg[key] !== null && cfg[key] !== '') ? cfg[key] : zero[key];
|
|
546
|
+
if (raw === null || raw === undefined || raw === '') continue;
|
|
547
|
+
const n = Number(raw);
|
|
548
|
+
if (Number.isFinite(n)) return Math.round(clamp(n, minV, maxV));
|
|
549
|
+
}
|
|
550
|
+
return Math.round(clamp(def, minV, maxV));
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
useBudgetGates: true,
|
|
555
|
+
maxGridImportW: pickNum(['maxGridImportW', 'gridImportToleranceW', 'pvMaxGridImportW', 'pvImportToleranceW', 'gridImportTripW'], 250, 0, 1000000),
|
|
556
|
+
gridImportHoldSec: pickNum(['gridImportHoldSec', 'gridImportTripSec', 'pvGridImportHoldSec'], 15, 0, 3600),
|
|
557
|
+
hardGridImportW: pickNum(['hardGridImportW', 'pvHardGridImportW'], 3000, 0, 1000000),
|
|
558
|
+
storageDischargeToleranceW: pickNum(['storageDischargeToleranceW', 'pvStorageDischargeToleranceW'], 300, 0, 1000000),
|
|
559
|
+
storageDischargeHoldSec: pickNum(['storageDischargeHoldSec', 'storageDischargeTripSec', 'pvStorageDischargeHoldSec'], 15, 0, 3600),
|
|
560
|
+
hardStorageDischargeW: pickNum(['hardStorageDischargeW', 'pvHardStorageDischargeW'], 1200, 0, 1000000),
|
|
561
|
+
budgetSafetyReserveW: pickNum(['budgetSafetyReserveW', 'pvSafetyReserveW'], 150, 0, 1000000),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
493
565
|
_readStorageSnapshot(staleMs) {
|
|
494
566
|
let chargeW = Math.max(0, num(this._readNumberAny([
|
|
495
567
|
'storageFarm.totalChargePowerW',
|
|
@@ -522,19 +594,38 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
522
594
|
|
|
523
595
|
_computeBasePvAvailableW(currentHeatingRodW = 0) {
|
|
524
596
|
const cfg = this._getCfg();
|
|
597
|
+
const gateCfg = this._getBudgetGateCfg();
|
|
525
598
|
const staleTimeoutSec = clamp(num(cfg.staleTimeoutSec, 15), 1, 3600);
|
|
526
599
|
const staleMs = Math.max(1, Math.round(staleTimeoutSec * 1000));
|
|
600
|
+
const finite = (v) => (typeof v === 'number' && Number.isFinite(v));
|
|
601
|
+
|
|
602
|
+
const cmActive = this._readBooleanAny(['hr.cm.active', 'chargingManagement.control.active'], staleMs, null);
|
|
603
|
+
const cmStaleMeter = this._readBooleanAny(['hr.cm.staleMeter', 'chargingManagement.control.staleMeter'], staleMs, false);
|
|
604
|
+
const cmStaleBudget = this._readBooleanAny(['hr.cm.staleBudget', 'chargingManagement.control.staleBudget'], staleMs, false);
|
|
605
|
+
|
|
606
|
+
const cmBudgetWRaw = this._readNumberAny(['hr.cm.budgetW', 'chargingManagement.control.budgetW'], staleMs, null);
|
|
607
|
+
const cmRemainingWRaw = this._readNumberAny(['hr.cm.remainingW', 'chargingManagement.control.remainingW'], staleMs, null);
|
|
608
|
+
const cmUsedWRaw = this._readNumberAny(['hr.cm.usedW', 'chargingManagement.control.usedW'], staleMs, null);
|
|
609
|
+
const cmPvCapEffectiveRaw = this._readNumberAny(['hr.cm.pvCapW', 'chargingManagement.control.pvCapEffectiveW'], staleMs, null);
|
|
610
|
+
const cmPvCapRawRaw = this._readNumberAny(['hr.cm.pvCapRawW', 'chargingManagement.control.pvCapRawW'], staleMs, null);
|
|
611
|
+
const cmPvNoEvRaw = this._readNumberAny(['hr.cm.pvSurplusNoEvRawW', 'chargingManagement.control.pvSurplusNoEvRawW'], staleMs, null);
|
|
612
|
+
const cmPvNoEvAvg = this._readNumberAny(['hr.cm.pvSurplusNoEvAvg5mW', 'chargingManagement.control.pvSurplusNoEvAvg5mW'], staleMs, null);
|
|
613
|
+
const cmPvAvailable = this._readBooleanAny(['hr.cm.pvAvailable', 'chargingManagement.control.pvAvailable'], staleMs, null);
|
|
614
|
+
|
|
615
|
+
const pvCapW = finite(cmPvCapEffectiveRaw) ? Math.max(0, cmPvCapEffectiveRaw) : 0;
|
|
616
|
+
const evcsUsedW = finite(cmUsedWRaw) ? Math.max(0, cmUsedWRaw) : 0;
|
|
617
|
+
const currentW = Math.max(0, num(currentHeatingRodW, 0));
|
|
527
618
|
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
619
|
+
const gridW = this._readNumberAny([
|
|
620
|
+
'hr.cm.gridW',
|
|
621
|
+
'chargingManagement.control.gridImportW',
|
|
622
|
+
'grid.powerRawW',
|
|
623
|
+
'grid.powerW',
|
|
624
|
+
'ps.gridPowerW'
|
|
625
|
+
], staleMs, null);
|
|
626
|
+
const gridKnown = finite(gridW);
|
|
535
627
|
const exportW = gridKnown ? Math.max(0, -gridW) : 0;
|
|
536
628
|
const importW = gridKnown ? Math.max(0, gridW) : 0;
|
|
537
|
-
const currentW = Math.max(0, num(currentHeatingRodW, 0));
|
|
538
629
|
const storage = this._readStorageSnapshot(staleMs);
|
|
539
630
|
|
|
540
631
|
const storageTargetSocPct = clamp(num(cfg.storageTargetSocPct, 90), 0, 100);
|
|
@@ -547,63 +638,168 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
547
638
|
? storageReserveCfgW
|
|
548
639
|
: 0;
|
|
549
640
|
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
641
|
+
// Gate A/T/§14a/Peak: consume the remaining central budget after EVCS.
|
|
642
|
+
// This is intentionally read-only: Heizstab does not change the load management budget engine.
|
|
643
|
+
let totalGateRemainingW = Number.POSITIVE_INFINITY;
|
|
644
|
+
let totalGateBudgetW = Number.POSITIVE_INFINITY;
|
|
645
|
+
let totalGateSource = 'unlimited';
|
|
646
|
+
const cmLooksActive = cmActive === true
|
|
647
|
+
|| evcsUsedW > 0
|
|
648
|
+
|| (finite(cmBudgetWRaw) && cmBudgetWRaw > 0)
|
|
649
|
+
|| (finite(cmRemainingWRaw) && cmRemainingWRaw > 0);
|
|
650
|
+
if (gateCfg.useBudgetGates && cmLooksActive && !cmStaleBudget && finite(cmRemainingWRaw)) {
|
|
651
|
+
totalGateRemainingW = Math.max(0, cmRemainingWRaw);
|
|
652
|
+
totalGateBudgetW = finite(cmBudgetWRaw) ? Math.max(0, cmBudgetWRaw) : totalGateRemainingW + evcsUsedW;
|
|
653
|
+
totalGateSource = 'chargingManagement.remainingW';
|
|
654
|
+
} else {
|
|
655
|
+
try {
|
|
656
|
+
const caps = (this.adapter && this.adapter._emsCaps && typeof this.adapter._emsCaps === 'object') ? this.adapter._emsCaps : null;
|
|
657
|
+
const cap = caps && caps.evcsHighLevel ? num(caps.evcsHighLevel.capW, null) : null;
|
|
658
|
+
if (gateCfg.useBudgetGates && typeof cap === 'number' && Number.isFinite(cap) && cap > 0) {
|
|
659
|
+
totalGateBudgetW = Math.max(0, cap);
|
|
660
|
+
totalGateRemainingW = Math.max(0, cap - evcsUsedW);
|
|
661
|
+
totalGateSource = `ems.core.${String(caps.evcsHighLevel.binding || 'highLevel')}`;
|
|
662
|
+
}
|
|
663
|
+
} catch (_e) {
|
|
664
|
+
// ignore core fallback
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Gate B: prefer the same PV surplus gate that EVCS uses when it is active.
|
|
669
|
+
// It is reconstructed without EVCS; therefore add the current Heizstab load back in,
|
|
670
|
+
// otherwise an already running stage would collapse its own PV budget at 0 export.
|
|
671
|
+
let cmPvGateW = null;
|
|
672
|
+
let cmPvGateSource = '';
|
|
673
|
+
if (!cmStaleMeter) {
|
|
674
|
+
const candidates = [];
|
|
675
|
+
if (finite(cmPvCapEffectiveRaw) && cmPvCapEffectiveRaw > 0) candidates.push({ k: 'cm.pvCapEffectiveW', w: cmPvCapEffectiveRaw });
|
|
676
|
+
if (finite(cmPvCapRawRaw) && cmPvCapRawRaw > 0) candidates.push({ k: 'cm.pvCapRawW', w: cmPvCapRawRaw });
|
|
677
|
+
if (finite(cmPvNoEvRaw) && cmPvNoEvRaw > 0) candidates.push({ k: 'cm.pvSurplusNoEvRawW', w: cmPvNoEvRaw });
|
|
678
|
+
if (finite(cmPvNoEvAvg) && cmPvNoEvAvg > 0) candidates.push({ k: 'cm.pvSurplusNoEvAvg5mW', w: cmPvNoEvAvg });
|
|
679
|
+
if (candidates.length) {
|
|
680
|
+
const best = candidates.reduce((a, b) => (b.w > a.w ? b : a), candidates[0]);
|
|
681
|
+
cmPvGateW = Math.max(0, best.w - evcsUsedW + currentW - storageReserveW - gateCfg.budgetSafetyReserveW);
|
|
682
|
+
cmPvGateSource = best.k;
|
|
683
|
+
} else if (cmPvAvailable === false && finite(cmPvCapEffectiveRaw)) {
|
|
684
|
+
cmPvGateW = Math.max(0, currentW - storageReserveW - gateCfg.budgetSafetyReserveW);
|
|
685
|
+
cmPvGateSource = 'cm.pvAvailable.false_hold_only';
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Fallback/second truth: NVP balance without Heizstab as flexible load.
|
|
690
|
+
// import above the configured tolerance consumes the budget; small import remains allowed
|
|
691
|
+
// to keep the stages running calmly like PV-only EV charging.
|
|
692
|
+
const importExcessW = gridKnown ? Math.max(0, importW - gateCfg.maxGridImportW) : 0;
|
|
556
693
|
const nvpSurplusBeforeFlexW = gridKnown
|
|
557
|
-
? Math.max(0, exportW
|
|
694
|
+
? Math.max(0, exportW + currentW + storage.chargeW - storage.dischargeW - importExcessW)
|
|
558
695
|
: 0;
|
|
559
|
-
const nvpAvailableW = Math.max(0, nvpSurplusBeforeFlexW - storageReserveW);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const
|
|
574
|
-
const
|
|
696
|
+
const nvpAvailableW = Math.max(0, nvpSurplusBeforeFlexW - storageReserveW - gateCfg.budgetSafetyReserveW);
|
|
697
|
+
|
|
698
|
+
let pvBudgetGateW = nvpAvailableW;
|
|
699
|
+
let pvBudgetSource = gridKnown ? 'nvp+ownLoad+storageReserve' : 'no-fresh-nvp';
|
|
700
|
+
if (cmPvGateW !== null && Number.isFinite(cmPvGateW) && cmPvGateW > pvBudgetGateW) {
|
|
701
|
+
pvBudgetGateW = cmPvGateW;
|
|
702
|
+
pvBudgetSource = cmPvGateSource || 'cm.pvGate';
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const effectiveGateW = Math.max(0, Math.min(
|
|
706
|
+
pvBudgetGateW,
|
|
707
|
+
Number.isFinite(totalGateRemainingW) ? totalGateRemainingW : Number.POSITIVE_INFINITY
|
|
708
|
+
));
|
|
709
|
+
|
|
710
|
+
const source = `${pvBudgetSource}|${totalGateSource}`;
|
|
711
|
+
const gridImportActive = !!(gridKnown && importW > gateCfg.maxGridImportW);
|
|
712
|
+
const storageDischargeActive = !!(storage.dischargeW > gateCfg.storageDischargeToleranceW);
|
|
575
713
|
const nonPvEnergyActive = !!(gridImportActive || storageDischargeActive);
|
|
576
|
-
const forceOff =
|
|
714
|
+
const forceOff = effectiveGateW <= 50 && currentW > 0 && (importW > gateCfg.hardGridImportW || storage.dischargeW > gateCfg.hardStorageDischargeW);
|
|
577
715
|
|
|
578
716
|
return {
|
|
579
|
-
pvCapW: Math.max(pvCapW, nvpSurplusBeforeFlexW),
|
|
717
|
+
pvCapW: Math.max(pvBudgetGateW, pvCapW, nvpSurplusBeforeFlexW),
|
|
580
718
|
evcsUsedW,
|
|
581
|
-
availableW,
|
|
719
|
+
availableW: effectiveGateW,
|
|
582
720
|
source,
|
|
721
|
+
gateCfg,
|
|
722
|
+
useBudgetGates: !!gateCfg.useBudgetGates,
|
|
723
|
+
budgetGateTotalW: Number.isFinite(totalGateBudgetW) ? Math.max(0, totalGateBudgetW) : null,
|
|
724
|
+
budgetGateRemainingW: Number.isFinite(totalGateRemainingW) ? Math.max(0, totalGateRemainingW) : null,
|
|
725
|
+
budgetGatePvW: Math.max(0, pvBudgetGateW),
|
|
726
|
+
budgetGateEffectiveW: Math.max(0, effectiveGateW),
|
|
727
|
+
budgetGateSource: source,
|
|
728
|
+
cmActive,
|
|
729
|
+
cmStaleMeter: !!cmStaleMeter,
|
|
730
|
+
cmStaleBudget: !!cmStaleBudget,
|
|
731
|
+
cmPvAvailable,
|
|
732
|
+
cmPvCapEffectiveW: pvCapW,
|
|
733
|
+
cmPvCapRawW: finite(cmPvCapRawRaw) ? Math.max(0, cmPvCapRawRaw) : 0,
|
|
734
|
+
cmPvSurplusNoEvRawW: finite(cmPvNoEvRaw) ? Math.max(0, cmPvNoEvRaw) : 0,
|
|
583
735
|
gridKnown,
|
|
584
736
|
gridW: gridKnown ? gridW : null,
|
|
585
737
|
importW,
|
|
586
|
-
importToleranceW,
|
|
738
|
+
importToleranceW: gateCfg.maxGridImportW,
|
|
587
739
|
gridImportActive,
|
|
588
740
|
exportW,
|
|
589
741
|
currentHeatingRodW: currentW,
|
|
590
742
|
storageChargeW: storage.chargeW,
|
|
591
743
|
storageDischargeW: storage.dischargeW,
|
|
592
|
-
dischargeToleranceW,
|
|
744
|
+
dischargeToleranceW: gateCfg.storageDischargeToleranceW,
|
|
593
745
|
storageDischargeActive,
|
|
594
746
|
nonPvEnergyActive,
|
|
595
747
|
storageSocPct: storage.socPct,
|
|
596
748
|
storageReserveW,
|
|
597
749
|
storageTargetSocPct,
|
|
598
750
|
nvpSurplusBeforeFlexW,
|
|
599
|
-
cmAvailableW,
|
|
751
|
+
cmAvailableW: (cmPvGateW !== null && Number.isFinite(cmPvGateW)) ? Math.max(0, cmPvGateW) : 0,
|
|
600
752
|
nvpAvailableW,
|
|
601
753
|
forceOff,
|
|
602
754
|
};
|
|
603
755
|
}
|
|
604
756
|
|
|
757
|
+
_updateBudgetGateProtection(pvBase, now) {
|
|
758
|
+
const cfg = (pvBase && pvBase.gateCfg) ? pvBase.gateCfg : this._getBudgetGateCfg();
|
|
759
|
+
const st = this._budgetProtect || { importSinceMs: 0, dischargeSinceMs: 0 };
|
|
760
|
+
const importActive = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.maxGridImportW);
|
|
761
|
+
const dischargeActive = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.storageDischargeToleranceW);
|
|
762
|
+
const hardImport = !!(pvBase && pvBase.gridKnown && num(pvBase.importW, 0) > cfg.hardGridImportW);
|
|
763
|
+
const hardDischarge = !!(pvBase && num(pvBase.storageDischargeW, 0) > cfg.hardStorageDischargeW);
|
|
764
|
+
if (importActive) {
|
|
765
|
+
if (!st.importSinceMs) st.importSinceMs = now;
|
|
766
|
+
} else {
|
|
767
|
+
st.importSinceMs = 0;
|
|
768
|
+
}
|
|
769
|
+
if (dischargeActive) {
|
|
770
|
+
if (!st.dischargeSinceMs) st.dischargeSinceMs = now;
|
|
771
|
+
} else {
|
|
772
|
+
st.dischargeSinceMs = 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const importHoldMs = importActive && st.importSinceMs ? Math.max(0, now - st.importSinceMs) : 0;
|
|
776
|
+
const dischargeHoldMs = dischargeActive && st.dischargeSinceMs ? Math.max(0, now - st.dischargeSinceMs) : 0;
|
|
777
|
+
const hardOff = !!(hardImport || hardDischarge);
|
|
778
|
+
const reduceNow = hardOff
|
|
779
|
+
|| (importActive && importHoldMs >= Math.max(0, cfg.gridImportHoldSec * 1000))
|
|
780
|
+
|| (dischargeActive && dischargeHoldMs >= Math.max(0, cfg.storageDischargeHoldSec * 1000));
|
|
781
|
+
const reason = hardOff
|
|
782
|
+
? (hardImport ? 'hard_grid_import' : 'hard_storage_discharge')
|
|
783
|
+
: (reduceNow ? (importActive ? 'grid_import_hold' : 'storage_discharge_hold') : (importActive || dischargeActive ? 'watch' : 'ok'));
|
|
784
|
+
|
|
785
|
+
this._budgetProtect = st;
|
|
786
|
+
return {
|
|
787
|
+
importActive,
|
|
788
|
+
dischargeActive,
|
|
789
|
+
hardImport,
|
|
790
|
+
hardDischarge,
|
|
791
|
+
importHoldMs,
|
|
792
|
+
dischargeHoldMs,
|
|
793
|
+
hardOff,
|
|
794
|
+
reduceNow,
|
|
795
|
+
watchActive: !!((importActive || dischargeActive) && !reduceNow),
|
|
796
|
+
reason,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
605
800
|
_getZeroExportCfg() {
|
|
606
801
|
const cfg = this._getCfg();
|
|
802
|
+
const gateCfg = this._getBudgetGateCfg();
|
|
607
803
|
const raw = (cfg.zeroExport && typeof cfg.zeroExport === 'object')
|
|
608
804
|
? cfg.zeroExport
|
|
609
805
|
: ((cfg.zeroFeedIn && typeof cfg.zeroFeedIn === 'object') ? cfg.zeroFeedIn : {});
|
|
@@ -630,15 +826,19 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
630
826
|
minForecastPeakW: n(['minForecastPeakW', 'forecastMinPeakW'], 1000, 0, 1000000),
|
|
631
827
|
minForecastKwh6h: clamp(num(raw.minForecastKwh6h ?? raw.forecastMinKwh6h, 0.5), 0, 100000),
|
|
632
828
|
storageFullSocPct: n(['storageFullSocPct', 'storagePrioritySocPct'], 95, 0, 100),
|
|
633
|
-
gridImportTripW:
|
|
634
|
-
gridImportTripSec:
|
|
635
|
-
hardGridImportW:
|
|
636
|
-
storageDischargeToleranceW:
|
|
637
|
-
storageDischargeTripSec:
|
|
638
|
-
hardStorageDischargeW:
|
|
829
|
+
gridImportTripW: gateCfg.maxGridImportW,
|
|
830
|
+
gridImportTripSec: gateCfg.gridImportHoldSec,
|
|
831
|
+
hardGridImportW: gateCfg.hardGridImportW,
|
|
832
|
+
storageDischargeToleranceW: gateCfg.storageDischargeToleranceW,
|
|
833
|
+
storageDischargeTripSec: gateCfg.storageDischargeHoldSec,
|
|
834
|
+
hardStorageDischargeW: gateCfg.hardStorageDischargeW,
|
|
639
835
|
stepUpDelaySec: n(['stepUpDelaySec', 'stepUpWaitSec'], 60, 0, 86400),
|
|
640
836
|
stepDownDelaySec: n(['stepDownDelaySec', 'stepDownWaitSec'], 5, 0, 86400),
|
|
641
837
|
cooldownSec: n(['cooldownSec', 'probeCooldownSec'], 60, 0, 86400),
|
|
838
|
+
probeObserveSec: n(['probeObserveSec', 'pvFollowCheckSec', 'pvNachregelCheckSec'], 45, 0, 3600),
|
|
839
|
+
probeMinPvRisePct: n(['probeMinPvRisePct', 'pvRiseMinPct', 'pvAnstiegMinPct'], 20, 0, 1000),
|
|
840
|
+
probeMinPvRiseW: n(['probeMinPvRiseW', 'pvRiseMinW', 'pvAnstiegMinW'], 150, 0, 1000000),
|
|
841
|
+
probeRetrySec: n(['probeRetrySec', 'retryAfterFailedRiseSec', 'pvRiseRetrySec'], 600, 0, 86400),
|
|
642
842
|
};
|
|
643
843
|
}
|
|
644
844
|
|
|
@@ -1016,6 +1216,7 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1016
1216
|
const currentStage = Math.max(0, Math.min(Math.round(Number(st.targetStage ?? observedStage) || 0), d.stageCount));
|
|
1017
1217
|
let targetStage = Math.max(0, Math.min(Math.round(Number(desiredStage) || 0), d.stageCount));
|
|
1018
1218
|
let reason = (info && info.reason) ? String(info.reason) : 'zero_export';
|
|
1219
|
+
const pvNowW = Math.max(0, Math.round(num(info && info.pvNowW, 0)));
|
|
1019
1220
|
let reduceNow = false;
|
|
1020
1221
|
let hardOff = false;
|
|
1021
1222
|
|
|
@@ -1049,6 +1250,7 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1049
1250
|
st.zeroCooldownUntilMs = now + Math.max(0, cfg.cooldownSec * 1000);
|
|
1050
1251
|
st.zeroLastStepDownMs = now;
|
|
1051
1252
|
st.lastDecreaseMs = now;
|
|
1253
|
+
st.zeroProbe = null;
|
|
1052
1254
|
st.targetStage = targetStage;
|
|
1053
1255
|
reason = hardOff ? 'hard_non_pv_reduce' : (importActive ? 'grid_import_reduce' : 'storage_discharge_reduce');
|
|
1054
1256
|
this._stageCtl.set(d.id, st);
|
|
@@ -1073,6 +1275,7 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1073
1275
|
st.zeroCooldownUntilMs = now + Math.max(0, cfg.cooldownSec * 1000);
|
|
1074
1276
|
st.zeroLastStepDownMs = now;
|
|
1075
1277
|
st.lastDecreaseMs = now;
|
|
1278
|
+
st.zeroProbe = null;
|
|
1076
1279
|
st.targetStage = targetStage;
|
|
1077
1280
|
reason = 'storage_priority_reduce';
|
|
1078
1281
|
this._stageCtl.set(d.id, st);
|
|
@@ -1087,6 +1290,65 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1087
1290
|
};
|
|
1088
1291
|
}
|
|
1089
1292
|
|
|
1293
|
+
const probe = (st.zeroProbe && typeof st.zeroProbe === 'object') ? st.zeroProbe : null;
|
|
1294
|
+
if (probe) {
|
|
1295
|
+
const probeStage = Math.max(0, Math.min(Math.round(Number(probe.stage) || 0), d.stageCount));
|
|
1296
|
+
const probeStillOn = probeStage > 0 && Math.max(currentStage, observedStage, targetStage) >= probeStage;
|
|
1297
|
+
if (!probeStillOn) {
|
|
1298
|
+
st.zeroProbe = null;
|
|
1299
|
+
} else {
|
|
1300
|
+
const observeMs = Math.max(0, Math.round(num(cfg.probeObserveSec, 45) * 1000));
|
|
1301
|
+
const startMs = Math.max(0, Math.round(num(probe.startMs, now)));
|
|
1302
|
+
const elapsedMs = Math.max(0, now - startMs);
|
|
1303
|
+
if (observeMs > 0 && elapsedMs < observeMs) {
|
|
1304
|
+
targetStage = Math.max(targetStage, probeStage);
|
|
1305
|
+
st.targetStage = targetStage;
|
|
1306
|
+
reason = 'probe_observing_pv_rise';
|
|
1307
|
+
this._stageCtl.set(d.id, st);
|
|
1308
|
+
return {
|
|
1309
|
+
targetStage: st.targetStage,
|
|
1310
|
+
reduceNow: false,
|
|
1311
|
+
hardOff: false,
|
|
1312
|
+
reason,
|
|
1313
|
+
importHoldMs,
|
|
1314
|
+
dischargeHoldMs,
|
|
1315
|
+
nextAllowedAt: startMs + observeMs,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const riseW = Math.max(0, pvNowW - Math.max(0, Math.round(num(probe.basePvW, 0))));
|
|
1320
|
+
const addedPowerW = Math.max(0, Math.round(num(probe.addedPowerW, 0)));
|
|
1321
|
+
const needRiseW = Math.max(
|
|
1322
|
+
Math.max(0, Math.round(num(cfg.probeMinPvRiseW, 150))),
|
|
1323
|
+
Math.round(addedPowerW * Math.max(0, num(cfg.probeMinPvRisePct, 20)) / 100)
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
if (riseW + 1 < needRiseW) {
|
|
1327
|
+
const reduceBase = Math.max(currentStage, observedStage, targetStage, probeStage);
|
|
1328
|
+
targetStage = this._previousPhysicalStageBelow(d, reduceBase);
|
|
1329
|
+
st.zeroCooldownUntilMs = now + Math.max(0, cfg.probeRetrySec * 1000);
|
|
1330
|
+
st.zeroLastStepDownMs = now;
|
|
1331
|
+
st.lastDecreaseMs = now;
|
|
1332
|
+
st.zeroProbe = null;
|
|
1333
|
+
st.targetStage = targetStage;
|
|
1334
|
+
reason = `probe_pv_rise_failed_${riseW}of${needRiseW}W`;
|
|
1335
|
+
this._stageCtl.set(d.id, st);
|
|
1336
|
+
return {
|
|
1337
|
+
targetStage,
|
|
1338
|
+
reduceNow: true,
|
|
1339
|
+
hardOff: false,
|
|
1340
|
+
reason,
|
|
1341
|
+
importHoldMs,
|
|
1342
|
+
dischargeHoldMs,
|
|
1343
|
+
nextAllowedAt: st.zeroCooldownUntilMs || 0,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
st.zeroProbe = null;
|
|
1348
|
+
reason = `probe_pv_rise_ok_${riseW}of${needRiseW}W`;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1090
1352
|
const cooldownActive = !!(st.zeroCooldownUntilMs && now < st.zeroCooldownUntilMs);
|
|
1091
1353
|
const canProbe = !!(info && info.active && info.canProbe && !cooldownActive);
|
|
1092
1354
|
|
|
@@ -1097,8 +1359,17 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1097
1359
|
const stepWaitMs = Math.max(0, cfg.stepUpDelaySec * 1000);
|
|
1098
1360
|
const mayStep = nextStage > baseStage && (!lastUp || (now - lastUp) >= stepWaitMs);
|
|
1099
1361
|
if (mayStep) {
|
|
1362
|
+
const basePowerW = this._sumStagePower(d, baseStage);
|
|
1363
|
+
const nextPowerW = this._sumStagePower(d, nextStage);
|
|
1100
1364
|
targetStage = Math.max(targetStage, nextStage);
|
|
1101
1365
|
st.zeroLastStepUpMs = now;
|
|
1366
|
+
st.zeroProbe = {
|
|
1367
|
+
stage: nextStage,
|
|
1368
|
+
baseStage,
|
|
1369
|
+
basePvW: pvNowW,
|
|
1370
|
+
addedPowerW: Math.max(0, nextPowerW - basePowerW),
|
|
1371
|
+
startMs: now,
|
|
1372
|
+
};
|
|
1102
1373
|
reason = 'probe_step_up';
|
|
1103
1374
|
} else {
|
|
1104
1375
|
reason = (nextStage <= baseStage) ? 'max_physical_stage' : 'waiting_step_up_delay';
|
|
@@ -1262,6 +1533,7 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1262
1533
|
}
|
|
1263
1534
|
|
|
1264
1535
|
const pvBase = this._computeBasePvAvailableW(currentHeatingRodW);
|
|
1536
|
+
const budgetProtection = this._updateBudgetGateProtection(pvBase, now);
|
|
1265
1537
|
const zeroExportInfo = this._computeZeroExportInfo(pvBase);
|
|
1266
1538
|
const minPvAutomationW = this._getPvAutomationMinW();
|
|
1267
1539
|
const staleTimeoutSec = clamp(num(this._getCfg().staleTimeoutSec, 15), 1, 3600);
|
|
@@ -1514,6 +1786,13 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1514
1786
|
let desiredStage = this._computeDesiredStage(d, remainingW, observedStage);
|
|
1515
1787
|
let zeroDecision = null;
|
|
1516
1788
|
|
|
1789
|
+
// Wenn der Budget-Gate-Schutz gerade einen Netzbezug/Speicherbezug beobachtet,
|
|
1790
|
+
// nicht weiter hochfahren. Bestehende Stufe darf bis zur konfigurierten Schutzzeit
|
|
1791
|
+
// laufen, damit PV/FEMS sauber nachregeln kann.
|
|
1792
|
+
if (budgetProtection && budgetProtection.watchActive && desiredStage > observedStage) {
|
|
1793
|
+
desiredStage = observedStage;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1517
1796
|
// 0-/Minus-Einspeiseanlagen verstecken PV-Überschuss am Netzpunkt, weil der
|
|
1518
1797
|
// Wechselrichter/FEMS die PV abregelt. In diesem Sondermodus darf PV-Auto vorsichtig
|
|
1519
1798
|
// eine physische Heizstab-Stufe als Testlast zuschalten, wenn Forecast, PV-Leistung,
|
|
@@ -1529,13 +1808,14 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1529
1808
|
// Reiner PV-Betrieb: bei Netzbezug oder Speicherentladung keine Stufe halten
|
|
1530
1809
|
// oder neu zuschalten. Bei aktivem 0-Einspeise-Sondermodus werden kurze Transienten
|
|
1531
1810
|
// nicht sofort gekillt, sondern erst nach den konfigurierten Schutzzeiten.
|
|
1532
|
-
let forceNonPvDown = !!(
|
|
1533
|
-
if (zeroExportInfo.active) forceNonPvDown = !!(zeroDecision && zeroDecision.reduceNow);
|
|
1811
|
+
let forceNonPvDown = !!(budgetProtection && budgetProtection.reduceNow);
|
|
1812
|
+
if (zeroExportInfo.active) forceNonPvDown = !!(forceNonPvDown || (zeroDecision && zeroDecision.reduceNow));
|
|
1534
1813
|
if (forceNonPvDown) {
|
|
1535
1814
|
// Reduce to the next lower *physical* actuator set. This is important for
|
|
1536
1815
|
// installations that accidentally map several virtual stages to the same KNX/relay
|
|
1537
1816
|
// datapoint: targetStage 3 -> 2 would otherwise still keep the same actuator ON.
|
|
1538
|
-
const
|
|
1817
|
+
const hardOff = !!((budgetProtection && budgetProtection.hardOff) || (zeroDecision && zeroDecision.hardOff));
|
|
1818
|
+
const lowerPhysicalStage = hardOff
|
|
1539
1819
|
? 0
|
|
1540
1820
|
: this._previousPhysicalStageBelow(d, Math.max(observedStage, desiredStage));
|
|
1541
1821
|
desiredStage = Math.min(desiredStage, lowerPhysicalStage);
|
|
@@ -1562,9 +1842,10 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1562
1842
|
await this._setStateIfChanged(`heatingRod.devices.${d.id}.targetW`, Math.round(targetW));
|
|
1563
1843
|
await this._setStateIfChanged(`heatingRod.devices.${d.id}.appliedW`, Math.round(usedW));
|
|
1564
1844
|
const zeroSuffix = zeroDecision && zeroDecision.reason ? `_zero_${String(zeroDecision.reason)}` : '';
|
|
1845
|
+
const gateSuffix = budgetProtection && budgetProtection.reason && budgetProtection.reason !== 'ok' ? `_gate_${String(budgetProtection.reason)}` : '';
|
|
1565
1846
|
const autoStatus = forceStorageProtectOff
|
|
1566
|
-
? `storage_protect_${String(res.status || '')}${zeroSuffix}`
|
|
1567
|
-
: (forceNonPvDown ? `pv_only_protect_${String(res.status || '')}${zeroSuffix}` : `${String(res.status || 'pv_auto')}${zeroSuffix}`);
|
|
1847
|
+
? `storage_protect_${String(res.status || '')}${zeroSuffix}${gateSuffix}`
|
|
1848
|
+
: (forceNonPvDown ? `pv_only_protect_${String(res.status || '')}${zeroSuffix}${gateSuffix}` : `${String(res.status || 'pv_auto')}${zeroSuffix}${gateSuffix}`);
|
|
1568
1849
|
await this._setStateIfChanged(`heatingRod.devices.${d.id}.status`, autoStatus);
|
|
1569
1850
|
await this._setStateIfChanged(`heatingRod.devices.${d.id}.override`, '');
|
|
1570
1851
|
}
|
|
@@ -1582,6 +1863,15 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1582
1863
|
await this._setStateIfChanged('heatingRod.summary.pvAvailableW', Math.round(Math.max(0, num(pvBase.availableW, 0) - thermalUsedW)));
|
|
1583
1864
|
await this._setStateIfChanged('heatingRod.summary.appliedTotalW', Math.round(appliedTotalW));
|
|
1584
1865
|
await this._setStateIfChanged('heatingRod.summary.budgetUsedW', Math.round(budgetUsedW));
|
|
1866
|
+
await this._setStateIfChanged('heatingRod.summary.budgetGateTotalW', pvBase.budgetGateTotalW === null ? 0 : Math.round(num(pvBase.budgetGateTotalW, 0)));
|
|
1867
|
+
await this._setStateIfChanged('heatingRod.summary.budgetGateRemainingW', pvBase.budgetGateRemainingW === null ? 0 : Math.round(num(pvBase.budgetGateRemainingW, 0)));
|
|
1868
|
+
await this._setStateIfChanged('heatingRod.summary.budgetGatePvW', Math.round(num(pvBase.budgetGatePvW, 0)));
|
|
1869
|
+
await this._setStateIfChanged('heatingRod.summary.budgetGateEffectiveW', Math.round(num(pvBase.budgetGateEffectiveW, 0)));
|
|
1870
|
+
await this._setStateIfChanged('heatingRod.summary.budgetGateSource', String(pvBase.budgetGateSource || pvBase.source || ''));
|
|
1871
|
+
await this._setStateIfChanged('heatingRod.summary.gridImportW', Math.round(num(pvBase.importW, 0)));
|
|
1872
|
+
await this._setStateIfChanged('heatingRod.summary.gridImportLimitW', Math.round(num(pvBase.importToleranceW, 0)));
|
|
1873
|
+
await this._setStateIfChanged('heatingRod.summary.gridImportExceeded', !!(budgetProtection && budgetProtection.importActive));
|
|
1874
|
+
await this._setStateIfChanged('heatingRod.summary.storageDischargeExceeded', !!(budgetProtection && budgetProtection.dischargeActive));
|
|
1585
1875
|
await this._setStateIfChanged('heatingRod.summary.zeroExportActive', !!zeroExportInfo.active);
|
|
1586
1876
|
await this._setStateIfChanged('heatingRod.summary.zeroExportCanProbe', !!zeroExportInfo.canProbe);
|
|
1587
1877
|
await this._setStateIfChanged('heatingRod.summary.zeroExportReason', String(zeroExportInfo.reason || ''));
|
|
@@ -1617,6 +1907,22 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1617
1907
|
availableW: Math.round(num(pvBase.availableW, 0)),
|
|
1618
1908
|
thermalUsedW: Math.round(thermalUsedW),
|
|
1619
1909
|
forceOff: !!pvBase.forceOff,
|
|
1910
|
+
budgetGate: {
|
|
1911
|
+
useBudgetGates: !!pvBase.useBudgetGates,
|
|
1912
|
+
totalW: pvBase.budgetGateTotalW,
|
|
1913
|
+
remainingW: pvBase.budgetGateRemainingW,
|
|
1914
|
+
pvW: Math.round(num(pvBase.budgetGatePvW, 0)),
|
|
1915
|
+
effectiveW: Math.round(num(pvBase.budgetGateEffectiveW, 0)),
|
|
1916
|
+
source: pvBase.budgetGateSource,
|
|
1917
|
+
cmActive: pvBase.cmActive,
|
|
1918
|
+
cmStaleMeter: !!pvBase.cmStaleMeter,
|
|
1919
|
+
cmStaleBudget: !!pvBase.cmStaleBudget,
|
|
1920
|
+
cmPvAvailable: pvBase.cmPvAvailable,
|
|
1921
|
+
cmPvCapEffectiveW: Math.round(num(pvBase.cmPvCapEffectiveW, 0)),
|
|
1922
|
+
cmPvCapRawW: Math.round(num(pvBase.cmPvCapRawW, 0)),
|
|
1923
|
+
cmPvSurplusNoEvRawW: Math.round(num(pvBase.cmPvSurplusNoEvRawW, 0)),
|
|
1924
|
+
protection: budgetProtection || null,
|
|
1925
|
+
},
|
|
1620
1926
|
zeroExport: {
|
|
1621
1927
|
active: !!zeroExportInfo.active,
|
|
1622
1928
|
canProbe: !!zeroExportInfo.canProbe,
|
|
@@ -1630,7 +1936,7 @@ class HeatingRodControlModule extends BaseModule {
|
|
|
1630
1936
|
},
|
|
1631
1937
|
}));
|
|
1632
1938
|
await this._setStateIfChanged('heatingRod.summary.lastUpdate', now);
|
|
1633
|
-
await this._setStateIfChanged('heatingRod.summary.status', (this._devices && this._devices.length) ? `ok_${pvBase.source}${!pvAutomationAllowedByMin ? '_pv_min_block' : ''}${pvBase.forceOff ? '_storage_protect' : ''}${
|
|
1939
|
+
await this._setStateIfChanged('heatingRod.summary.status', (this._devices && this._devices.length) ? `ok_${pvBase.source}${!pvAutomationAllowedByMin ? '_pv_min_block' : ''}${pvBase.forceOff ? '_storage_protect' : ''}${budgetProtection && budgetProtection.reason !== 'ok' ? `_gate_${String(budgetProtection.reason)}` : ''}${zeroExportInfo.active ? `_zero_${String(zeroExportInfo.reason || 'active')}` : ''}` : 'no_devices');
|
|
1634
1940
|
}
|
|
1635
1941
|
}
|
|
1636
1942
|
|
|
@@ -1389,21 +1389,32 @@ if (typeof soc === 'number') {
|
|
|
1389
1389
|
errW = nvpRawW - targetImportW;
|
|
1390
1390
|
}
|
|
1391
1391
|
|
|
1392
|
-
// Deadband gegen Flattern: erst außerhalb der Bandbreite nachregeln
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1392
|
+
// Deadband gegen Flattern: erst außerhalb der Bandbreite nachregeln.
|
|
1393
|
+
// WICHTIG: Innerhalb der Deadband darf der Sollwert nicht mit der Batterie-Istleistung
|
|
1394
|
+
// mitwandern. Viele Speicher/Farm-DPs melden die Istleistung zeitversetzt oder leicht
|
|
1395
|
+
// springend. Wenn wir bei err=0 trotzdem battIst als neuen Sollwert übernehmen,
|
|
1396
|
+
// entstehen sichtbare Sollwertsprünge trotz konstantem Verbrauch.
|
|
1397
|
+
const outsideDeadband = (errW > deadbandW || errW < -deadbandW);
|
|
1398
|
+
const holdInDeadband = !outsideDeadband && curSetW > 0;
|
|
1399
|
+
let errAdjW = outsideDeadband ? errW : 0;
|
|
1400
|
+
|
|
1401
|
+
// OpenEMS-Balancing (Vorbild): neuer Sollwert = battIst + (gridIst - gridZiel).
|
|
1402
|
+
// Innerhalb der Deadband halten wir aber den letzten Sollwert, statt auf die
|
|
1403
|
+
// zeitversetzte Istleistung zu springen.
|
|
1404
|
+
// Fallback ohne Ist-Batterieleistung: inkrementelle Regelung (Soll = letzter Sollwert + Fehler).
|
|
1400
1405
|
// Rampe/Schrittweite/Anti-PingPong folgen im Dispatcher weiter unten.
|
|
1401
|
-
let nextSetW =
|
|
1406
|
+
let nextSetW = holdInDeadband
|
|
1407
|
+
? curSetW
|
|
1408
|
+
: ((typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW));
|
|
1402
1409
|
|
|
1403
1410
|
// Safety-Clamp gegen unnötige Export-Spikes:
|
|
1404
1411
|
// Begrenze grob auf aktuelle Hauslast am NVP: Import (roh) + aktuelle Entladung (falls messbar) + Puffer.
|
|
1412
|
+
// Bei aktiver Regelung zählt der letzte selbst gesetzte Sollwert als plausible Entladebasis,
|
|
1413
|
+
// damit zeitversetzte/0-W-Istwerte die Farm nicht künstlich herunterziehen.
|
|
1405
1414
|
const importRawNowW = Math.max(0, (typeof nvpRawW === 'number') ? nvpRawW : 0);
|
|
1406
|
-
const
|
|
1415
|
+
const measuredDischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
|
|
1416
|
+
const commandedDischargeNowW = curSetW > 0 ? curSetW : 0;
|
|
1417
|
+
const dischargeNowW = Math.max(measuredDischargeNowW, commandedDischargeNowW);
|
|
1407
1418
|
const safetyMarginW = 200;
|
|
1408
1419
|
const maxByDemandW = importRawNowW + dischargeNowW + safetyMarginW;
|
|
1409
1420
|
if (Number.isFinite(maxByDemandW) && maxByDemandW > 0) {
|
|
@@ -1555,22 +1566,32 @@ if (targetW === 0 && selfDischargeEnabled) {
|
|
|
1555
1566
|
|
|
1556
1567
|
// PI-light: Inkrement-Regelung.
|
|
1557
1568
|
// Hinweis: Stabilisierung erfolgt über Deadband + Dispatcher (Schrittweite/Rampe/Anti-PingPong).
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1569
|
+
// WICHTIG: Innerhalb der Deadband den letzten Sollwert halten. Sonst wandert der
|
|
1570
|
+
// Sollwert mit der zeitversetzten Batterie-/Farm-Istleistung mit und springt trotz
|
|
1571
|
+
// konstantem Hausverbrauch unnötig hoch/runter.
|
|
1572
|
+
const outsideDeadband = (errW > deadbandW || errW < -deadbandW);
|
|
1573
|
+
const holdInDeadband = !outsideDeadband && lastWasSelf && curSetW > 0;
|
|
1574
|
+
let errAdjW = outsideDeadband ? errW : 0;
|
|
1575
|
+
|
|
1576
|
+
// OpenEMS-Balancing (Vorbild): battIst + (gridIst - gridZiel).
|
|
1577
|
+
// Innerhalb der Deadband halten wir den letzten Sollwert statt auf die
|
|
1578
|
+
// zeitversetzte Istleistung zu springen.
|
|
1564
1579
|
// Fallback: letzter Sollwert + Fehler
|
|
1565
|
-
let nextSetW =
|
|
1580
|
+
let nextSetW = holdInDeadband
|
|
1581
|
+
? curSetW
|
|
1582
|
+
: ((typeof battW === 'number') ? (battW + errAdjW) : (curSetW + errAdjW));
|
|
1566
1583
|
|
|
1567
1584
|
// Safety-Clamp gegen Überschwingen:
|
|
1568
1585
|
// Wenn battW nicht gemappt ist (oder NVP kurzfristig "alt" ist), kann die inkrementelle Regelung
|
|
1569
1586
|
// zu großen Sollwerten aufintegrieren. Wir begrenzen deshalb die Entladeleistung grob auf
|
|
1570
1587
|
// "aktuelle Last" am NVP: Import (roh) + aktuelle Entladung (falls messbar) + kleiner Puffer.
|
|
1571
1588
|
// Dadurch bleibt die Regelung im Bereich der realen Hauslast und erzeugt keine Export-Spikes.
|
|
1589
|
+
// Bei aktiver Eigenverbrauchsregelung zählt der letzte selbst gesetzte Sollwert als plausible
|
|
1590
|
+
// Entladebasis, damit zeitversetzte/0-W-Istwerte die Farm nicht künstlich herunterziehen.
|
|
1572
1591
|
const importRawNowW = Math.max(0, (typeof nvpRawW === 'number') ? nvpRawW : 0);
|
|
1573
|
-
const
|
|
1592
|
+
const measuredDischargeNowW = (typeof battW === 'number') ? Math.max(0, battW) : 0;
|
|
1593
|
+
const commandedDischargeNowW = curSetW > 0 ? curSetW : 0;
|
|
1594
|
+
const dischargeNowW = Math.max(measuredDischargeNowW, commandedDischargeNowW);
|
|
1574
1595
|
const safetyMarginW = 200; // bewusst konservativ; Feintuning über selfTargetGridW/Deadband/Rampe
|
|
1575
1596
|
const maxByDemandW = importRawNowW + dischargeNowW + safetyMarginW;
|
|
1576
1597
|
if (Number.isFinite(maxByDemandW) && maxByDemandW > 0) {
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "nexowatt-ui",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.2",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.7.2": {
|
|
7
|
+
"en": "Heating rod PV-Auto rebuilt as a read-only budget-gate consumer. It now uses the existing charging/load-management remaining budget and PV gate before switching stages, keeps running through short inverter/FEMS transients, and reduces only after configurable grid-import or battery-discharge hold times. Zero/minus feed-in test loads now verify that PV generation actually rises and retry after a failed PV-rise check. Charging management, storage control and storage-farm distribution were not changed.",
|
|
8
|
+
"de": "Heizstab-PV-Auto als lesender Budget-Gate-Verbraucher neu aufgebaut. Die App nutzt jetzt Restbudget und PV-Gate aus dem bestehenden Lade-/Lastmanagement, bevor Stufen geschaltet werden, bleibt bei kurzen WR-/FEMS-Transienten stabil und reduziert erst nach einstellbarer Netzbezug- oder Speicherentlade-Haltezeit. 0-/Minus-Einspeise-Testlasten prüfen jetzt, ob die PV-Erzeugung wirklich nachregelt, und warten nach fehlendem PV-Anstieg bis zum nächsten Versuch. Lade-/Lastmanagement, Speicherregelung und Speicherfarm-Verteilung wurden nicht geändert."
|
|
9
|
+
},
|
|
10
|
+
"0.7.1": {
|
|
11
|
+
"en": "Storage control stabilized: self-consumption and tariff discharge now hold the last active setpoint inside the NVP target band instead of following delayed/noisy battery or storage-farm actual-power values. The demand safety clamp also treats the last commanded discharge setpoint as plausible discharge power, preventing stale 0 W telemetry from pulling the farm down. No changes to heating rod logic, PV surplus charging, storage-farm distribution, or zero-feed-in control.",
|
|
12
|
+
"de": "Speicherregelung stabilisiert: Eigenverbrauchs- und Tarif-Entladung halten innerhalb des NVP-Zielbands den letzten aktiven Sollwert, statt verzögerten/rauschenden Batterie- oder Speicherfarm-Istleistungen zu folgen. Der Sicherheits-Lastdeckel wertet den letzten geschriebenen Entlade-Sollwert zusätzlich als plausible Entladeleistung, damit stale 0-W-Telemetrie die Farm nicht herunterzieht. Keine Änderungen an Heizstablogik, PV-Überschussladung, Speicherfarm-Verteilung oder 0-Einspeise-Regelung."
|
|
13
|
+
},
|
|
6
14
|
"0.7.0": {
|
|
7
15
|
"en": "Stable 0.7.0 baseline from uploaded 0.6.262: version bump and web-cache bump only. The faulty storage/farm/heating-rod control changes from 0.6.267–0.6.270 are not included.",
|
|
8
16
|
"de": "Stabile 0.7.0-Basis aus der hochgeladenen 0.6.262: nur Versionssprung und Webcache-Bump. Die fehlerhaften Speicher-/Farm-/Heizstab-Regeländerungen aus 0.6.267–0.6.270 sind nicht übernommen."
|
|
@@ -1814,8 +1822,12 @@
|
|
|
1814
1822
|
}
|
|
1815
1823
|
},
|
|
1816
1824
|
"instanceObjects": [],
|
|
1817
|
-
"version": "0.7.
|
|
1825
|
+
"version": "0.7.1",
|
|
1818
1826
|
"news": {
|
|
1827
|
+
"0.7.1": {
|
|
1828
|
+
"en": "Storage control stabilized: self-consumption and tariff discharge now hold the last active setpoint inside the NVP target band instead of following delayed/noisy battery or storage-farm actual-power values. The demand safety clamp also treats the last commanded discharge setpoint as plausible discharge power, preventing stale 0 W telemetry from pulling the farm down. No changes to heating rod logic, PV surplus charging, storage-farm distribution, or zero-feed-in control.",
|
|
1829
|
+
"de": "Speicherregelung stabilisiert: Eigenverbrauchs- und Tarif-Entladung halten innerhalb des NVP-Zielbands den letzten aktiven Sollwert, statt verzögerten/rauschenden Batterie- oder Speicherfarm-Istleistungen zu folgen. Der Sicherheits-Lastdeckel wertet den letzten geschriebenen Entlade-Sollwert zusätzlich als plausible Entladeleistung, damit stale 0-W-Telemetrie die Farm nicht herunterzieht. Keine Änderungen an Heizstablogik, PV-Überschussladung, Speicherfarm-Verteilung oder 0-Einspeise-Regelung."
|
|
1830
|
+
},
|
|
1819
1831
|
"0.7.0": {
|
|
1820
1832
|
"en": "Stable 0.7.0 baseline from uploaded 0.6.262: version bump and web-cache bump only. The faulty storage/farm/heating-rod control changes from 0.6.267–0.6.270 are not included.",
|
|
1821
1833
|
"de": "Stabile 0.7.0-Basis aus der hochgeladenen 0.6.262: nur Versionssprung und Webcache-Bump. Die fehlerhaften Speicher-/Farm-/Heizstab-Regeländerungen aus 0.6.267–0.6.270 sind nicht übernommen."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.nexowatt-ui",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Responsive NexoWatt EMS visualization adapter.",
|
|
5
5
|
"author": "NexoWatt",
|
|
6
6
|
"license": "Proprietary License (NPL) v1.0",
|
|
@@ -49,5 +49,6 @@
|
|
|
49
49
|
"README.md",
|
|
50
50
|
"LICENSE",
|
|
51
51
|
"ems/**/*"
|
|
52
|
-
]
|
|
52
|
+
],
|
|
53
|
+
"type": "commonjs"
|
|
53
54
|
}
|
package/www/ems-apps.js
CHANGED
|
@@ -1675,6 +1675,37 @@ function _collectFlowPowerDpIsWFromUI() {
|
|
|
1675
1675
|
h.storageTargetSocPct = Math.max(0, Math.min(100, Math.round(Number(h.storageTargetSocPct ?? 90) || 90)));
|
|
1676
1676
|
const hMinPvRaw = Number(h.minPvPowerW ?? h.pvAutoMinPvPowerW ?? h.minCurrentPvW);
|
|
1677
1677
|
h.minPvPowerW = Math.max(0, Math.round(Number.isFinite(hMinPvRaw) ? hMinPvRaw : 800));
|
|
1678
|
+
h.useBudgetGates = true;
|
|
1679
|
+
const legacyZeroForBudget = (h.zeroExport && typeof h.zeroExport === 'object') ? h.zeroExport : {};
|
|
1680
|
+
const legacyBudgetAliases = {
|
|
1681
|
+
maxGridImportW: ['maxGridImportW', 'gridImportTripW'],
|
|
1682
|
+
gridImportHoldSec: ['gridImportHoldSec', 'gridImportTripSec'],
|
|
1683
|
+
hardGridImportW: ['hardGridImportW'],
|
|
1684
|
+
storageDischargeToleranceW: ['storageDischargeToleranceW'],
|
|
1685
|
+
storageDischargeHoldSec: ['storageDischargeHoldSec', 'storageDischargeTripSec'],
|
|
1686
|
+
hardStorageDischargeW: ['hardStorageDischargeW'],
|
|
1687
|
+
budgetSafetyReserveW: ['budgetSafetyReserveW', 'pvSafetyReserveW'],
|
|
1688
|
+
};
|
|
1689
|
+
const hn = (key, def, min, max) => {
|
|
1690
|
+
let raw = h[key];
|
|
1691
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
1692
|
+
const aliases = legacyBudgetAliases[key] || [];
|
|
1693
|
+
for (const alias of aliases) {
|
|
1694
|
+
const zRaw = legacyZeroForBudget[alias];
|
|
1695
|
+
if (zRaw !== undefined && zRaw !== null && zRaw !== '') { raw = zRaw; break; }
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
let n = Number(raw);
|
|
1699
|
+
if (!Number.isFinite(n)) n = def;
|
|
1700
|
+
h[key] = Math.round(Math.max(min, Math.min(max, n)));
|
|
1701
|
+
};
|
|
1702
|
+
hn('maxGridImportW', 250, 0, 1000000);
|
|
1703
|
+
hn('gridImportHoldSec', 15, 0, 3600);
|
|
1704
|
+
hn('hardGridImportW', 3000, 0, 1000000);
|
|
1705
|
+
hn('storageDischargeToleranceW', 300, 0, 1000000);
|
|
1706
|
+
hn('storageDischargeHoldSec', 15, 0, 3600);
|
|
1707
|
+
hn('hardStorageDischargeW', 1200, 0, 1000000);
|
|
1708
|
+
hn('budgetSafetyReserveW', 150, 0, 1000000);
|
|
1678
1709
|
h.zeroExport = (h.zeroExport && typeof h.zeroExport === 'object') ? h.zeroExport : {};
|
|
1679
1710
|
const z = h.zeroExport;
|
|
1680
1711
|
const zn = (key, def, min, max, integer = true) => {
|
|
@@ -1702,6 +1733,10 @@ function _collectFlowPowerDpIsWFromUI() {
|
|
|
1702
1733
|
zn('stepUpDelaySec', 60, 0, 86400);
|
|
1703
1734
|
zn('stepDownDelaySec', 5, 0, 86400);
|
|
1704
1735
|
zn('cooldownSec', 60, 0, 86400);
|
|
1736
|
+
zn('probeObserveSec', 45, 0, 3600);
|
|
1737
|
+
zn('probeMinPvRisePct', 20, 0, 1000);
|
|
1738
|
+
zn('probeMinPvRiseW', 150, 0, 1000000);
|
|
1739
|
+
zn('probeRetrySec', 600, 0, 86400);
|
|
1705
1740
|
|
|
1706
1741
|
const bySlot = new Map();
|
|
1707
1742
|
for (const raw of h.devices) {
|
|
@@ -2074,9 +2109,23 @@ function _collectFlowPowerDpIsWFromUI() {
|
|
|
2074
2109
|
grpCoord.body.appendChild(coordHint);
|
|
2075
2110
|
els.heatingRodDevices.appendChild(grpCoord.wrap);
|
|
2076
2111
|
|
|
2112
|
+
const grpGate = _mkCfgGroup('Budget-Gates & Lastmanagement');
|
|
2113
|
+
const gateInfo = document.createElement('div');
|
|
2114
|
+
gateInfo.className = 'nw-config-field-hint';
|
|
2115
|
+
gateInfo.textContent = 'Die Heizstab-App arbeitet als Budget-Verbraucher: sie liest Restbudget und PV-Budget aus den bestehenden Gates des Lade-/Lastmanagements nur mit. Ladepark, Speicher und Lastmanagement werden hier nicht verändert.';
|
|
2116
|
+
grpGate.body.appendChild(gateInfo);
|
|
2117
|
+
grpGate.body.appendChild(_mkCfgField('Max. Netzbezug erlaubt (W)', _mkCfgInput('number', cfg.maxGridImportW, (v) => { cfg.maxGridImportW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Bis zu diesem kurzen Netzbezug darf eine Heizstab-Stufe weiterlaufen, damit Wechselrichter/FEMS sauber nachregeln können. Darüber wird beobachtet und danach reduziert.'));
|
|
2118
|
+
grpGate.body.appendChild(_mkCfgField('Netzbezug-Haltezeit (s)', _mkCfgInput('number', cfg.gridImportHoldSec, (v) => { cfg.gridImportHoldSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Nach dieser Zeit mit zu hohem Netzbezug wird eine physische Heizstab-Stufe reduziert.'));
|
|
2119
|
+
grpGate.body.appendChild(_mkCfgField('Harter Netzbezug AUS (W)', _mkCfgInput('number', cfg.hardGridImportW, (v) => { cfg.hardGridImportW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Ab diesem Netzbezug wird sofort komplett zurückgenommen.'));
|
|
2120
|
+
grpGate.body.appendChild(_mkCfgField('Speicherentladung erlaubt (W)', _mkCfgInput('number', cfg.storageDischargeToleranceW, (v) => { cfg.storageDischargeToleranceW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Kleine Transienten sind erlaubt; anhaltende Speicherentladung wird nicht verheizt.'));
|
|
2121
|
+
grpGate.body.appendChild(_mkCfgField('Speicherentladung-Haltezeit (s)', _mkCfgInput('number', cfg.storageDischargeHoldSec, (v) => { cfg.storageDischargeHoldSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Nach dieser Zeit mit Speicherentladung über der Toleranz wird eine physische Stufe reduziert.'));
|
|
2122
|
+
grpGate.body.appendChild(_mkCfgField('Harte Speicherentladung AUS (W)', _mkCfgInput('number', cfg.hardStorageDischargeW, (v) => { cfg.hardStorageDischargeW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Ab dieser Speicherentladung wird sofort komplett zurückgenommen.'));
|
|
2123
|
+
grpGate.body.appendChild(_mkCfgField('Budget-Sicherheitsreserve (W)', _mkCfgInput('number', cfg.budgetSafetyReserveW, (v) => { cfg.budgetSafetyReserveW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Wird vom berechneten Heizstab-PV-Budget abgezogen, damit Speicher, Hauslast und Messrauschen nicht gegeneinander regeln.'));
|
|
2124
|
+
els.heatingRodDevices.appendChild(grpGate.wrap);
|
|
2125
|
+
|
|
2077
2126
|
const zeroCfg = cfg.zeroExport || {};
|
|
2078
|
-
const grpZero = _mkCfgGroup('0-
|
|
2079
|
-
grpZero.body.appendChild(_mkCfgField('Logik aktiv', _mkCfgToggle(!!zeroCfg.enabled, (v) => { zeroCfg.enabled = !!v; setDirty(); }), 'Nur
|
|
2127
|
+
const grpZero = _mkCfgGroup('Heizstab: 0-Einspeise-Testlast / PV-Nachregelung');
|
|
2128
|
+
grpZero.body.appendChild(_mkCfgField('Logik aktiv', _mkCfgToggle(!!zeroCfg.enabled, (v) => { zeroCfg.enabled = !!v; setDirty(); }), 'Nur Heizstab-PV-Auto: bei 0-/Minus-Einspeiseanlagen darf die App vorsichtig Stufe für Stufe Testlast zuschalten, wenn Budget-Gates, Forecast und Einspeiselimit es freigeben.'));
|
|
2080
2129
|
grpZero.body.appendChild(_mkCfgField('Erlaubte Einspeisung (W)', _mkCfgInput('number', zeroCfg.feedInLimitW, (v) => { zeroCfg.feedInLimitW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Bei -1 kW Einspeiselimit bitte 1000 eintragen. Bei echter 0-Einspeisung 0 eintragen.'));
|
|
2081
2130
|
grpZero.body.appendChild(_mkCfgField('Einspeise-Toleranz (W)', _mkCfgInput('number', zeroCfg.feedInToleranceW, (v) => { zeroCfg.feedInToleranceW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Ab diesem Korridor gilt der Netzpunkt als am Einspeiselimit.'));
|
|
2082
2131
|
grpZero.body.appendChild(_mkCfgField('Ziel-Einspeisepuffer (W)', _mkCfgInput('number', zeroCfg.targetExportBufferW, (v) => { zeroCfg.targetExportBufferW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Sicherheitsabstand: die Testlast startet erst, wenn am Einspeiselimit noch dieser Puffer plausibel vorhanden ist.'));
|
|
@@ -2085,17 +2134,19 @@ function _collectFlowPowerDpIsWFromUI() {
|
|
|
2085
2134
|
grpZero.body.appendChild(_mkCfgField('Forecast Peak min. (W)', _mkCfgInput('number', zeroCfg.minForecastPeakW, (v) => { zeroCfg.minForecastPeakW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 50, width: '150px' }), 'Mindestens erwartete PV-Spitze innerhalb des Forecast-Zeitraums.'));
|
|
2086
2135
|
grpZero.body.appendChild(_mkCfgField('Forecast 6h min. (kWh)', _mkCfgInput('number', zeroCfg.minForecastKwh6h, (v) => { zeroCfg.minForecastKwh6h = Math.max(0, Number(v) || 0); setDirty(); }, { min: 0, step: 0.1, width: '150px' }), 'Alternative Freigabe über erwartete Energie in den nächsten Stunden.'));
|
|
2087
2136
|
grpZero.body.appendChild(_mkCfgField('Speicher-Vorrang bis SoC (%)', _mkCfgInput('number', zeroCfg.storageFullSocPct, (v) => { zeroCfg.storageFullSocPct = Math.max(0, Math.min(100, Math.round(Number(v) || 0))); setDirty(); }, { min: 0, max: 100, step: 1, width: '150px' }), 'Erst ab diesem SoC darf versteckte/abgeregelte PV vorsichtig in den Heizstab gehen.'));
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
grpZero.body.appendChild(
|
|
2092
|
-
grpZero.body.appendChild(_mkCfgField('Speicherentladung-Zeit (s)', _mkCfgInput('number', zeroCfg.storageDischargeTripSec, (v) => { zeroCfg.storageDischargeTripSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Erlaubt kurze Speicher-Transienten, reduziert aber bei anhaltender Entladung.'));
|
|
2093
|
-
grpZero.body.appendChild(_mkCfgField('Harte Speicherentladung (W)', _mkCfgInput('number', zeroCfg.hardStorageDischargeW, (v) => { zeroCfg.hardStorageDischargeW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Ab dieser Entladung wird sofort komplett zurückgenommen.'));
|
|
2137
|
+
const zeroGateHint = document.createElement('div');
|
|
2138
|
+
zeroGateHint.className = 'nw-config-field-hint';
|
|
2139
|
+
zeroGateHint.textContent = 'Netzbezug- und Speicherentladungsschutz kommen zentral aus „Budget-Gates & Lastmanagement“ oben. Dieser Block steuert nur die Heizstab-Testlast bei 0-/Minus-Einspeisung.';
|
|
2140
|
+
grpZero.body.appendChild(zeroGateHint);
|
|
2094
2141
|
grpZero.body.appendChild(_mkCfgField('Stufe-hoch Wartezeit (s)', _mkCfgInput('number', zeroCfg.stepUpDelaySec, (v) => { zeroCfg.stepUpDelaySec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Langsam hochfahren: nur eine physische Stufe je Wartezeit.'));
|
|
2142
|
+
grpZero.body.appendChild(_mkCfgField('PV-Nachregelprüfung (s)', _mkCfgInput('number', zeroCfg.probeObserveSec, (v) => { zeroCfg.probeObserveSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Zeitfenster nach Zuschalten einer Teststufe. In diesem Fenster muss die PV-Erzeugung sichtbar nachziehen.'));
|
|
2143
|
+
grpZero.body.appendChild(_mkCfgField('PV-Anstieg min. (%)', _mkCfgInput('number', zeroCfg.probeMinPvRisePct, (v) => { zeroCfg.probeMinPvRisePct = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Mindestanteil der neu zugeschalteten Stufenleistung, der als PV-Anstieg sichtbar sein muss.'));
|
|
2144
|
+
grpZero.body.appendChild(_mkCfgField('PV-Anstieg min. (W)', _mkCfgInput('number', zeroCfg.probeMinPvRiseW, (v) => { zeroCfg.probeMinPvRiseW = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Absolute Mindeständerung gegen Messrauschen.'));
|
|
2145
|
+
grpZero.body.appendChild(_mkCfgField('Erneuter Test nach PV-Fehlanstieg (s)', _mkCfgInput('number', zeroCfg.probeRetrySec, (v) => { zeroCfg.probeRetrySec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 10, width: '150px' }), 'Standard 600 s: wenn PV nach einer Teststufe nicht nachzieht, bleibt die App unten und versucht es später erneut.'));
|
|
2095
2146
|
grpZero.body.appendChild(_mkCfgField('Cooldown nach Abwurf (s)', _mkCfgInput('number', zeroCfg.cooldownSec, (v) => { zeroCfg.cooldownSec = Math.max(0, Math.round(Number(v) || 0)); setDirty(); }, { min: 0, step: 1, width: '150px' }), 'Wartezeit nach Netzbezug/Speicherentladung, bevor erneut getestet wird.'));
|
|
2096
2147
|
const zeroHint = document.createElement('div');
|
|
2097
2148
|
zeroHint.className = 'nw-config-field-hint';
|
|
2098
|
-
zeroHint.textContent = 'Prinzip:
|
|
2149
|
+
zeroHint.textContent = 'Prinzip: Die Heizstab-App liest die zentralen Budget-Gates, schaltet dann langsam Stufe für Stufe und prüft, ob PV wirklich nachregelt. Bei Netzbezug, Speicherentladung oder fehlendem PV-Anstieg wird reduziert. Manuell, Boost und Aus bleiben davon unberührt.';
|
|
2099
2150
|
grpZero.body.appendChild(zeroHint);
|
|
2100
2151
|
els.heatingRodDevices.appendChild(grpZero.wrap);
|
|
2101
2152
|
|
package/www/sw.js
CHANGED