@atelieruldetehnologii/node-red-custome-solutions 0.62.22
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/NOTICE +56 -0
- package/PROPRIETARY-LICENSE.txt +157 -0
- package/README.md +105 -0
- package/TERMS-AND-CONDITIONS.md +311 -0
- package/advantech-read/advantech-read.html +775 -0
- package/advantech-read/advantech-read.js +449 -0
- package/advantech-write/advantech-write.html +413 -0
- package/advantech-write/advantech-write.js +338 -0
- package/bess-control/bess-control.html +569 -0
- package/bess-control/bess-control.js +541 -0
- package/bess-control/lib/allocation.js +149 -0
- package/bess-control/lib/batteries.js +86 -0
- package/bess-control/lib/helpers.js +157 -0
- package/bess-control/lib/messages.js +131 -0
- package/bess-control/lib/runtime.js +156 -0
- package/carlo-gavazzi/carlo-gavazzi.html +4327 -0
- package/carlo-gavazzi/carlo-gavazzi.js +3081 -0
- package/control-export/control-export.html +3644 -0
- package/control-export/control-export.js +2192 -0
- package/control-reactive/control-reactive.html +3136 -0
- package/control-reactive/control-reactive.js +2124 -0
- package/fronius-emulator/README.md +258 -0
- package/fronius-emulator/fronius-emulator.html +170 -0
- package/fronius-emulator/fronius-emulator.js +520 -0
- package/fronius-emulator/lib/binary-codec.js +65 -0
- package/fronius-emulator/lib/fronius-register-map.js +185 -0
- package/fronius-emulator/lib/modbus-parser.js +61 -0
- package/fronius-emulator/lib/modbus-tcp-server.js +224 -0
- package/fronius-emulator/lib/validation.js +206 -0
- package/fronius-emulator/lib/virtual-meter.js +132 -0
- package/fronius-read/fronius-read.html +3944 -0
- package/fronius-read/fronius-read.js +1466 -0
- package/fronius-read/lib/inverter-profiles.js +711 -0
- package/fronius-read/lib/sunspec-core.js +303 -0
- package/fronius-read/lib/sunspec-model-parsers.js +845 -0
- package/fronius-write/fronius-write.html +1789 -0
- package/fronius-write/fronius-write.js +2531 -0
- package/influxdb/influxdb.html +1077 -0
- package/influxdb/influxdb.js +1487 -0
- package/janitza/janitza.html +2562 -0
- package/janitza/janitza.js +1500 -0
- package/janitza/lib/janitza-profiles.js +371 -0
- package/lib/a3-test-server.js +199 -0
- package/lib/adam-6000-profiles.js +173 -0
- package/lib/licensing/constants.js +103 -0
- package/lib/licensing/device-id.js +251 -0
- package/lib/licensing/errors.js +21 -0
- package/lib/licensing/license-client.js +377 -0
- package/lib/licensing/license-state.js +50 -0
- package/lib/licensing/node-guard.js +150 -0
- package/lib/licensing/package-integrity.js +167 -0
- package/lib/licensing/package-integrity.json +513 -0
- package/lib/licensing/public-key.pem +3 -0
- package/lib/licensing/public-key.pem.example +2 -0
- package/lib/licensing/public-keys.js +18 -0
- package/lib/licensing/storage.js +63 -0
- package/lib/licensing/token-verifier.js +84 -0
- package/lib/numbers.js +26 -0
- package/license/atelier-license.html +88 -0
- package/license/atelier-license.js +170 -0
- package/modbus-tcp-discover/modbus-tcp-discover.html +190 -0
- package/modbus-tcp-discover/modbus-tcp-discover.js +706 -0
- package/multi-battery/multi-battery.html +649 -0
- package/multi-battery/multi-battery.js +668 -0
- package/multi-grid/multi-grid.html +745 -0
- package/multi-grid/multi-grid.js +1392 -0
- package/multi-inverter/lib/engine-core.js +237 -0
- package/multi-inverter/multi-inverter.html +2674 -0
- package/multi-inverter/multi-inverter.js +1393 -0
- package/package.json +109 -0
- package/phoenix-contact/lib/eem-ma370-advanced.js +175 -0
- package/phoenix-contact/lib/eem-ma370.js +138 -0
- package/phoenix-contact/lib/integrations.js +149 -0
- package/phoenix-contact/lib/phoenix-contact-profile.js +35030 -0
- package/phoenix-contact/phoenix-contact.html +840 -0
- package/phoenix-contact/phoenix-contact.js +679 -0
- package/pv-generator/pv-generator.html +284 -0
- package/pv-generator/pv-generator.js +387 -0
- package/pylontech-read/pylontech-read.html +763 -0
- package/pylontech-read/pylontech-read.js +518 -0
- package/pylontech-write/pylontech-write.html +405 -0
- package/pylontech-write/pylontech-write.js +546 -0
- package/scada-aggregator/lib/aggregate.js +72 -0
- package/scada-aggregator/lib/groups.js +39 -0
- package/scada-aggregator/scada-aggregator.html +108 -0
- package/scada-aggregator/scada-aggregator.js +117 -0
- package/scada-server/lib/aggregator-map.js +58 -0
- package/scada-server/lib/encoding.js +235 -0
- package/scada-server/lib/modbus-server.js +492 -0
- package/scada-server/lib/object-path.js +18 -0
- package/scada-server/lib/register-map.js +288 -0
- package/scada-server/scada-server.html +1419 -0
- package/scada-server/scada-server.js +704 -0
- package/scripts/check-syntax.js +66 -0
- package/scripts/package-integrity.js +46 -0
- package/scripts/package-release.js +116 -0
- package/scripts/run-tests.js +2178 -0
- package/scripts/tgz-integrity.js +74 -0
- package/sma-read/lib/sunspec-core.js +83 -0
- package/sma-read/lib/sunspec-model-parsers.js +853 -0
- package/sma-read/sma-read.html +659 -0
- package/sma-read/sma-read.js +458 -0
- package/sma-write/sma-write.html +105 -0
- package/sma-write/sma-write.js +270 -0
- package/victron-read/lib/mqtt-state.js +176 -0
- package/victron-read/victron-read.html +1074 -0
- package/victron-read/victron-read.js +1222 -0
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
atLeast,
|
|
9
|
+
clampNumber,
|
|
10
|
+
toFiniteNumber
|
|
11
|
+
} = require("../lib/numbers");
|
|
12
|
+
const {
|
|
13
|
+
createA3TestServer
|
|
14
|
+
} = require("../lib/a3-test-server");
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function clamp(value, min, max) {
|
|
18
|
+
|
|
19
|
+
if (!Number.isFinite(value)) return min;
|
|
20
|
+
|
|
21
|
+
return Math.min(Math.max(value, min), max);
|
|
22
|
+
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
function firstFinite() {
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < arguments.length; i++) {
|
|
29
|
+
|
|
30
|
+
if (arguments[i] === null || arguments[i] === undefined || arguments[i] === "") continue;
|
|
31
|
+
|
|
32
|
+
const n = Number(arguments[i]);
|
|
33
|
+
|
|
34
|
+
if (Number.isFinite(n)) return n;
|
|
35
|
+
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
function normalizeDebugLevel(value) {
|
|
44
|
+
|
|
45
|
+
const level = String(value || "").toLowerCase();
|
|
46
|
+
|
|
47
|
+
if (level === "normal" || level === "verbose") return level;
|
|
48
|
+
|
|
49
|
+
return "off";
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
function ControlExportNode(config) {
|
|
55
|
+
|
|
56
|
+
RED.nodes.createNode(this, config);
|
|
57
|
+
|
|
58
|
+
const node = this;
|
|
59
|
+
|
|
60
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
node.name = config.name || "";
|
|
64
|
+
|
|
65
|
+
node.mode = config.mode || "zero_feed_in";
|
|
66
|
+
node.zeroFeedInMode = config.zeroFeedInMode === "absolute" ? "absolute" : "relative";
|
|
67
|
+
node.requireAllPhases = config.requireAllPhases !== false;
|
|
68
|
+
node.tuningPreset = (function (value) {
|
|
69
|
+
const key = String(value || "").toLowerCase();
|
|
70
|
+
if (key === "custom" || key === "stable_grid") return key;
|
|
71
|
+
return "normal";
|
|
72
|
+
})(config.tuningPreset);
|
|
73
|
+
|
|
74
|
+
const configuredGridSetPoint = toFiniteNumber(config.gridSetPoint, 0);
|
|
75
|
+
node.gridSetPoint = configuredGridSetPoint;
|
|
76
|
+
|
|
77
|
+
node.nominalPower = toFiniteNumber(config.nominalPower, 1000);
|
|
78
|
+
node.nominalPowerFromGlobal = !!config.nominalPowerFromGlobal;
|
|
79
|
+
node.nominalPowerGlobalVar = String(config.nominalPowerGlobalVar || "nominal_power").trim() || "nominal_power";
|
|
80
|
+
|
|
81
|
+
node.signConvention = (config.signConvention === "positive_export" || config.signConvention === "positive_import")
|
|
82
|
+
? config.signConvention
|
|
83
|
+
: "positive_import";
|
|
84
|
+
|
|
85
|
+
node.deadband = atLeast(config.deadband, 0, 180);
|
|
86
|
+
|
|
87
|
+
node.minLimit = toFiniteNumber(config.minLimit, 0);
|
|
88
|
+
|
|
89
|
+
node.configuredMaxLimit = toFiniteNumber(config.maxLimit, 1000);
|
|
90
|
+
node.maxLimitFromGlobal = !!config.maxLimitFromGlobal;
|
|
91
|
+
node.maxLimitGlobalVar = String(config.maxLimitGlobalVar || "max_limit").trim() || "max_limit";
|
|
92
|
+
node.maxLimit = node.configuredMaxLimit;
|
|
93
|
+
|
|
94
|
+
node.outputTopic = config.outputTopic || "site-limit";
|
|
95
|
+
|
|
96
|
+
node.smoothingAlpha = clampNumber(config.smoothingAlpha, 0, 1, 0.15);
|
|
97
|
+
|
|
98
|
+
node.filterTauMs = atLeast(config.filterTauMs, 0, 2800);
|
|
99
|
+
|
|
100
|
+
node.kp = atLeast(config.kp, 0, 0.22);
|
|
101
|
+
|
|
102
|
+
node.ki = atLeast(config.ki, 0, 0.01);
|
|
103
|
+
|
|
104
|
+
node.controlPeriodMs = atLeast(config.controlPeriodMs, 50, 800);
|
|
105
|
+
node.exportThresholdW = atLeast(config.exportThresholdW, 0, 60);
|
|
106
|
+
|
|
107
|
+
node.recoverThresholdW = atLeast(config.recoverThresholdW, 0, 25);
|
|
108
|
+
|
|
109
|
+
node.recoveryDelayMs = atLeast(config.recoveryDelayMs, 0, 3000);
|
|
110
|
+
node.transientIgnoreMs = atLeast(config.transientIgnoreMs, 0, 1200);
|
|
111
|
+
node.maxStepUpW = atLeast(config.maxStepUpW, 0, 500);
|
|
112
|
+
node.maxStepDownW = atLeast(config.maxStepDownW, 0, 1500);
|
|
113
|
+
node.adaptiveGains = config.adaptiveGains !== false;
|
|
114
|
+
node.adaptiveErrorBandW = atLeast(config.adaptiveErrorBandW, 1, 4000);
|
|
115
|
+
node.adaptiveMinScale = clampNumber(config.adaptiveMinScale, 0.05, 1, 0.2);
|
|
116
|
+
|
|
117
|
+
if (node.tuningPreset === "stable_grid") {
|
|
118
|
+
node.deadband = 150;
|
|
119
|
+
node.filterTauMs = 1500;
|
|
120
|
+
node.kp = 0.24;
|
|
121
|
+
node.ki = 0.008;
|
|
122
|
+
node.controlPeriodMs = 1000;
|
|
123
|
+
node.exportThresholdW = 100;
|
|
124
|
+
node.recoverThresholdW = 40;
|
|
125
|
+
node.recoveryDelayMs = 2000;
|
|
126
|
+
node.transientIgnoreMs = 800;
|
|
127
|
+
node.maxStepUpW = 1000;
|
|
128
|
+
node.maxStepDownW = 1800;
|
|
129
|
+
node.adaptiveGains = true;
|
|
130
|
+
node.adaptiveErrorBandW = 5000;
|
|
131
|
+
node.adaptiveMinScale = 0.45;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
node.maxStepW = atLeast(config.maxStepW, 0, 0); // compatibilitate
|
|
135
|
+
|
|
136
|
+
node.staleTimeoutMs = atLeast(config.staleTimeoutMs, 0, 5000);
|
|
137
|
+
node.maxFutureTimestampMs = atLeast(config.maxFutureTimestampMs, 0, 2000);
|
|
138
|
+
node.fastSetpointMaxTelemetryAgeMs = atLeast(config.fastSetpointMaxTelemetryAgeMs, 50, 600);
|
|
139
|
+
node.outputHeartbeatMs = clampNumber(config.outputHeartbeatMs, 250, 3500, 2000);
|
|
140
|
+
node.debugLevel = normalizeDebugLevel(config.debugLevel);
|
|
141
|
+
node.failSecureEnabled = !!config.failSecureEnabled;
|
|
142
|
+
node.failSecureTimeSec = atLeast(config.failSecureTimeSec, 1, 10);
|
|
143
|
+
node.a3TestsEnabled = !!config.a3TestsEnabled;
|
|
144
|
+
node.a3ModbusHost = String(config.a3ModbusHost || "0.0.0.0").trim() || "0.0.0.0";
|
|
145
|
+
node.a3ModbusPort = atLeast(config.a3ModbusPort, 1, 1502);
|
|
146
|
+
node.a3ModbusUnitId = atLeast(config.a3ModbusUnitId, 0, 1);
|
|
147
|
+
node.a3RampType = String(config.a3RampType || "none").trim() || "none";
|
|
148
|
+
node.a3RampValue = atLeast(config.a3RampValue, 0, 0);
|
|
149
|
+
|
|
150
|
+
if (node.a3TestsEnabled) {
|
|
151
|
+
node.mode = "nominal_power";
|
|
152
|
+
node.nominalPowerFromGlobal = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (node.maxLimit < node.minLimit) {
|
|
156
|
+
|
|
157
|
+
const swap = node.maxLimit;
|
|
158
|
+
|
|
159
|
+
node.maxLimit = node.minLimit;
|
|
160
|
+
|
|
161
|
+
node.minLimit = swap;
|
|
162
|
+
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
node.configuredMaxLimit = node.maxLimit;
|
|
166
|
+
|
|
167
|
+
node.recoverThresholdW = Math.min(node.recoverThresholdW, node.exportThresholdW);
|
|
168
|
+
|
|
169
|
+
node.safeLimitOnStale = toFiniteNumber(config.safeLimitOnStale, 0);
|
|
170
|
+
node.startLimitMode = (function(v) {
|
|
171
|
+
const m = String(v || "").toLowerCase();
|
|
172
|
+
if (m === "min_limit") return m;
|
|
173
|
+
return "last_limit";
|
|
174
|
+
})(config.startLimitMode);
|
|
175
|
+
node.useGlobalPvCap = !!config.useGlobalPvCap;
|
|
176
|
+
node.globalPvTotalVar = String(config.globalPvTotalVar || "total_pv").trim() || "total_pv";
|
|
177
|
+
node.globalPvCapOffsetW = atLeast(config.globalPvCapOffsetW, 0, 500);
|
|
178
|
+
node.pvCapOnlyBlocksIncrease = config.pvCapOnlyBlocksIncrease !== false;
|
|
179
|
+
node.pvCapHoldMs = atLeast(config.pvCapHoldMs, 0, 10000);
|
|
180
|
+
node.pvCapReleaseWPerSec = atLeast(config.pvCapReleaseWPerSec, 0, 1000);
|
|
181
|
+
node.feedForwardEnabled = config.feedForwardEnabled !== false;
|
|
182
|
+
node.feedForwardGain = clampNumber(config.feedForwardGain, 0, 1.5, 0.85);
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
let lastLimit = null;
|
|
186
|
+
|
|
187
|
+
let filteredGridReference = null;
|
|
188
|
+
|
|
189
|
+
let lastMeasurementTs = null;
|
|
190
|
+
let lastFilterWallClockMs = null;
|
|
191
|
+
let lastFilterSourceKey = null;
|
|
192
|
+
let gridMedianSamples = [];
|
|
193
|
+
|
|
194
|
+
let lastControlTs = 0;
|
|
195
|
+
|
|
196
|
+
let lastExportDetectedTs = 0;
|
|
197
|
+
|
|
198
|
+
let lastImmediateReductionTs = 0;
|
|
199
|
+
|
|
200
|
+
let lastRawGridReference = null;
|
|
201
|
+
|
|
202
|
+
let lastMsgTimestampMs = null;
|
|
203
|
+
|
|
204
|
+
let integratorState = 0;
|
|
205
|
+
let failSecureBreachStartMs = null;
|
|
206
|
+
let failSecureActive = false;
|
|
207
|
+
let lastFailSecureOutputActive = false;
|
|
208
|
+
let outsideDeadbandSinceMs = null;
|
|
209
|
+
let lastCriticalPhase = null;
|
|
210
|
+
let watchdogTimer = null;
|
|
211
|
+
let a3Server = null;
|
|
212
|
+
let lastInputAtMs = node.staleTimeoutMs > 0 && node.mode !== "nominal_power" ? Date.now() : null;
|
|
213
|
+
let lastAcceptedMeasurementTs = null;
|
|
214
|
+
let lastPersistedLimit = null;
|
|
215
|
+
let lastPersistedAtMs = 0;
|
|
216
|
+
let lastOutputAtMs = 0;
|
|
217
|
+
let lastOutputLimit = null;
|
|
218
|
+
let lastOutputStale = null;
|
|
219
|
+
let lastWatchdogSafeAtMs = 0;
|
|
220
|
+
const pvTelemetryBySource = new Map();
|
|
221
|
+
let pvAvailableEstimateW = null;
|
|
222
|
+
let pvEstimateHoldUntilMs = 0;
|
|
223
|
+
let lastPvEstimateUpdateMs = 0;
|
|
224
|
+
const nodeCtx = node.context();
|
|
225
|
+
const globalCtx = node.context().global;
|
|
226
|
+
const persistedLastLimitKey = "control_export_last_limit_w__" + String(node.id || "node");
|
|
227
|
+
const persistedGridSetpointKey = "control_export_grid_setpoint_w";
|
|
228
|
+
const persistedGridSetpointBaseKey = "control_export_grid_setpoint_base_w";
|
|
229
|
+
|
|
230
|
+
(function initializeRuntimeGridSetpoint() {
|
|
231
|
+
if (node.mode !== "grid_set_point") return;
|
|
232
|
+
const persisted = Number(nodeCtx.get(persistedGridSetpointKey));
|
|
233
|
+
const persistedBase = Number(nodeCtx.get(persistedGridSetpointBaseKey));
|
|
234
|
+
if (Number.isFinite(persisted) && Number.isFinite(persistedBase) && persistedBase === configuredGridSetPoint) {
|
|
235
|
+
node.gridSetPoint = persisted;
|
|
236
|
+
} else {
|
|
237
|
+
node.gridSetPoint = configuredGridSetPoint;
|
|
238
|
+
nodeCtx.set(persistedGridSetpointKey, node.gridSetPoint);
|
|
239
|
+
}
|
|
240
|
+
nodeCtx.set(persistedGridSetpointBaseKey, configuredGridSetPoint);
|
|
241
|
+
})();
|
|
242
|
+
|
|
243
|
+
function refreshMaxLimitFromGlobal() {
|
|
244
|
+
let maxLimit = node.configuredMaxLimit;
|
|
245
|
+
if (node.maxLimitFromGlobal) {
|
|
246
|
+
const raw = globalCtx.get(node.maxLimitGlobalVar);
|
|
247
|
+
const value = Number(raw);
|
|
248
|
+
if (Number.isFinite(value)) maxLimit = value;
|
|
249
|
+
}
|
|
250
|
+
node.maxLimit = Math.max(node.minLimit, maxLimit);
|
|
251
|
+
return node.maxLimit;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateRequiredGlobalValues() {
|
|
255
|
+
const requirements = [];
|
|
256
|
+
if (node.nominalPowerFromGlobal) requirements.push([node.nominalPowerGlobalVar, "Nominal Power"]);
|
|
257
|
+
if (node.maxLimitFromGlobal) requirements.push([node.maxLimitGlobalVar, "Max Limit"]);
|
|
258
|
+
|
|
259
|
+
for (const requirement of requirements) {
|
|
260
|
+
const key = String(requirement[0] || "").trim();
|
|
261
|
+
const raw = key ? globalCtx.get(key) : undefined;
|
|
262
|
+
if (raw === undefined || raw === null || raw === "" || !Number.isFinite(Number(raw))) {
|
|
263
|
+
node.status({ fill: "red", shape: "ring", text: "ALARM: global " + requirement[1] + " missing (" + key + ")" });
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function controlBands() {
|
|
272
|
+
const deadband = Math.max(0, Number(node.deadband) || 0);
|
|
273
|
+
return {
|
|
274
|
+
exportW: Math.min(deadband, Math.max(0, Number(node.exportThresholdW) || 0)),
|
|
275
|
+
importW: Math.min(deadband, Math.max(0, Number(node.recoverThresholdW) || 0))
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!validateRequiredGlobalValues()) {
|
|
280
|
+
node.warn("control-export blocked: required global value is missing or invalid");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getPersistedLastLimit() {
|
|
284
|
+
const localValue = Number(nodeCtx.get("control_export_last_limit_w"));
|
|
285
|
+
if (Number.isFinite(localValue)) {
|
|
286
|
+
return clamp(localValue, node.minLimit, node.maxLimit);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const globalValue = Number(globalCtx.get(persistedLastLimitKey));
|
|
290
|
+
if (Number.isFinite(globalValue)) {
|
|
291
|
+
return clamp(globalValue, node.minLimit, node.maxLimit);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function persistLastLimit(value, options) {
|
|
298
|
+
if (!Number.isFinite(value)) return;
|
|
299
|
+
const clamped = clamp(Number(value), node.minLimit, node.maxLimit);
|
|
300
|
+
const opts = options || {};
|
|
301
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
|
|
302
|
+
const force = opts.force === true;
|
|
303
|
+
if (!force && Number.isFinite(lastPersistedLimit)) {
|
|
304
|
+
const changedEnough = Math.abs(clamped - lastPersistedLimit) >= 1;
|
|
305
|
+
const elapsedEnough = (nowMs - lastPersistedAtMs) >= 5000;
|
|
306
|
+
if (!changedEnough && !elapsedEnough) return;
|
|
307
|
+
}
|
|
308
|
+
nodeCtx.set("control_export_last_limit_w", clamped);
|
|
309
|
+
globalCtx.set(persistedLastLimitKey, clamped);
|
|
310
|
+
lastPersistedLimit = clamped;
|
|
311
|
+
lastPersistedAtMs = nowMs;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
(function initializeStartLimit() {
|
|
315
|
+
if (node.startLimitMode === "last_limit") {
|
|
316
|
+
const persisted = getPersistedLastLimit();
|
|
317
|
+
if (Number.isFinite(persisted)) {
|
|
318
|
+
lastLimit = persisted;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
lastLimit = clamp(node.minLimit, node.minLimit, node.maxLimit);
|
|
324
|
+
})();
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
function extractTimestampMs(msg) {
|
|
328
|
+
|
|
329
|
+
const candidate = firstFinite(
|
|
330
|
+
|
|
331
|
+
msg && msg.timestamp,
|
|
332
|
+
|
|
333
|
+
msg && msg.ts,
|
|
334
|
+
|
|
335
|
+
msg && msg.time,
|
|
336
|
+
|
|
337
|
+
msg && msg.timestamp_ms,
|
|
338
|
+
|
|
339
|
+
msg && msg.time_ms,
|
|
340
|
+
|
|
341
|
+
msg && msg.payload && msg.payload.timestamp,
|
|
342
|
+
|
|
343
|
+
msg && msg.payload && msg.payload.ts,
|
|
344
|
+
|
|
345
|
+
msg && msg.payload && msg.payload.time,
|
|
346
|
+
|
|
347
|
+
msg && msg.payload && msg.payload.timestamp_ms,
|
|
348
|
+
|
|
349
|
+
msg && msg.payload && msg.payload.time_ms
|
|
350
|
+
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if (candidate === null) return Date.now();
|
|
355
|
+
|
|
356
|
+
if (candidate < 1e11) return Math.round(candidate * 1000);
|
|
357
|
+
|
|
358
|
+
return Math.round(candidate);
|
|
359
|
+
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
function hasExplicitGridReference(payload) {
|
|
364
|
+
if (!payload || typeof payload !== "object") return false;
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
payload.grid_reference_w !== undefined ||
|
|
368
|
+
payload.pcc_power !== undefined ||
|
|
369
|
+
payload.pcc_power_w !== undefined ||
|
|
370
|
+
payload.grid_power !== undefined ||
|
|
371
|
+
payload.grid_power_w !== undefined ||
|
|
372
|
+
(payload.summary && payload.summary.power && payload.summary.power.total_w !== undefined) ||
|
|
373
|
+
(payload.summary && payload.summary.meter && payload.summary.meter.power_w !== undefined)
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isControlCommandMessage(msg) {
|
|
378
|
+
const payload = msg && msg.payload;
|
|
379
|
+
const topic = String(msg && msg.topic || "").trim().toLowerCase();
|
|
380
|
+
|
|
381
|
+
if (topic === String(node.outputTopic || "").trim().toLowerCase()) return true;
|
|
382
|
+
if (topic === "site-limit" || topic === "control-export") return true;
|
|
383
|
+
if (topic.indexOf("control-export/") === 0) return true;
|
|
384
|
+
|
|
385
|
+
if (!payload || typeof payload !== "object") return false;
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
payload.site_limit_w !== undefined ||
|
|
389
|
+
payload.limit_w !== undefined ||
|
|
390
|
+
payload.grid_target_w !== undefined ||
|
|
391
|
+
payload.controller_state !== undefined ||
|
|
392
|
+
payload.control_active !== undefined
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function topicLooksLikeInverterTelemetry(topic) {
|
|
397
|
+
const t = String(topic || "").trim().toLowerCase();
|
|
398
|
+
if (!t) return false;
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
t.indexOf("meter") >= 0 ||
|
|
402
|
+
t.indexOf("grid") >= 0 ||
|
|
403
|
+
t.indexOf("pcc") >= 0 ||
|
|
404
|
+
t.indexOf("carlo") >= 0 ||
|
|
405
|
+
t.indexOf("gavazzi") >= 0
|
|
406
|
+
) {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
/^inv(\b|[_-]?\d+)/.test(t) ||
|
|
412
|
+
t.indexOf("inverter") >= 0 ||
|
|
413
|
+
t.indexOf("fronius") >= 0 ||
|
|
414
|
+
t.indexOf("pv") >= 0
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isInverterTelemetryWithoutGridReference(msg) {
|
|
419
|
+
const payload = msg && msg.payload;
|
|
420
|
+
if (!payload || typeof payload !== "object") return false;
|
|
421
|
+
if (hasExplicitGridReference(payload)) return false;
|
|
422
|
+
|
|
423
|
+
const deviceType = String(
|
|
424
|
+
payload.device_type ||
|
|
425
|
+
msg.device_type ||
|
|
426
|
+
""
|
|
427
|
+
).toLowerCase();
|
|
428
|
+
const payloadField = String(
|
|
429
|
+
payload.payload_field ||
|
|
430
|
+
msg.payload_field ||
|
|
431
|
+
""
|
|
432
|
+
).toLowerCase();
|
|
433
|
+
|
|
434
|
+
if (
|
|
435
|
+
payload.meter_id ||
|
|
436
|
+
msg.meter_id ||
|
|
437
|
+
deviceType.indexOf("meter") >= 0 ||
|
|
438
|
+
payloadField.indexOf("meter") >= 0 ||
|
|
439
|
+
payloadField.indexOf("grid") >= 0 ||
|
|
440
|
+
payloadField.indexOf("pcc") >= 0
|
|
441
|
+
) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (deviceType.indexOf("inverter") >= 0 || deviceType.indexOf("pv") >= 0) {
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (topicLooksLikeInverterTelemetry(msg && msg.topic)) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return !!(
|
|
454
|
+
payload.inverter_id ||
|
|
455
|
+
payload.inverter_type ||
|
|
456
|
+
payload.inverter_profile ||
|
|
457
|
+
msg.inverter_id ||
|
|
458
|
+
msg.inverter_type ||
|
|
459
|
+
msg.inverter_profile
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function extractGridReference(msg) {
|
|
464
|
+
|
|
465
|
+
const p = msg.payload;
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
if (typeof p === "number") return p;
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
if (typeof p === "string" && p.trim() !== "" && Number.isFinite(Number(p))) {
|
|
472
|
+
|
|
473
|
+
return Number(p);
|
|
474
|
+
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if (p && typeof p === "object") {
|
|
479
|
+
if (isInverterTelemetryWithoutGridReference(msg)) return null;
|
|
480
|
+
|
|
481
|
+
const candidate = firstFinite(
|
|
482
|
+
|
|
483
|
+
p.grid_reference_w,
|
|
484
|
+
|
|
485
|
+
p.pcc_power,
|
|
486
|
+
|
|
487
|
+
p.pcc_power_w,
|
|
488
|
+
|
|
489
|
+
p.grid_power,
|
|
490
|
+
|
|
491
|
+
p.grid_power_w,
|
|
492
|
+
|
|
493
|
+
p.summary && p.summary.meter && p.summary.meter.power_w,
|
|
494
|
+
|
|
495
|
+
p.summary && p.summary.power && p.summary.power.total_w,
|
|
496
|
+
|
|
497
|
+
p.power_w,
|
|
498
|
+
|
|
499
|
+
p.power,
|
|
500
|
+
|
|
501
|
+
p.value
|
|
502
|
+
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (candidate !== null) return candidate;
|
|
506
|
+
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
return null;
|
|
511
|
+
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
function extractWorstPhaseReference(msg) {
|
|
516
|
+
|
|
517
|
+
const payload = msg && msg.payload;
|
|
518
|
+
|
|
519
|
+
if (!payload || typeof payload !== "object") {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const phaseValues = [
|
|
524
|
+
{
|
|
525
|
+
name: "L1",
|
|
526
|
+
value: firstFinite(
|
|
527
|
+
payload.power_l1_w,
|
|
528
|
+
payload.l1_power_w,
|
|
529
|
+
payload.l1_w,
|
|
530
|
+
payload.summary && payload.summary.power && payload.summary.power.l1_w
|
|
531
|
+
)
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "L2",
|
|
535
|
+
value: firstFinite(
|
|
536
|
+
payload.power_l2_w,
|
|
537
|
+
payload.l2_power_w,
|
|
538
|
+
payload.l2_w,
|
|
539
|
+
payload.summary && payload.summary.power && payload.summary.power.l2_w
|
|
540
|
+
)
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
name: "L3",
|
|
544
|
+
value: firstFinite(
|
|
545
|
+
payload.power_l3_w,
|
|
546
|
+
payload.l3_power_w,
|
|
547
|
+
payload.l3_w,
|
|
548
|
+
payload.summary && payload.summary.power && payload.summary.power.l3_w
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
];
|
|
552
|
+
|
|
553
|
+
const validPhaseValues = phaseValues.filter(function (phase) {
|
|
554
|
+
return Number.isFinite(phase.value);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
if (node.requireAllPhases && validPhaseValues.length !== 3) {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (!validPhaseValues.length) {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const worst = validPhaseValues.reduce(function (best, phase) {
|
|
566
|
+
const normalized = normalizeGridReference(phase.value);
|
|
567
|
+
if (!best) {
|
|
568
|
+
return {
|
|
569
|
+
name: phase.name,
|
|
570
|
+
raw: phase.value,
|
|
571
|
+
normalized: normalized
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// In normalized convention, larger values mean stronger export.
|
|
576
|
+
// Pick the phase with the highest export pressure so zero-feed-in
|
|
577
|
+
// protects all phases, not only the one closest to 0.
|
|
578
|
+
if (normalized > best.normalized) {
|
|
579
|
+
return {
|
|
580
|
+
name: phase.name,
|
|
581
|
+
raw: phase.value,
|
|
582
|
+
normalized: normalized
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return best;
|
|
587
|
+
}, null);
|
|
588
|
+
|
|
589
|
+
lastCriticalPhase = worst ? worst.name : null;
|
|
590
|
+
return worst ? worst.raw : null;
|
|
591
|
+
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
function extractControlReference(msg) {
|
|
596
|
+
|
|
597
|
+
if (node.mode !== "zero_feed_in") {
|
|
598
|
+
return extractGridReference(msg);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (node.zeroFeedInMode === "absolute") {
|
|
602
|
+
return extractWorstPhaseReference(msg);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (isInverterTelemetryWithoutGridReference(msg)) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
lastCriticalPhase = null;
|
|
609
|
+
return extractGridReference(msg);
|
|
610
|
+
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
function normalizeByConvention(rawValue) {
|
|
615
|
+
|
|
616
|
+
if (node.signConvention === "positive_import") return -rawValue;
|
|
617
|
+
|
|
618
|
+
return rawValue;
|
|
619
|
+
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
function denormalizeByConvention(normalizedValue) {
|
|
624
|
+
|
|
625
|
+
if (node.signConvention === "positive_import") return -normalizedValue;
|
|
626
|
+
|
|
627
|
+
return normalizedValue;
|
|
628
|
+
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
function normalizeGridReference(rawValue) {
|
|
633
|
+
|
|
634
|
+
return normalizeByConvention(rawValue);
|
|
635
|
+
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
function isStale(timestampMs, nowMs) {
|
|
640
|
+
|
|
641
|
+
if (node.staleTimeoutMs <= 0) return false;
|
|
642
|
+
|
|
643
|
+
if (!Number.isFinite(timestampMs)) return true;
|
|
644
|
+
|
|
645
|
+
return (nowMs - timestampMs) > node.staleTimeoutMs;
|
|
646
|
+
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
function updateFilter(gridReference, measurementTsMs, nowMs, sourceKey) {
|
|
651
|
+
|
|
652
|
+
if (!Number.isFinite(gridReference)) return filteredGridReference;
|
|
653
|
+
|
|
654
|
+
const effectiveSourceKey = String(sourceKey || "total");
|
|
655
|
+
if (lastFilterSourceKey !== effectiveSourceKey) {
|
|
656
|
+
gridMedianSamples = [];
|
|
657
|
+
}
|
|
658
|
+
gridMedianSamples.push(gridReference);
|
|
659
|
+
if (gridMedianSamples.length > 3) gridMedianSamples.shift();
|
|
660
|
+
const filterInput = gridMedianSamples.length < 3
|
|
661
|
+
? gridReference
|
|
662
|
+
: gridMedianSamples.slice().sort(function (a, b) { return a - b; })[1];
|
|
663
|
+
|
|
664
|
+
if (
|
|
665
|
+
filteredGridReference === null ||
|
|
666
|
+
!Number.isFinite(lastMeasurementTs) ||
|
|
667
|
+
(lastFilterSourceKey !== null && lastFilterSourceKey !== effectiveSourceKey)
|
|
668
|
+
) {
|
|
669
|
+
|
|
670
|
+
filteredGridReference = filterInput;
|
|
671
|
+
|
|
672
|
+
lastMeasurementTs = measurementTsMs;
|
|
673
|
+
lastFilterWallClockMs = nowMs;
|
|
674
|
+
lastFilterSourceKey = effectiveSourceKey;
|
|
675
|
+
|
|
676
|
+
return filteredGridReference;
|
|
677
|
+
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
if (node.filterTauMs > 0) {
|
|
682
|
+
|
|
683
|
+
const sourceDtMs = measurementTsMs - lastMeasurementTs;
|
|
684
|
+
const arrivalDtMs = Number.isFinite(lastFilterWallClockMs) ? nowMs - lastFilterWallClockMs : NaN;
|
|
685
|
+
const dtMs = sourceDtMs > 0
|
|
686
|
+
? sourceDtMs
|
|
687
|
+
: (arrivalDtMs > 0 ? arrivalDtMs : 1);
|
|
688
|
+
|
|
689
|
+
const alpha = clamp(dtMs / (node.filterTauMs + dtMs), 0, 1);
|
|
690
|
+
|
|
691
|
+
filteredGridReference = alpha * filterInput + (1 - alpha) * filteredGridReference;
|
|
692
|
+
|
|
693
|
+
} else {
|
|
694
|
+
|
|
695
|
+
filteredGridReference =
|
|
696
|
+
|
|
697
|
+
node.smoothingAlpha * filterInput +
|
|
698
|
+
|
|
699
|
+
(1 - node.smoothingAlpha) * filteredGridReference;
|
|
700
|
+
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
lastMeasurementTs = measurementTsMs;
|
|
705
|
+
lastFilterWallClockMs = nowMs;
|
|
706
|
+
lastFilterSourceKey = effectiveSourceKey;
|
|
707
|
+
|
|
708
|
+
return filteredGridReference;
|
|
709
|
+
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
function getGridTarget() {
|
|
714
|
+
|
|
715
|
+
const rawTarget = node.mode === "zero_feed_in" ? 0 : node.gridSetPoint;
|
|
716
|
+
|
|
717
|
+
return normalizeByConvention(rawTarget);
|
|
718
|
+
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function extractFastSetpointCommand(msg, nowMs) {
|
|
722
|
+
const topic = String(msg && msg.topic || "").trim().toLowerCase();
|
|
723
|
+
const payload = msg && msg.payload;
|
|
724
|
+
if (topic !== "control-export/setpoint" || !payload || typeof payload !== "object") return null;
|
|
725
|
+
|
|
726
|
+
const value = Number(payload.grid_set_point_w);
|
|
727
|
+
if (!Number.isFinite(value)) {
|
|
728
|
+
throw new Error("grid_set_point_w trebuie sa fie numeric");
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const requestedAtMs = extractTimestampMs(msg);
|
|
732
|
+
if ((requestedAtMs - nowMs) > node.maxFutureTimestampMs) {
|
|
733
|
+
throw new Error("Timestamp setpoint din viitor peste limita admisa");
|
|
734
|
+
}
|
|
735
|
+
if ((nowMs - requestedAtMs) > node.fastSetpointMaxTelemetryAgeMs) {
|
|
736
|
+
throw new Error("Comanda grid setpoint este expirata");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
value: value,
|
|
741
|
+
commandId: String(payload.command_id || msg.command_id || ("setpoint-" + nowMs)),
|
|
742
|
+
requestedAtMs: requestedAtMs,
|
|
743
|
+
receivedAtMs: nowMs
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
function integratorBounds() {
|
|
749
|
+
|
|
750
|
+
const span = Math.max(0, node.maxLimit - node.minLimit);
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
|
|
754
|
+
min: -span,
|
|
755
|
+
|
|
756
|
+
max: span
|
|
757
|
+
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function effectiveGains(errorAbs) {
|
|
763
|
+
if (!node.adaptiveGains) {
|
|
764
|
+
return { kp: node.kp, ki: node.ki, scale: 1 };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const progress = clamp(Number(errorAbs || 0) / node.adaptiveErrorBandW, 0, 1);
|
|
768
|
+
const scale = node.adaptiveMinScale + (1 - node.adaptiveMinScale) * progress;
|
|
769
|
+
return {
|
|
770
|
+
kp: node.kp * scale,
|
|
771
|
+
ki: node.ki * scale,
|
|
772
|
+
scale: scale
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
function canIncreaseLimit(nowMs, filteredReference, target) {
|
|
778
|
+
|
|
779
|
+
if (!Number.isFinite(filteredReference)) return true;
|
|
780
|
+
|
|
781
|
+
if (filteredReference > (target + controlBands().exportW)) return false;
|
|
782
|
+
|
|
783
|
+
return (nowMs - lastExportDetectedTs) >= node.recoveryDelayMs;
|
|
784
|
+
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function canReduceImmediately(nowMs, protectiveReference, target, controlDue) {
|
|
788
|
+
|
|
789
|
+
if (controlDue) return false;
|
|
790
|
+
|
|
791
|
+
if (!Number.isFinite(protectiveReference) || !Number.isFinite(target)) return false;
|
|
792
|
+
|
|
793
|
+
const overshootW = protectiveReference - target;
|
|
794
|
+
|
|
795
|
+
const requiredOvershootW = controlBands().exportW;
|
|
796
|
+
|
|
797
|
+
if (overshootW <= requiredOvershootW) return false;
|
|
798
|
+
|
|
799
|
+
if (lastImmediateReductionTs <= 0) return true;
|
|
800
|
+
|
|
801
|
+
const cooldownMs = Math.max(
|
|
802
|
+
Number(node.transientIgnoreMs) || 0,
|
|
803
|
+
Number(node.controlPeriodMs) || 0,
|
|
804
|
+
800
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
return (nowMs - lastImmediateReductionTs) >= cooldownMs;
|
|
808
|
+
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function computeClosedLoopLimit(filteredReference, nowMs, dtMs, protectiveMode, pvTotalW) {
|
|
812
|
+
|
|
813
|
+
const target = getGridTarget();
|
|
814
|
+
|
|
815
|
+
const error = filteredReference - target;
|
|
816
|
+
const bands = controlBands();
|
|
817
|
+
|
|
818
|
+
const previousLimit = Number.isFinite(lastLimit)
|
|
819
|
+
|
|
820
|
+
? lastLimit
|
|
821
|
+
|
|
822
|
+
: clamp(node.maxLimit, node.minLimit, node.maxLimit);
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
const exportDetected = error > bands.exportW;
|
|
826
|
+
|
|
827
|
+
if (exportDetected) {
|
|
828
|
+
|
|
829
|
+
lastExportDetectedTs = nowMs;
|
|
830
|
+
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
let candidateLimit = previousLimit;
|
|
834
|
+
let reason = "deadband";
|
|
835
|
+
let recoveryBlocked = false;
|
|
836
|
+
let pTerm = 0;
|
|
837
|
+
let stepLimited = false;
|
|
838
|
+
let gains = effectiveGains(Math.abs(error));
|
|
839
|
+
let effectiveKi = gains.ki;
|
|
840
|
+
let candidateIntegrator = integratorState;
|
|
841
|
+
let feedForwardTargetW = null;
|
|
842
|
+
let feedForwardDeltaW = 0;
|
|
843
|
+
if (node.feedForwardEnabled && Number.isFinite(pvTotalW)) {
|
|
844
|
+
feedForwardTargetW = clamp(
|
|
845
|
+
Math.max(0, Number(pvTotalW)) - node.feedForwardGain * error,
|
|
846
|
+
node.minLimit,
|
|
847
|
+
node.maxLimit
|
|
848
|
+
);
|
|
849
|
+
feedForwardDeltaW = feedForwardTargetW - previousLimit;
|
|
850
|
+
}
|
|
851
|
+
const boundedDtMs = Math.min(
|
|
852
|
+
Math.max(1, Number(dtMs) || node.controlPeriodMs),
|
|
853
|
+
Math.max(node.controlPeriodMs * 4, 1000)
|
|
854
|
+
);
|
|
855
|
+
const dtSeconds = boundedDtMs / 1000;
|
|
856
|
+
const insideBand = error <= bands.exportW && error >= -bands.importW;
|
|
857
|
+
|
|
858
|
+
if (insideBand && Number.isFinite(lastLimit)) {
|
|
859
|
+
outsideDeadbandSinceMs = null;
|
|
860
|
+
integratorState = 0;
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
|
|
864
|
+
siteLimit: previousLimit,
|
|
865
|
+
previousLimit: previousLimit,
|
|
866
|
+
|
|
867
|
+
gridTarget: target,
|
|
868
|
+
|
|
869
|
+
correction: 0,
|
|
870
|
+
|
|
871
|
+
error: error,
|
|
872
|
+
|
|
873
|
+
pTerm: 0,
|
|
874
|
+
|
|
875
|
+
iTerm: integratorState,
|
|
876
|
+
|
|
877
|
+
active: false,
|
|
878
|
+
|
|
879
|
+
exportDetected: exportDetected,
|
|
880
|
+
|
|
881
|
+
rampDirection: "hold",
|
|
882
|
+
|
|
883
|
+
reason: "deadband",
|
|
884
|
+
|
|
885
|
+
controllerState: "inside_deadband",
|
|
886
|
+
gainsScale: gains.scale,
|
|
887
|
+
effectiveKp: gains.kp,
|
|
888
|
+
effectiveKi: gains.ki,
|
|
889
|
+
feedForwardTargetW: feedForwardTargetW,
|
|
890
|
+
feedForwardDeltaW: feedForwardDeltaW,
|
|
891
|
+
|
|
892
|
+
recoveryBlocked: false,
|
|
893
|
+
|
|
894
|
+
clampedByMin: false,
|
|
895
|
+
|
|
896
|
+
clampedByMax: false
|
|
897
|
+
|
|
898
|
+
};
|
|
899
|
+
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (!Number.isFinite(outsideDeadbandSinceMs)) {
|
|
903
|
+
outsideDeadbandSinceMs = nowMs;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const outsideDurationMs = Math.max(0, nowMs - outsideDeadbandSinceMs);
|
|
907
|
+
const transientBlocked = error < -bands.importW && outsideDurationMs < node.transientIgnoreMs;
|
|
908
|
+
|
|
909
|
+
if (exportDetected) {
|
|
910
|
+
pTerm = gains.kp * error;
|
|
911
|
+
const bounds = integratorBounds();
|
|
912
|
+
candidateIntegrator = clamp(
|
|
913
|
+
integratorState + gains.ki * error * dtSeconds,
|
|
914
|
+
bounds.min,
|
|
915
|
+
bounds.max
|
|
916
|
+
);
|
|
917
|
+
let correctionW = Math.max(0, pTerm + candidateIntegrator);
|
|
918
|
+
if (Number.isFinite(feedForwardTargetW)) {
|
|
919
|
+
correctionW = Math.max(correctionW, previousLimit - feedForwardTargetW);
|
|
920
|
+
}
|
|
921
|
+
if (protectiveMode) {
|
|
922
|
+
correctionW = Math.max(correctionW, error - bands.exportW);
|
|
923
|
+
}
|
|
924
|
+
candidateLimit = previousLimit - correctionW;
|
|
925
|
+
reason = "export_limit";
|
|
926
|
+
} else if (error < -bands.importW) {
|
|
927
|
+
if (!canIncreaseLimit(nowMs, filteredReference, target)) {
|
|
928
|
+
candidateLimit = previousLimit;
|
|
929
|
+
reason = "recovery_hold";
|
|
930
|
+
recoveryBlocked = true;
|
|
931
|
+
effectiveKi = 0;
|
|
932
|
+
} else if (transientBlocked) {
|
|
933
|
+
candidateLimit = previousLimit;
|
|
934
|
+
reason = "transient_ignore";
|
|
935
|
+
recoveryBlocked = true;
|
|
936
|
+
effectiveKi = 0;
|
|
937
|
+
} else {
|
|
938
|
+
const bounds = integratorBounds();
|
|
939
|
+
pTerm = gains.kp * error;
|
|
940
|
+
candidateIntegrator = clamp(
|
|
941
|
+
integratorState + gains.ki * error * dtSeconds,
|
|
942
|
+
bounds.min,
|
|
943
|
+
bounds.max
|
|
944
|
+
);
|
|
945
|
+
const marginW = Math.max(0, target - filteredReference);
|
|
946
|
+
const requestedIncreaseW = Math.max(
|
|
947
|
+
0,
|
|
948
|
+
-(pTerm + candidateIntegrator),
|
|
949
|
+
Number.isFinite(feedForwardTargetW) ? feedForwardTargetW - previousLimit : 0
|
|
950
|
+
);
|
|
951
|
+
const stepUpW = Math.min(
|
|
952
|
+
marginW,
|
|
953
|
+
node.maxStepUpW > 0 ? node.maxStepUpW : Infinity,
|
|
954
|
+
requestedIncreaseW
|
|
955
|
+
);
|
|
956
|
+
candidateLimit = previousLimit + stepUpW;
|
|
957
|
+
reason = "recovery";
|
|
958
|
+
}
|
|
959
|
+
} else {
|
|
960
|
+
candidateLimit = previousLimit;
|
|
961
|
+
reason = "deadband";
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const maxStepUp = node.maxStepUpW > 0 ? node.maxStepUpW : Infinity;
|
|
965
|
+
const maxStepDown = node.maxStepDownW > 0 ? node.maxStepDownW : Infinity;
|
|
966
|
+
const rawDelta = candidateLimit - previousLimit;
|
|
967
|
+
|
|
968
|
+
if (rawDelta > maxStepUp) {
|
|
969
|
+
candidateLimit = previousLimit + maxStepUp;
|
|
970
|
+
stepLimited = true;
|
|
971
|
+
} else if (rawDelta < -maxStepDown) {
|
|
972
|
+
candidateLimit = previousLimit - maxStepDown;
|
|
973
|
+
stepLimited = true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
const unclampedCandidate = candidateLimit;
|
|
978
|
+
|
|
979
|
+
candidateLimit = clamp(candidateLimit, node.minLimit, node.maxLimit);
|
|
980
|
+
|
|
981
|
+
const clampedByMin = candidateLimit <= node.minLimit && unclampedCandidate < node.minLimit;
|
|
982
|
+
|
|
983
|
+
const clampedByMax = candidateLimit >= node.maxLimit && unclampedCandidate > node.maxLimit;
|
|
984
|
+
|
|
985
|
+
const constrainedInControlDirection =
|
|
986
|
+
(error > bands.exportW && candidateLimit > unclampedCandidate) ||
|
|
987
|
+
(error < -bands.importW && candidateLimit < unclampedCandidate) ||
|
|
988
|
+
stepLimited || recoveryBlocked;
|
|
989
|
+
if (!constrainedInControlDirection) {
|
|
990
|
+
integratorState = candidateIntegrator;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
const rampDirection = candidateLimit > previousLimit
|
|
995
|
+
|
|
996
|
+
? "up"
|
|
997
|
+
|
|
998
|
+
: candidateLimit < previousLimit
|
|
999
|
+
|
|
1000
|
+
? "down"
|
|
1001
|
+
|
|
1002
|
+
: "hold";
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
let controllerState = "tracking";
|
|
1006
|
+
|
|
1007
|
+
if (clampedByMin && error > bands.exportW) controllerState = "target_unreachable_at_min";
|
|
1008
|
+
|
|
1009
|
+
else if (clampedByMax && error < -bands.importW) controllerState = "target_unreachable_at_max";
|
|
1010
|
+
|
|
1011
|
+
else if (recoveryBlocked) controllerState = reason === "transient_ignore" ? "transient_ignore" : "recovery_blocked";
|
|
1012
|
+
|
|
1013
|
+
else if (clampedByMin) controllerState = "at_min_limit";
|
|
1014
|
+
|
|
1015
|
+
else if (clampedByMax) controllerState = "at_max_limit";
|
|
1016
|
+
|
|
1017
|
+
else if (rampDirection === "up") controllerState = "increasing_limit";
|
|
1018
|
+
|
|
1019
|
+
else if (rampDirection === "down") controllerState = "decreasing_limit";
|
|
1020
|
+
else if (reason === "deadband") controllerState = "inside_deadband";
|
|
1021
|
+
if (stepLimited && controllerState === "tracking") controllerState = "step_limited";
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
|
|
1026
|
+
siteLimit: candidateLimit,
|
|
1027
|
+
previousLimit: previousLimit,
|
|
1028
|
+
|
|
1029
|
+
gridTarget: target,
|
|
1030
|
+
|
|
1031
|
+
correction: previousLimit - candidateLimit,
|
|
1032
|
+
|
|
1033
|
+
error: error,
|
|
1034
|
+
|
|
1035
|
+
pTerm: pTerm,
|
|
1036
|
+
|
|
1037
|
+
iTerm: integratorState,
|
|
1038
|
+
|
|
1039
|
+
active: rampDirection !== "hold",
|
|
1040
|
+
|
|
1041
|
+
exportDetected: exportDetected,
|
|
1042
|
+
|
|
1043
|
+
rampDirection: rampDirection,
|
|
1044
|
+
|
|
1045
|
+
reason: reason,
|
|
1046
|
+
|
|
1047
|
+
controllerState: controllerState,
|
|
1048
|
+
gainsScale: gains.scale,
|
|
1049
|
+
effectiveKp: gains.kp,
|
|
1050
|
+
effectiveKi: effectiveKi,
|
|
1051
|
+
feedForwardTargetW: feedForwardTargetW,
|
|
1052
|
+
feedForwardDeltaW: feedForwardDeltaW,
|
|
1053
|
+
|
|
1054
|
+
recoveryBlocked: recoveryBlocked,
|
|
1055
|
+
|
|
1056
|
+
clampedByMin: clampedByMin,
|
|
1057
|
+
|
|
1058
|
+
clampedByMax: clampedByMax
|
|
1059
|
+
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function buildStatus(info) {
|
|
1065
|
+
|
|
1066
|
+
if (info.reason === "stale") {
|
|
1067
|
+
|
|
1068
|
+
node.status({ fill: "yellow", shape: "ring", text: "stale | limit " + Math.round(info.siteLimit) + "W" });
|
|
1069
|
+
|
|
1070
|
+
return;
|
|
1071
|
+
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
if (info.mode === "nominal_power") {
|
|
1076
|
+
|
|
1077
|
+
node.status({ fill: "blue", shape: "dot", text: "nominal | limit " + Math.round(info.siteLimit) + "W" });
|
|
1078
|
+
|
|
1079
|
+
return;
|
|
1080
|
+
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
const gridNow = Number.isFinite(info.gridReference) ? Math.round(denormalizeByConvention(info.gridReference)) : 0;
|
|
1085
|
+
|
|
1086
|
+
const target = Number.isFinite(info.gridTarget) ? Math.round(denormalizeByConvention(info.gridTarget)) : 0;
|
|
1087
|
+
|
|
1088
|
+
const limit = Math.round(info.siteLimit);
|
|
1089
|
+
|
|
1090
|
+
const state = info.controllerState || info.reason || "idle";
|
|
1091
|
+
const text = "grid " + gridNow + "W | target " + target + "W | limit " + limit + "W | " + state;
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
if (info.active) {
|
|
1095
|
+
|
|
1096
|
+
node.status({
|
|
1097
|
+
|
|
1098
|
+
fill: "green",
|
|
1099
|
+
|
|
1100
|
+
shape: "dot",
|
|
1101
|
+
|
|
1102
|
+
text: text
|
|
1103
|
+
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
return;
|
|
1107
|
+
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
node.status({
|
|
1112
|
+
|
|
1113
|
+
fill: "blue",
|
|
1114
|
+
|
|
1115
|
+
shape: "ring",
|
|
1116
|
+
|
|
1117
|
+
text: text
|
|
1118
|
+
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
function buildOutputPayload(context) {
|
|
1125
|
+
|
|
1126
|
+
const gridReferenceOut = Number.isFinite(context.gridReference)
|
|
1127
|
+
? denormalizeByConvention(context.gridReference)
|
|
1128
|
+
: context.gridReference;
|
|
1129
|
+
const filteredReferenceOut = Number.isFinite(context.filteredReference)
|
|
1130
|
+
? denormalizeByConvention(context.filteredReference)
|
|
1131
|
+
: context.filteredReference;
|
|
1132
|
+
const gridTargetOut = Number.isFinite(context.gridTarget)
|
|
1133
|
+
? denormalizeByConvention(context.gridTarget)
|
|
1134
|
+
: context.gridTarget;
|
|
1135
|
+
const basePayload = {
|
|
1136
|
+
site_limit_w: context.siteLimit,
|
|
1137
|
+
grid_reference_w: filteredReferenceOut,
|
|
1138
|
+
grid_target_w: gridTargetOut,
|
|
1139
|
+
reason: context.reason,
|
|
1140
|
+
controller_state: context.controllerState,
|
|
1141
|
+
stale: context.stale,
|
|
1142
|
+
pv_total_w: Number.isFinite(context.pvTotalW) ? Math.round(context.pvTotalW) : null,
|
|
1143
|
+
pv_cap_limit_w: Number.isFinite(context.pvCapLimitW) ? Math.round(context.pvCapLimitW) : null,
|
|
1144
|
+
pv_cap_offset_w: Number.isFinite(context.pvCapOffsetW) ? Math.round(context.pvCapOffsetW) : null,
|
|
1145
|
+
pv_cap_applied: !!context.pvCapApplied,
|
|
1146
|
+
pv_available_estimate_w: Number.isFinite(context.pvAvailableEstimateW) ? Math.round(context.pvAvailableEstimateW) : null,
|
|
1147
|
+
pv_cap_blocked_increase: !!context.pvCapBlockedIncrease,
|
|
1148
|
+
controller_requested_limit_w: Number.isFinite(context.controllerRequestedLimitW)
|
|
1149
|
+
? Math.round(context.controllerRequestedLimitW)
|
|
1150
|
+
: Math.round(context.siteLimit),
|
|
1151
|
+
feed_forward_target_w: Number.isFinite(context.feedForwardTargetW) ? Math.round(context.feedForwardTargetW) : null,
|
|
1152
|
+
timestamp_ms: context.msgTimestampMs
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
if (node.a3TestsEnabled) {
|
|
1156
|
+
const command = a3Server ? a3Server.getCommand() : null;
|
|
1157
|
+
basePayload.powerType = "active";
|
|
1158
|
+
basePayload.mode = "watts";
|
|
1159
|
+
basePayload.a3_tests = true;
|
|
1160
|
+
basePayload.ramp = {
|
|
1161
|
+
type: command ? command.rampType : node.a3RampType,
|
|
1162
|
+
value: command ? command.rampValue : node.a3RampValue
|
|
1163
|
+
};
|
|
1164
|
+
basePayload.a3_sequence = command ? command.sequence : 0;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (context.fastCommand) {
|
|
1168
|
+
basePayload.command_id = context.fastCommand.commandId;
|
|
1169
|
+
basePayload.command_requested_at_ms = context.fastCommand.requestedAtMs;
|
|
1170
|
+
basePayload.control_received_at_ms = context.fastCommand.receivedAtMs;
|
|
1171
|
+
basePayload.control_calculated_at_ms = Date.now();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (node.debugLevel === "off") {
|
|
1175
|
+
return basePayload;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const normalPayload = Object.assign({}, basePayload, {
|
|
1179
|
+
raw_grid_reference_w: context.rawGridReference,
|
|
1180
|
+
filtered_grid_reference_w: filteredReferenceOut,
|
|
1181
|
+
error_w: context.error,
|
|
1182
|
+
correction_w: context.correction,
|
|
1183
|
+
control_active: context.active,
|
|
1184
|
+
export_detected: context.exportDetected,
|
|
1185
|
+
recovery_blocked: context.recoveryBlocked,
|
|
1186
|
+
ramp_direction: context.rampDirection,
|
|
1187
|
+
clamped_by_min: context.clampedByMin,
|
|
1188
|
+
clamped_by_max: context.clampedByMax,
|
|
1189
|
+
measurement_age_ms: context.measurementAgeMs
|
|
1190
|
+
});
|
|
1191
|
+
normalPayload.feed_forward_delta_w = Number.isFinite(context.feedForwardDeltaW)
|
|
1192
|
+
? Math.round(context.feedForwardDeltaW)
|
|
1193
|
+
: null;
|
|
1194
|
+
normalPayload.pv_cap_hold_active = !!context.pvCapHoldActive;
|
|
1195
|
+
|
|
1196
|
+
if (Number.isFinite(context.previousLimit)) {
|
|
1197
|
+
normalPayload.previous_limit_w = Math.round(context.previousLimit);
|
|
1198
|
+
normalPayload.new_limit_w = Math.round(context.siteLimit);
|
|
1199
|
+
normalPayload.delta_limit_w = Math.round(context.siteLimit - context.previousLimit);
|
|
1200
|
+
}
|
|
1201
|
+
normalPayload.control_due = !!context.controlDue;
|
|
1202
|
+
normalPayload.immediate_reduction = !!context.immediateReductionDue;
|
|
1203
|
+
normalPayload.immediate_reduction_blocked = !!context.immediateReductionBlocked;
|
|
1204
|
+
normalPayload.immediate_reduction_cooldown_remaining_ms = Number.isFinite(context.immediateReductionCooldownRemainingMs)
|
|
1205
|
+
? Math.round(context.immediateReductionCooldownRemainingMs)
|
|
1206
|
+
: null;
|
|
1207
|
+
|
|
1208
|
+
if (node.debugLevel === "normal") {
|
|
1209
|
+
return normalPayload;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return Object.assign({}, normalPayload, {
|
|
1213
|
+
normalized_grid_reference_w: context.gridReference,
|
|
1214
|
+
normalized_filtered_grid_reference_w: context.filteredReference,
|
|
1215
|
+
normalized_grid_target_w: context.gridTarget,
|
|
1216
|
+
p_term_w: context.pTerm,
|
|
1217
|
+
i_term_w: context.iTerm,
|
|
1218
|
+
effective_kp: context.effectiveKp,
|
|
1219
|
+
effective_ki: context.effectiveKi,
|
|
1220
|
+
adaptive_gain_scale: context.gainsScale,
|
|
1221
|
+
sign_convention: node.signConvention,
|
|
1222
|
+
critical_phase: lastCriticalPhase,
|
|
1223
|
+
export_control_band_w: controlBands().exportW,
|
|
1224
|
+
import_control_band_w: controlBands().importW,
|
|
1225
|
+
kp: node.kp,
|
|
1226
|
+
ki: node.ki,
|
|
1227
|
+
max_step_up_w: node.maxStepUpW,
|
|
1228
|
+
max_step_down_w: node.maxStepDownW,
|
|
1229
|
+
adaptive_gains: node.adaptiveGains,
|
|
1230
|
+
adaptive_error_band_w: node.adaptiveErrorBandW,
|
|
1231
|
+
adaptive_min_scale: node.adaptiveMinScale,
|
|
1232
|
+
control_period_ms: node.controlPeriodMs,
|
|
1233
|
+
output_heartbeat_ms: node.outputHeartbeatMs,
|
|
1234
|
+
filter_tau_ms: node.filterTauMs,
|
|
1235
|
+
feed_forward_enabled: node.feedForwardEnabled,
|
|
1236
|
+
feed_forward_gain: node.feedForwardGain,
|
|
1237
|
+
pv_cap_only_blocks_increase: node.pvCapOnlyBlocksIncrease,
|
|
1238
|
+
pv_cap_hold_ms: node.pvCapHoldMs,
|
|
1239
|
+
pv_cap_release_w_per_s: node.pvCapReleaseWPerSec,
|
|
1240
|
+
debug_level: node.debugLevel
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function failSecureThresholdW() {
|
|
1246
|
+
return Math.max(20, controlBands().exportW);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function shouldEmitMainOutput(siteLimit, stale, nowMs, force) {
|
|
1250
|
+
if (force) return true;
|
|
1251
|
+
if (!Number.isFinite(lastOutputAtMs) || lastOutputAtMs <= 0) return true;
|
|
1252
|
+
if (!Number.isFinite(lastOutputLimit) || Math.abs(Number(siteLimit) - lastOutputLimit) >= 1) return true;
|
|
1253
|
+
if (lastOutputStale !== !!stale) return true;
|
|
1254
|
+
return (nowMs - lastOutputAtMs) >= node.outputHeartbeatMs;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function rememberMainOutput(siteLimit, stale, nowMs) {
|
|
1258
|
+
lastOutputAtMs = nowMs;
|
|
1259
|
+
lastOutputLimit = Number(siteLimit);
|
|
1260
|
+
lastOutputStale = !!stale;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function isPvTelemetryForConfiguredSource(msg) {
|
|
1264
|
+
if (!node.useGlobalPvCap && !node.feedForwardEnabled) return false;
|
|
1265
|
+
|
|
1266
|
+
const payload = msg && msg.payload;
|
|
1267
|
+
if (!payload || typeof payload !== "object") return false;
|
|
1268
|
+
if (!Number.isFinite(Number(payload.power_w))) return false;
|
|
1269
|
+
|
|
1270
|
+
if (payload.site_limit_w !== undefined) return false;
|
|
1271
|
+
if (payload.grid_reference_w !== undefined) return false;
|
|
1272
|
+
if (payload.grid_target_w !== undefined) return false;
|
|
1273
|
+
if (payload.pcc_power !== undefined || payload.pcc_power_w !== undefined) return false;
|
|
1274
|
+
if (payload.grid_power !== undefined) return false;
|
|
1275
|
+
if (payload.summary && payload.summary.power && Number.isFinite(Number(payload.summary.power.total_w))) return false;
|
|
1276
|
+
|
|
1277
|
+
const sourceId = String(
|
|
1278
|
+
payload.inverter_id ||
|
|
1279
|
+
msg.inverter_id ||
|
|
1280
|
+
msg.topic ||
|
|
1281
|
+
""
|
|
1282
|
+
).trim();
|
|
1283
|
+
|
|
1284
|
+
if (!sourceId) return false;
|
|
1285
|
+
if (sourceId === String(node.outputTopic || "").trim()) return false;
|
|
1286
|
+
|
|
1287
|
+
const configuredSource = String(node.globalPvTotalVar || "").trim();
|
|
1288
|
+
if (!configuredSource) return false;
|
|
1289
|
+
|
|
1290
|
+
// Single-inverter mode without global context:
|
|
1291
|
+
// allow using PV Global Variable as direct telemetry topic/inverter_id (e.g. "inv1").
|
|
1292
|
+
return sourceId === configuredSource;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function updatePvTelemetryFromMessage(msg, nowMs) {
|
|
1296
|
+
const payload = msg && msg.payload;
|
|
1297
|
+
const sourceId = String(
|
|
1298
|
+
payload.inverter_id ||
|
|
1299
|
+
msg.inverter_id ||
|
|
1300
|
+
msg.topic ||
|
|
1301
|
+
"pv"
|
|
1302
|
+
).trim() || "pv";
|
|
1303
|
+
|
|
1304
|
+
pvTelemetryBySource.set(sourceId, {
|
|
1305
|
+
powerW: Math.max(0, Number(payload.power_w)),
|
|
1306
|
+
ts: Number.isFinite(nowMs) ? nowMs : Date.now()
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function readLocalPvTotalW(nowMs) {
|
|
1311
|
+
if (!pvTelemetryBySource.size) return null;
|
|
1312
|
+
|
|
1313
|
+
const timeoutMs = node.staleTimeoutMs > 0
|
|
1314
|
+
? Math.max(1000, node.staleTimeoutMs)
|
|
1315
|
+
: 5000;
|
|
1316
|
+
|
|
1317
|
+
let total = 0;
|
|
1318
|
+
let count = 0;
|
|
1319
|
+
|
|
1320
|
+
pvTelemetryBySource.forEach(function (entry, key) {
|
|
1321
|
+
if (!entry || !Number.isFinite(entry.powerW) || !Number.isFinite(entry.ts)) {
|
|
1322
|
+
pvTelemetryBySource.delete(key);
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if ((nowMs - entry.ts) > timeoutMs) {
|
|
1327
|
+
pvTelemetryBySource.delete(key);
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
total += Math.max(0, Number(entry.powerW));
|
|
1332
|
+
count += 1;
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
if (!count) return null;
|
|
1336
|
+
return total;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function extractPvTotalFromGlobalValue(raw, nowMs) {
|
|
1340
|
+
const direct = Number(raw);
|
|
1341
|
+
if (Number.isFinite(direct)) {
|
|
1342
|
+
return Math.max(0, direct);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1346
|
+
|
|
1347
|
+
const value = firstFinite(
|
|
1348
|
+
raw.power_w,
|
|
1349
|
+
raw.total_pv_w,
|
|
1350
|
+
raw.total_w,
|
|
1351
|
+
raw.value,
|
|
1352
|
+
raw.payload && raw.payload.power_w,
|
|
1353
|
+
raw.payload && raw.payload.total_pv_w,
|
|
1354
|
+
raw.payload && raw.payload.value
|
|
1355
|
+
);
|
|
1356
|
+
if (!Number.isFinite(value)) return null;
|
|
1357
|
+
|
|
1358
|
+
const timestampMs = firstFinite(
|
|
1359
|
+
raw.timestamp_ms,
|
|
1360
|
+
raw.time_ms,
|
|
1361
|
+
raw.timestamp,
|
|
1362
|
+
raw.ts,
|
|
1363
|
+
raw.time,
|
|
1364
|
+
raw.payload && raw.payload.timestamp_ms,
|
|
1365
|
+
raw.payload && raw.payload.time_ms,
|
|
1366
|
+
raw.payload && raw.payload.timestamp,
|
|
1367
|
+
raw.payload && raw.payload.ts,
|
|
1368
|
+
raw.payload && raw.payload.time
|
|
1369
|
+
);
|
|
1370
|
+
if (Number.isFinite(timestampMs)) {
|
|
1371
|
+
const tsMs = timestampMs < 1e11 ? Math.round(timestampMs * 1000) : Math.round(timestampMs);
|
|
1372
|
+
const timeoutMs = node.staleTimeoutMs > 0 ? Math.max(1000, node.staleTimeoutMs) : 5000;
|
|
1373
|
+
if ((nowMs - tsMs) > timeoutMs || (tsMs - nowMs) > node.maxFutureTimestampMs) return null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return Math.max(0, Number(value));
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function readGlobalPvTotalW(nowMs) {
|
|
1380
|
+
if (!node.useGlobalPvCap && !node.feedForwardEnabled) return null;
|
|
1381
|
+
if (node.globalPvTotalVar) {
|
|
1382
|
+
const raw = globalCtx.get(node.globalPvTotalVar);
|
|
1383
|
+
const n = extractPvTotalFromGlobalValue(raw, nowMs);
|
|
1384
|
+
if (Number.isFinite(n)) {
|
|
1385
|
+
return n;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
return readLocalPvTotalW(nowMs);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function updatePvAvailabilityEstimate(rawPvTotalW, nowMs) {
|
|
1393
|
+
if (!Number.isFinite(rawPvTotalW)) return null;
|
|
1394
|
+
|
|
1395
|
+
const raw = Math.max(0, Number(rawPvTotalW));
|
|
1396
|
+
if (!Number.isFinite(pvAvailableEstimateW) || raw >= pvAvailableEstimateW) {
|
|
1397
|
+
pvAvailableEstimateW = raw;
|
|
1398
|
+
pvEstimateHoldUntilMs = nowMs + node.pvCapHoldMs;
|
|
1399
|
+
lastPvEstimateUpdateMs = nowMs;
|
|
1400
|
+
return pvAvailableEstimateW;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const dtSeconds = lastPvEstimateUpdateMs > 0
|
|
1404
|
+
? Math.max(0, nowMs - lastPvEstimateUpdateMs) / 1000
|
|
1405
|
+
: 0;
|
|
1406
|
+
if (nowMs >= pvEstimateHoldUntilMs && node.pvCapReleaseWPerSec > 0) {
|
|
1407
|
+
const maximumReleaseW = node.pvCapReleaseWPerSec * dtSeconds;
|
|
1408
|
+
pvAvailableEstimateW = Math.max(raw, pvAvailableEstimateW - maximumReleaseW);
|
|
1409
|
+
}
|
|
1410
|
+
lastPvEstimateUpdateMs = nowMs;
|
|
1411
|
+
return pvAvailableEstimateW;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function computeDynamicPvCapOffsetW(errorW) {
|
|
1415
|
+
const baseOffset = Math.max(0, Number(node.globalPvCapOffsetW) || 0);
|
|
1416
|
+
if (baseOffset <= 0) return 0;
|
|
1417
|
+
if (!Number.isFinite(errorW)) return baseOffset;
|
|
1418
|
+
|
|
1419
|
+
// Near setpoint keep only a small margin, farther away allow full offset.
|
|
1420
|
+
const minOffset = Math.min(baseOffset, Math.max(0, Number(node.recoverThresholdW) || 0));
|
|
1421
|
+
const nearBand = Math.max(
|
|
1422
|
+
1,
|
|
1423
|
+
Number(node.deadband) || 0,
|
|
1424
|
+
Number(node.exportThresholdW) || 0,
|
|
1425
|
+
Number(node.recoverThresholdW) || 0
|
|
1426
|
+
);
|
|
1427
|
+
const farBand = Math.max(nearBand + 1, Number(node.adaptiveErrorBandW) || (nearBand + 1));
|
|
1428
|
+
const progress = clamp((Math.abs(errorW) - nearBand) / (farBand - nearBand), 0, 1);
|
|
1429
|
+
|
|
1430
|
+
return minOffset + (baseOffset - minOffset) * progress;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function readNominalPowerW() {
|
|
1434
|
+
if (node.a3TestsEnabled && a3Server) {
|
|
1435
|
+
const command = a3Server.getCommand();
|
|
1436
|
+
if (command && Number.isFinite(command.nominalPower)) {
|
|
1437
|
+
return command.nominalPower;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (!node.nominalPowerFromGlobal) {
|
|
1442
|
+
return Number(node.nominalPower);
|
|
1443
|
+
}
|
|
1444
|
+
const key = String(node.nominalPowerGlobalVar || "").trim();
|
|
1445
|
+
if (!key) {
|
|
1446
|
+
return Number(node.nominalPower);
|
|
1447
|
+
}
|
|
1448
|
+
const raw = globalCtx.get(key);
|
|
1449
|
+
const n = Number(raw);
|
|
1450
|
+
if (Number.isFinite(n)) {
|
|
1451
|
+
return n;
|
|
1452
|
+
}
|
|
1453
|
+
return Number(node.nominalPower);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function evaluateFailSecure(context, nowMs) {
|
|
1457
|
+
if (!node.failSecureEnabled) {
|
|
1458
|
+
failSecureBreachStartMs = null;
|
|
1459
|
+
failSecureActive = false;
|
|
1460
|
+
return {
|
|
1461
|
+
active: false,
|
|
1462
|
+
durationMs: 0,
|
|
1463
|
+
minLimitW: node.minLimit,
|
|
1464
|
+
siteLimitW: Number(context && context.siteLimit)
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const minLimitW = Number(node.minLimit);
|
|
1469
|
+
const siteLimitW = Number(context && context.siteLimit);
|
|
1470
|
+
const epsilon = 1e-6;
|
|
1471
|
+
const gridReferenceW = Number(context && context.gridReference);
|
|
1472
|
+
const thresholdW = failSecureThresholdW();
|
|
1473
|
+
const eligible = node.mode !== "nominal_power" && Number.isFinite(siteLimitW);
|
|
1474
|
+
const atMinimum = siteLimitW <= (minLimitW + epsilon);
|
|
1475
|
+
const residualExport = Number.isFinite(gridReferenceW) && gridReferenceW >= thresholdW;
|
|
1476
|
+
const violation = eligible && atMinimum && residualExport;
|
|
1477
|
+
|
|
1478
|
+
if (violation) {
|
|
1479
|
+
if (!Number.isFinite(failSecureBreachStartMs)) {
|
|
1480
|
+
failSecureBreachStartMs = nowMs;
|
|
1481
|
+
}
|
|
1482
|
+
const durationMs = Math.max(0, nowMs - failSecureBreachStartMs);
|
|
1483
|
+
const triggerMs = Math.max(1000, Math.round(node.failSecureTimeSec * 1000));
|
|
1484
|
+
failSecureActive = durationMs >= triggerMs;
|
|
1485
|
+
return {
|
|
1486
|
+
active: failSecureActive,
|
|
1487
|
+
durationMs: durationMs,
|
|
1488
|
+
minLimitW: minLimitW,
|
|
1489
|
+
siteLimitW: siteLimitW,
|
|
1490
|
+
residualExportW: gridReferenceW,
|
|
1491
|
+
thresholdW: thresholdW
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
failSecureBreachStartMs = null;
|
|
1496
|
+
failSecureActive = false;
|
|
1497
|
+
return {
|
|
1498
|
+
active: false,
|
|
1499
|
+
durationMs: 0,
|
|
1500
|
+
minLimitW: minLimitW,
|
|
1501
|
+
siteLimitW: siteLimitW,
|
|
1502
|
+
residualExportW: gridReferenceW,
|
|
1503
|
+
thresholdW: thresholdW
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function buildFailSecureMessage(failSecureState, nowMs) {
|
|
1508
|
+
if (!node.failSecureEnabled) {
|
|
1509
|
+
lastFailSecureOutputActive = false;
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (failSecureState && failSecureState.active) {
|
|
1514
|
+
lastFailSecureOutputActive = true;
|
|
1515
|
+
return {
|
|
1516
|
+
topic: "control-export/fail-secure",
|
|
1517
|
+
payload: 1,
|
|
1518
|
+
fail_secure: {
|
|
1519
|
+
active: true,
|
|
1520
|
+
duration_ms: failSecureState.durationMs,
|
|
1521
|
+
min_limit_w: failSecureState.minLimitW,
|
|
1522
|
+
site_limit_w: failSecureState.siteLimitW,
|
|
1523
|
+
residual_export_w: failSecureState.residualExportW,
|
|
1524
|
+
threshold_w: failSecureState.thresholdW,
|
|
1525
|
+
timeout_s: node.failSecureTimeSec,
|
|
1526
|
+
timestamp_ms: nowMs
|
|
1527
|
+
}
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (lastFailSecureOutputActive) {
|
|
1532
|
+
lastFailSecureOutputActive = false;
|
|
1533
|
+
return {
|
|
1534
|
+
topic: "control-export/fail-secure",
|
|
1535
|
+
payload: 0,
|
|
1536
|
+
fail_secure: {
|
|
1537
|
+
active: false,
|
|
1538
|
+
timestamp_ms: nowMs
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function emitSafetyOutput(reason, nowMs, sendFn, force) {
|
|
1547
|
+
const safeLimit = clamp(node.safeLimitOnStale, node.minLimit, node.maxLimit);
|
|
1548
|
+
lastLimit = safeLimit;
|
|
1549
|
+
integratorState = 0;
|
|
1550
|
+
outsideDeadbandSinceMs = null;
|
|
1551
|
+
const gridTarget = node.mode === "nominal_power" ? null : getGridTarget();
|
|
1552
|
+
const mainMsg = {
|
|
1553
|
+
topic: node.outputTopic,
|
|
1554
|
+
payload: buildOutputPayload({
|
|
1555
|
+
siteLimit: safeLimit,
|
|
1556
|
+
rawGridReference: null,
|
|
1557
|
+
gridReference: lastRawGridReference,
|
|
1558
|
+
filteredReference: filteredGridReference,
|
|
1559
|
+
gridTarget: gridTarget,
|
|
1560
|
+
correction: 0,
|
|
1561
|
+
error: null,
|
|
1562
|
+
pTerm: 0,
|
|
1563
|
+
effectiveKp: node.kp,
|
|
1564
|
+
effectiveKi: node.ki,
|
|
1565
|
+
gainsScale: 1,
|
|
1566
|
+
iTerm: 0,
|
|
1567
|
+
active: false,
|
|
1568
|
+
reason: reason,
|
|
1569
|
+
controllerState: "stale_fallback",
|
|
1570
|
+
stale: true,
|
|
1571
|
+
pvTotalW: null,
|
|
1572
|
+
pvCapLimitW: null,
|
|
1573
|
+
pvCapOffsetW: null,
|
|
1574
|
+
pvCapApplied: false,
|
|
1575
|
+
exportDetected: false,
|
|
1576
|
+
recoveryBlocked: false,
|
|
1577
|
+
rampDirection: "hold",
|
|
1578
|
+
clampedByMin: safeLimit <= node.minLimit,
|
|
1579
|
+
clampedByMax: safeLimit >= node.maxLimit,
|
|
1580
|
+
measurementAgeMs: Number.isFinite(lastInputAtMs) ? Math.max(0, nowMs - lastInputAtMs) : null,
|
|
1581
|
+
msgTimestampMs: nowMs,
|
|
1582
|
+
previousLimit: lastOutputLimit,
|
|
1583
|
+
controlDue: true,
|
|
1584
|
+
immediateReductionDue: false,
|
|
1585
|
+
immediateReductionBlocked: false,
|
|
1586
|
+
immediateReductionCooldownRemainingMs: null
|
|
1587
|
+
})
|
|
1588
|
+
};
|
|
1589
|
+
if (!shouldEmitMainOutput(safeLimit, true, nowMs, force)) return false;
|
|
1590
|
+
if (node.failSecureEnabled) {
|
|
1591
|
+
sendFn([mainMsg, null]);
|
|
1592
|
+
} else {
|
|
1593
|
+
sendFn(mainMsg);
|
|
1594
|
+
}
|
|
1595
|
+
rememberMainOutput(safeLimit, true, nowMs);
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
function emitA3NominalCommand(nowMs, reason) {
|
|
1601
|
+
const command = a3Server ? a3Server.getCommand() : {
|
|
1602
|
+
nominalPower: node.nominalPower,
|
|
1603
|
+
rampType: node.a3RampType,
|
|
1604
|
+
rampValue: node.a3RampValue,
|
|
1605
|
+
sequence: 0
|
|
1606
|
+
};
|
|
1607
|
+
const siteLimit = clamp(command.nominalPower, node.minLimit, node.maxLimit);
|
|
1608
|
+
|
|
1609
|
+
lastLimit = siteLimit;
|
|
1610
|
+
persistLastLimit(lastLimit, { nowMs: nowMs, force: true });
|
|
1611
|
+
|
|
1612
|
+
buildStatus({
|
|
1613
|
+
mode: "nominal_power",
|
|
1614
|
+
siteLimit: siteLimit,
|
|
1615
|
+
gridReference: lastRawGridReference,
|
|
1616
|
+
gridTarget: null,
|
|
1617
|
+
active: false,
|
|
1618
|
+
reason: reason || "a3_modbus_command",
|
|
1619
|
+
rampDirection: "hold",
|
|
1620
|
+
controllerState: "a3_nominal_mode"
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
node.send({
|
|
1624
|
+
topic: node.outputTopic,
|
|
1625
|
+
payload: buildOutputPayload({
|
|
1626
|
+
siteLimit: siteLimit,
|
|
1627
|
+
rawGridReference: null,
|
|
1628
|
+
gridReference: lastRawGridReference,
|
|
1629
|
+
filteredReference: filteredGridReference,
|
|
1630
|
+
gridTarget: null,
|
|
1631
|
+
correction: 0,
|
|
1632
|
+
error: null,
|
|
1633
|
+
pTerm: 0,
|
|
1634
|
+
effectiveKp: node.kp,
|
|
1635
|
+
effectiveKi: node.ki,
|
|
1636
|
+
gainsScale: 1,
|
|
1637
|
+
iTerm: 0,
|
|
1638
|
+
active: false,
|
|
1639
|
+
reason: reason || "a3_modbus_command",
|
|
1640
|
+
controllerState: "a3_nominal_mode",
|
|
1641
|
+
stale: false,
|
|
1642
|
+
pvTotalW: null,
|
|
1643
|
+
pvCapLimitW: null,
|
|
1644
|
+
pvCapOffsetW: null,
|
|
1645
|
+
pvCapApplied: false,
|
|
1646
|
+
exportDetected: false,
|
|
1647
|
+
recoveryBlocked: false,
|
|
1648
|
+
rampDirection: "hold",
|
|
1649
|
+
clampedByMin: siteLimit <= node.minLimit,
|
|
1650
|
+
clampedByMax: siteLimit >= node.maxLimit,
|
|
1651
|
+
measurementAgeMs: null,
|
|
1652
|
+
msgTimestampMs: nowMs,
|
|
1653
|
+
previousLimit: null,
|
|
1654
|
+
controlDue: true,
|
|
1655
|
+
immediateReductionDue: false,
|
|
1656
|
+
immediateReductionBlocked: false,
|
|
1657
|
+
immediateReductionCooldownRemainingMs: null
|
|
1658
|
+
})
|
|
1659
|
+
});
|
|
1660
|
+
rememberMainOutput(siteLimit, false, nowMs);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
node.on("input", function (msg, send, done) {
|
|
1665
|
+
|
|
1666
|
+
try {
|
|
1667
|
+
const nowMs = Date.now();
|
|
1668
|
+
if (!validateRequiredGlobalValues()) {
|
|
1669
|
+
emitSafetyOutput("invalid_global", nowMs, send, false);
|
|
1670
|
+
done();
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
refreshMaxLimitFromGlobal();
|
|
1674
|
+
const fastCommand = extractFastSetpointCommand(msg, nowMs);
|
|
1675
|
+
|
|
1676
|
+
if (fastCommand) {
|
|
1677
|
+
if (node.mode !== "grid_set_point") {
|
|
1678
|
+
throw new Error("Fast grid setpoint necesita mode=grid_set_point");
|
|
1679
|
+
}
|
|
1680
|
+
const measurementAgeMs = Number.isFinite(lastMsgTimestampMs)
|
|
1681
|
+
? Math.max(0, nowMs - lastMsgTimestampMs)
|
|
1682
|
+
: Infinity;
|
|
1683
|
+
if (!Number.isFinite(lastRawGridReference) || measurementAgeMs > node.fastSetpointMaxTelemetryAgeMs) {
|
|
1684
|
+
throw new Error("Nu exista telemetrie grid proaspata pentru fast setpoint");
|
|
1685
|
+
}
|
|
1686
|
+
node.gridSetPoint = fastCommand.value;
|
|
1687
|
+
nodeCtx.set(persistedGridSetpointKey, node.gridSetPoint);
|
|
1688
|
+
nodeCtx.set(persistedGridSetpointBaseKey, configuredGridSetPoint);
|
|
1689
|
+
integratorState = 0;
|
|
1690
|
+
outsideDeadbandSinceMs = null;
|
|
1691
|
+
msg = {
|
|
1692
|
+
topic: "control-export/fast-setpoint-eval",
|
|
1693
|
+
timestamp_ms: lastMsgTimestampMs,
|
|
1694
|
+
payload: { power_w: denormalizeByConvention(lastRawGridReference) }
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
const msgTimestampMs = extractTimestampMs(msg);
|
|
1699
|
+
|
|
1700
|
+
if ((msgTimestampMs - nowMs) > node.maxFutureTimestampMs) {
|
|
1701
|
+
throw new Error("Timestamp din viitor peste limita admisa");
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
if (isPvTelemetryForConfiguredSource(msg)) {
|
|
1705
|
+
updatePvTelemetryFromMessage(msg, nowMs);
|
|
1706
|
+
done();
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (!fastCommand && isControlCommandMessage(msg)) {
|
|
1711
|
+
done();
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const stale = isStale(msgTimestampMs, nowMs);
|
|
1716
|
+
|
|
1717
|
+
if (!stale && Number.isFinite(lastAcceptedMeasurementTs) && msgTimestampMs < lastAcceptedMeasurementTs) {
|
|
1718
|
+
node.status({ fill: "yellow", shape: "ring", text: "ignored older telemetry" });
|
|
1719
|
+
done();
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const controlDtMs = lastControlTs > 0 ? Math.max(1, nowMs - lastControlTs) : node.controlPeriodMs;
|
|
1724
|
+
|
|
1725
|
+
const controlDue = !!fastCommand || !Number.isFinite(lastLimit) || (nowMs - lastControlTs) >= node.controlPeriodMs;
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
let siteLimit;
|
|
1729
|
+
|
|
1730
|
+
let gridReference = lastRawGridReference;
|
|
1731
|
+
|
|
1732
|
+
let filteredReference = filteredGridReference;
|
|
1733
|
+
|
|
1734
|
+
let gridTarget = null;
|
|
1735
|
+
|
|
1736
|
+
let correction = null;
|
|
1737
|
+
|
|
1738
|
+
let error = null;
|
|
1739
|
+
|
|
1740
|
+
let pTerm = null;
|
|
1741
|
+
let effectiveKp = null;
|
|
1742
|
+
let effectiveKi = null;
|
|
1743
|
+
let gainsScale = null;
|
|
1744
|
+
|
|
1745
|
+
let iTerm = integratorState;
|
|
1746
|
+
|
|
1747
|
+
let active = false;
|
|
1748
|
+
|
|
1749
|
+
let reason = "nominal";
|
|
1750
|
+
|
|
1751
|
+
let exportDetected = false;
|
|
1752
|
+
|
|
1753
|
+
let rampDirection = "hold";
|
|
1754
|
+
|
|
1755
|
+
let controllerState = node.mode === "nominal_power" ? "nominal_mode" : "waiting_measurement";
|
|
1756
|
+
|
|
1757
|
+
let recoveryBlocked = false;
|
|
1758
|
+
|
|
1759
|
+
let clampedByMin = false;
|
|
1760
|
+
|
|
1761
|
+
let clampedByMax = false;
|
|
1762
|
+
|
|
1763
|
+
let rawGridReference = null;
|
|
1764
|
+
let pvTotalW = null;
|
|
1765
|
+
let pvCapLimitW = null;
|
|
1766
|
+
let pvCapOffsetW = null;
|
|
1767
|
+
let pvCapApplied = false;
|
|
1768
|
+
let pvCapBlockedIncrease = false;
|
|
1769
|
+
let pvCapHoldActive = false;
|
|
1770
|
+
let pvAvailableEstimateForOutputW = null;
|
|
1771
|
+
let controllerRequestedLimitW = null;
|
|
1772
|
+
let feedForwardTargetW = null;
|
|
1773
|
+
let feedForwardDeltaW = null;
|
|
1774
|
+
let previousLimitForDebug = Number.isFinite(lastLimit) ? Number(lastLimit) : null;
|
|
1775
|
+
let immediateReductionDue = false;
|
|
1776
|
+
let immediateReductionBlocked = false;
|
|
1777
|
+
let immediateReductionCooldownRemainingMs = null;
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
pvTotalW = readGlobalPvTotalW(nowMs);
|
|
1781
|
+
pvAvailableEstimateForOutputW = updatePvAvailabilityEstimate(pvTotalW, nowMs);
|
|
1782
|
+
pvCapHoldActive = Number.isFinite(pvAvailableEstimateForOutputW) && nowMs < pvEstimateHoldUntilMs;
|
|
1783
|
+
|
|
1784
|
+
if (node.mode === "nominal_power") {
|
|
1785
|
+
outsideDeadbandSinceMs = null;
|
|
1786
|
+
|
|
1787
|
+
const nominalPowerW = readNominalPowerW();
|
|
1788
|
+
siteLimit = clamp(nominalPowerW, node.minLimit, node.maxLimit);
|
|
1789
|
+
|
|
1790
|
+
reason = "nominal";
|
|
1791
|
+
|
|
1792
|
+
integratorState = 0;
|
|
1793
|
+
|
|
1794
|
+
} else if (stale) {
|
|
1795
|
+
outsideDeadbandSinceMs = null;
|
|
1796
|
+
|
|
1797
|
+
siteLimit = node.safeLimitOnStale;
|
|
1798
|
+
|
|
1799
|
+
reason = "stale";
|
|
1800
|
+
|
|
1801
|
+
controllerState = "stale_fallback";
|
|
1802
|
+
|
|
1803
|
+
integratorState = 0;
|
|
1804
|
+
|
|
1805
|
+
} else {
|
|
1806
|
+
|
|
1807
|
+
rawGridReference = extractControlReference(msg);
|
|
1808
|
+
|
|
1809
|
+
if (rawGridReference === null) {
|
|
1810
|
+
|
|
1811
|
+
if (isInverterTelemetryWithoutGridReference(msg)) {
|
|
1812
|
+
done();
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
if (node.mode === "zero_feed_in" && node.zeroFeedInMode === "absolute") {
|
|
1817
|
+
throw new Error("Pentru Zero Feed-In absolut sunt necesare puterile pe faze (L1/L2/L3)");
|
|
1818
|
+
}
|
|
1819
|
+
throw new Error("Mesajul nu conține referința din rețea");
|
|
1820
|
+
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
lastInputAtMs = nowMs;
|
|
1824
|
+
|
|
1825
|
+
gridReference = normalizeGridReference(rawGridReference);
|
|
1826
|
+
|
|
1827
|
+
filteredReference = updateFilter(
|
|
1828
|
+
gridReference,
|
|
1829
|
+
msgTimestampMs,
|
|
1830
|
+
nowMs,
|
|
1831
|
+
node.mode === "zero_feed_in" && node.zeroFeedInMode === "absolute"
|
|
1832
|
+
? (lastCriticalPhase || "phase")
|
|
1833
|
+
: "total"
|
|
1834
|
+
);
|
|
1835
|
+
|
|
1836
|
+
lastRawGridReference = gridReference;
|
|
1837
|
+
|
|
1838
|
+
lastMsgTimestampMs = msgTimestampMs;
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
const gridTargetNow = getGridTarget();
|
|
1842
|
+
const protectiveReference = (function () {
|
|
1843
|
+
if (Number.isFinite(gridReference) && Number.isFinite(filteredReference)) {
|
|
1844
|
+
return Math.max(gridReference, filteredReference);
|
|
1845
|
+
}
|
|
1846
|
+
if (Number.isFinite(filteredReference)) return filteredReference;
|
|
1847
|
+
return gridReference;
|
|
1848
|
+
})();
|
|
1849
|
+
immediateReductionDue = canReduceImmediately(
|
|
1850
|
+
nowMs,
|
|
1851
|
+
protectiveReference,
|
|
1852
|
+
gridTargetNow,
|
|
1853
|
+
controlDue
|
|
1854
|
+
);
|
|
1855
|
+
if (!controlDue && Number.isFinite(protectiveReference) && Number.isFinite(gridTargetNow)) {
|
|
1856
|
+
const overshootW = protectiveReference - gridTargetNow;
|
|
1857
|
+
const requiredOvershootW = controlBands().exportW;
|
|
1858
|
+
const cooldownMs = Math.max(Number(node.transientIgnoreMs) || 0, Number(node.controlPeriodMs) || 0, 800);
|
|
1859
|
+
immediateReductionBlocked = overshootW > requiredOvershootW && !immediateReductionDue;
|
|
1860
|
+
immediateReductionCooldownRemainingMs = lastImmediateReductionTs > 0
|
|
1861
|
+
? Math.max(0, cooldownMs - (nowMs - lastImmediateReductionTs))
|
|
1862
|
+
: 0;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
if (controlDue || immediateReductionDue) {
|
|
1866
|
+
|
|
1867
|
+
const result = computeClosedLoopLimit(
|
|
1868
|
+
immediateReductionDue ? protectiveReference : filteredReference,
|
|
1869
|
+
nowMs,
|
|
1870
|
+
controlDtMs,
|
|
1871
|
+
immediateReductionDue,
|
|
1872
|
+
pvTotalW
|
|
1873
|
+
);
|
|
1874
|
+
|
|
1875
|
+
siteLimit = result.siteLimit;
|
|
1876
|
+
previousLimitForDebug = Number.isFinite(result.previousLimit) ? result.previousLimit : previousLimitForDebug;
|
|
1877
|
+
|
|
1878
|
+
gridTarget = result.gridTarget;
|
|
1879
|
+
|
|
1880
|
+
correction = result.correction;
|
|
1881
|
+
|
|
1882
|
+
error = result.error;
|
|
1883
|
+
|
|
1884
|
+
pTerm = result.pTerm;
|
|
1885
|
+
effectiveKp = result.effectiveKp;
|
|
1886
|
+
effectiveKi = result.effectiveKi;
|
|
1887
|
+
gainsScale = result.gainsScale;
|
|
1888
|
+
feedForwardTargetW = result.feedForwardTargetW;
|
|
1889
|
+
feedForwardDeltaW = result.feedForwardDeltaW;
|
|
1890
|
+
|
|
1891
|
+
iTerm = result.iTerm;
|
|
1892
|
+
|
|
1893
|
+
active = result.active;
|
|
1894
|
+
|
|
1895
|
+
reason = result.reason;
|
|
1896
|
+
|
|
1897
|
+
exportDetected = result.exportDetected;
|
|
1898
|
+
|
|
1899
|
+
rampDirection = result.rampDirection;
|
|
1900
|
+
|
|
1901
|
+
controllerState = result.controllerState;
|
|
1902
|
+
|
|
1903
|
+
recoveryBlocked = result.recoveryBlocked;
|
|
1904
|
+
|
|
1905
|
+
clampedByMin = result.clampedByMin;
|
|
1906
|
+
|
|
1907
|
+
clampedByMax = result.clampedByMax;
|
|
1908
|
+
|
|
1909
|
+
lastControlTs = nowMs;
|
|
1910
|
+
|
|
1911
|
+
if (immediateReductionDue) {
|
|
1912
|
+
lastImmediateReductionTs = nowMs;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
} else {
|
|
1916
|
+
|
|
1917
|
+
siteLimit = Number.isFinite(lastLimit)
|
|
1918
|
+
|
|
1919
|
+
? lastLimit
|
|
1920
|
+
|
|
1921
|
+
: clamp(node.maxLimit, node.minLimit, node.maxLimit);
|
|
1922
|
+
|
|
1923
|
+
gridTarget = getGridTarget();
|
|
1924
|
+
|
|
1925
|
+
error = Number.isFinite(filteredReference) ? (filteredReference - gridTarget) : null;
|
|
1926
|
+
|
|
1927
|
+
pTerm = Number.isFinite(error) ? node.kp * error : null;
|
|
1928
|
+
effectiveKp = node.kp;
|
|
1929
|
+
effectiveKi = node.ki;
|
|
1930
|
+
gainsScale = 1;
|
|
1931
|
+
|
|
1932
|
+
iTerm = integratorState;
|
|
1933
|
+
|
|
1934
|
+
correction = 0;
|
|
1935
|
+
|
|
1936
|
+
active = false;
|
|
1937
|
+
|
|
1938
|
+
exportDetected = Number.isFinite(filteredReference) && filteredReference > (gridTarget + controlBands().exportW);
|
|
1939
|
+
|
|
1940
|
+
rampDirection = "hold";
|
|
1941
|
+
|
|
1942
|
+
reason = "hold_period";
|
|
1943
|
+
|
|
1944
|
+
controllerState = "hold_period";
|
|
1945
|
+
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
|
|
1951
|
+
siteLimit = clamp(siteLimit, node.minLimit, node.maxLimit);
|
|
1952
|
+
controllerRequestedLimitW = siteLimit;
|
|
1953
|
+
if (node.useGlobalPvCap && node.mode !== "nominal_power" && Number.isFinite(pvAvailableEstimateForOutputW)) {
|
|
1954
|
+
pvCapOffsetW = computeDynamicPvCapOffsetW(error);
|
|
1955
|
+
pvCapLimitW = Math.max(0, pvAvailableEstimateForOutputW + pvCapOffsetW);
|
|
1956
|
+
const previousEmittedLimitW = Number.isFinite(previousLimitForDebug)
|
|
1957
|
+
? previousLimitForDebug
|
|
1958
|
+
: node.minLimit;
|
|
1959
|
+
const capFloorW = node.pvCapOnlyBlocksIncrease
|
|
1960
|
+
? Math.min(siteLimit, previousEmittedLimitW)
|
|
1961
|
+
: node.minLimit;
|
|
1962
|
+
const cappedByPv = clamp(
|
|
1963
|
+
Math.min(siteLimit, Math.max(capFloorW, pvCapLimitW)),
|
|
1964
|
+
node.minLimit,
|
|
1965
|
+
node.maxLimit
|
|
1966
|
+
);
|
|
1967
|
+
if (cappedByPv < siteLimit) {
|
|
1968
|
+
siteLimit = cappedByPv;
|
|
1969
|
+
pvCapApplied = true;
|
|
1970
|
+
pvCapBlockedIncrease = node.pvCapOnlyBlocksIncrease && siteLimit >= previousEmittedLimitW;
|
|
1971
|
+
if (controllerState && controllerState !== "stale_fallback") {
|
|
1972
|
+
controllerState = "pv_cap";
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// Controller memory follows the actual emitted limit. In growth-only
|
|
1978
|
+
// mode the PV cap may block an increase, but never creates a new decrease.
|
|
1979
|
+
if (Number.isFinite(siteLimit)) {
|
|
1980
|
+
lastLimit = siteLimit;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
if (!Number.isFinite(lastLimit) || node.mode === "nominal_power") {
|
|
1984
|
+
|
|
1985
|
+
lastLimit = siteLimit;
|
|
1986
|
+
|
|
1987
|
+
}
|
|
1988
|
+
if (reason !== "stale") {
|
|
1989
|
+
persistLastLimit(lastLimit, { nowMs: nowMs });
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
buildStatus({
|
|
1994
|
+
|
|
1995
|
+
mode: node.mode,
|
|
1996
|
+
|
|
1997
|
+
siteLimit: siteLimit,
|
|
1998
|
+
|
|
1999
|
+
gridReference: gridReference,
|
|
2000
|
+
|
|
2001
|
+
gridTarget: gridTarget,
|
|
2002
|
+
|
|
2003
|
+
active: active,
|
|
2004
|
+
|
|
2005
|
+
reason: reason,
|
|
2006
|
+
|
|
2007
|
+
rampDirection: rampDirection,
|
|
2008
|
+
|
|
2009
|
+
controllerState: controllerState
|
|
2010
|
+
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
|
|
2014
|
+
const measurementAgeMs = Number.isFinite(lastMsgTimestampMs) ? Math.max(0, nowMs - lastMsgTimestampMs) : null;
|
|
2015
|
+
|
|
2016
|
+
const mainMsg = {
|
|
2017
|
+
|
|
2018
|
+
topic: node.outputTopic,
|
|
2019
|
+
|
|
2020
|
+
payload: buildOutputPayload({
|
|
2021
|
+
siteLimit: siteLimit,
|
|
2022
|
+
rawGridReference: rawGridReference,
|
|
2023
|
+
gridReference: gridReference,
|
|
2024
|
+
filteredReference: filteredReference,
|
|
2025
|
+
gridTarget: gridTarget,
|
|
2026
|
+
correction: correction,
|
|
2027
|
+
error: error,
|
|
2028
|
+
pTerm: pTerm,
|
|
2029
|
+
effectiveKp: effectiveKp,
|
|
2030
|
+
effectiveKi: effectiveKi,
|
|
2031
|
+
gainsScale: gainsScale,
|
|
2032
|
+
iTerm: iTerm,
|
|
2033
|
+
active: active,
|
|
2034
|
+
reason: reason,
|
|
2035
|
+
controllerState: controllerState,
|
|
2036
|
+
stale: stale,
|
|
2037
|
+
pvTotalW: pvTotalW,
|
|
2038
|
+
pvCapLimitW: pvCapLimitW,
|
|
2039
|
+
pvCapOffsetW: pvCapOffsetW,
|
|
2040
|
+
pvCapApplied: pvCapApplied,
|
|
2041
|
+
pvAvailableEstimateW: pvAvailableEstimateForOutputW,
|
|
2042
|
+
pvCapBlockedIncrease: pvCapBlockedIncrease,
|
|
2043
|
+
pvCapHoldActive: pvCapHoldActive,
|
|
2044
|
+
controllerRequestedLimitW: controllerRequestedLimitW,
|
|
2045
|
+
feedForwardTargetW: feedForwardTargetW,
|
|
2046
|
+
feedForwardDeltaW: feedForwardDeltaW,
|
|
2047
|
+
exportDetected: exportDetected,
|
|
2048
|
+
recoveryBlocked: recoveryBlocked,
|
|
2049
|
+
rampDirection: rampDirection,
|
|
2050
|
+
clampedByMin: clampedByMin,
|
|
2051
|
+
clampedByMax: clampedByMax,
|
|
2052
|
+
measurementAgeMs: measurementAgeMs,
|
|
2053
|
+
msgTimestampMs: msgTimestampMs,
|
|
2054
|
+
fastCommand: fastCommand,
|
|
2055
|
+
previousLimit: previousLimitForDebug,
|
|
2056
|
+
controlDue: controlDue,
|
|
2057
|
+
immediateReductionDue: immediateReductionDue,
|
|
2058
|
+
immediateReductionBlocked: immediateReductionBlocked,
|
|
2059
|
+
immediateReductionCooldownRemainingMs: immediateReductionCooldownRemainingMs
|
|
2060
|
+
})
|
|
2061
|
+
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
const failSecureState = evaluateFailSecure({
|
|
2065
|
+
stale: stale,
|
|
2066
|
+
error: error,
|
|
2067
|
+
siteLimit: siteLimit,
|
|
2068
|
+
gridReference: filteredReference !== null ? filteredReference : gridReference
|
|
2069
|
+
}, nowMs);
|
|
2070
|
+
|
|
2071
|
+
const failSecureMsg = buildFailSecureMessage(failSecureState, nowMs);
|
|
2072
|
+
const emitMain = shouldEmitMainOutput(siteLimit, stale, nowMs, !!fastCommand);
|
|
2073
|
+
|
|
2074
|
+
if (node.failSecureEnabled) {
|
|
2075
|
+
if (emitMain || failSecureMsg) {
|
|
2076
|
+
send([emitMain ? mainMsg : null, failSecureMsg]);
|
|
2077
|
+
}
|
|
2078
|
+
} else if (emitMain) {
|
|
2079
|
+
send(mainMsg);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (emitMain) {
|
|
2083
|
+
rememberMainOutput(siteLimit, stale, nowMs);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (!stale) {
|
|
2087
|
+
lastAcceptedMeasurementTs = msgTimestampMs;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
|
|
2091
|
+
done();
|
|
2092
|
+
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
|
|
2095
|
+
node.status({
|
|
2096
|
+
|
|
2097
|
+
fill: "red",
|
|
2098
|
+
|
|
2099
|
+
shape: "ring",
|
|
2100
|
+
|
|
2101
|
+
text: err.message
|
|
2102
|
+
|
|
2103
|
+
});
|
|
2104
|
+
|
|
2105
|
+
if (node.mode !== "nominal_power" && !isControlCommandMessage(msg)) {
|
|
2106
|
+
emitSafetyOutput("invalid_telemetry", Date.now(), send, false);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
done(err);
|
|
2110
|
+
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
if (node.staleTimeoutMs > 0 && node.mode !== "nominal_power") {
|
|
2116
|
+
watchdogTimer = setInterval(function () {
|
|
2117
|
+
const nowMs = Date.now();
|
|
2118
|
+
if (!Number.isFinite(lastInputAtMs) || (nowMs - lastInputAtMs) <= node.staleTimeoutMs) {
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
if ((nowMs - lastWatchdogSafeAtMs) < node.outputHeartbeatMs) return;
|
|
2122
|
+
|
|
2123
|
+
const globalsValid = validateRequiredGlobalValues();
|
|
2124
|
+
if (globalsValid) refreshMaxLimitFromGlobal();
|
|
2125
|
+
lastWatchdogSafeAtMs = nowMs;
|
|
2126
|
+
node.status({ fill: "yellow", shape: "ring", text: (globalsValid ? "watchdog stale" : "invalid global") + " | safe output" });
|
|
2127
|
+
emitSafetyOutput(globalsValid ? "watchdog_stale" : "invalid_global", nowMs, node.send.bind(node), false);
|
|
2128
|
+
}, Math.max(250, Math.min(node.staleTimeoutMs, 1000)));
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
if (node.a3TestsEnabled) {
|
|
2132
|
+
a3Server = createA3TestServer({
|
|
2133
|
+
host: node.a3ModbusHost,
|
|
2134
|
+
port: node.a3ModbusPort,
|
|
2135
|
+
unitId: node.a3ModbusUnitId,
|
|
2136
|
+
nominalPower: node.nominalPower,
|
|
2137
|
+
minNominal: node.minLimit,
|
|
2138
|
+
maxNominal: node.maxLimit,
|
|
2139
|
+
rampType: node.a3RampType,
|
|
2140
|
+
rampValue: node.a3RampValue,
|
|
2141
|
+
onCommand: function () {
|
|
2142
|
+
emitA3NominalCommand(Date.now(), "a3_modbus_command");
|
|
2143
|
+
},
|
|
2144
|
+
onError: function (err) {
|
|
2145
|
+
node.warn("control-export A3 Modbus: " + err.message);
|
|
2146
|
+
},
|
|
2147
|
+
onProtocolIssue: function (issue) {
|
|
2148
|
+
if (node.debugLevel !== "off") {
|
|
2149
|
+
node.warn("control-export A3 Modbus issue: " + issue.code);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
a3Server.start().then(function () {
|
|
2155
|
+
node.status({
|
|
2156
|
+
fill: "blue",
|
|
2157
|
+
shape: "dot",
|
|
2158
|
+
text: "A3 Modbus " + node.a3ModbusHost + ":" + node.a3ModbusPort
|
|
2159
|
+
});
|
|
2160
|
+
emitA3NominalCommand(Date.now(), "a3_start");
|
|
2161
|
+
}).catch(function (err) {
|
|
2162
|
+
node.status({ fill: "red", shape: "ring", text: "A3 Modbus: " + err.message });
|
|
2163
|
+
node.error("control-export A3 Modbus: " + err.message);
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
node.on("close", function(done) {
|
|
2168
|
+
if (watchdogTimer) {
|
|
2169
|
+
clearInterval(watchdogTimer);
|
|
2170
|
+
watchdogTimer = null;
|
|
2171
|
+
}
|
|
2172
|
+
persistLastLimit(lastLimit, { force: true });
|
|
2173
|
+
if (a3Server) {
|
|
2174
|
+
const server = a3Server;
|
|
2175
|
+
a3Server = null;
|
|
2176
|
+
server.stop().finally(function () {
|
|
2177
|
+
if (typeof done === "function") done();
|
|
2178
|
+
});
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
if (typeof done === "function") done();
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
RED.nodes.registerType("control-export", ControlExportNode);
|
|
2191
|
+
|
|
2192
|
+
};
|