@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.
Files changed (107) hide show
  1. package/NOTICE +56 -0
  2. package/PROPRIETARY-LICENSE.txt +157 -0
  3. package/README.md +105 -0
  4. package/TERMS-AND-CONDITIONS.md +311 -0
  5. package/advantech-read/advantech-read.html +775 -0
  6. package/advantech-read/advantech-read.js +449 -0
  7. package/advantech-write/advantech-write.html +413 -0
  8. package/advantech-write/advantech-write.js +338 -0
  9. package/bess-control/bess-control.html +569 -0
  10. package/bess-control/bess-control.js +541 -0
  11. package/bess-control/lib/allocation.js +149 -0
  12. package/bess-control/lib/batteries.js +86 -0
  13. package/bess-control/lib/helpers.js +157 -0
  14. package/bess-control/lib/messages.js +131 -0
  15. package/bess-control/lib/runtime.js +156 -0
  16. package/carlo-gavazzi/carlo-gavazzi.html +4327 -0
  17. package/carlo-gavazzi/carlo-gavazzi.js +3081 -0
  18. package/control-export/control-export.html +3644 -0
  19. package/control-export/control-export.js +2192 -0
  20. package/control-reactive/control-reactive.html +3136 -0
  21. package/control-reactive/control-reactive.js +2124 -0
  22. package/fronius-emulator/README.md +258 -0
  23. package/fronius-emulator/fronius-emulator.html +170 -0
  24. package/fronius-emulator/fronius-emulator.js +520 -0
  25. package/fronius-emulator/lib/binary-codec.js +65 -0
  26. package/fronius-emulator/lib/fronius-register-map.js +185 -0
  27. package/fronius-emulator/lib/modbus-parser.js +61 -0
  28. package/fronius-emulator/lib/modbus-tcp-server.js +224 -0
  29. package/fronius-emulator/lib/validation.js +206 -0
  30. package/fronius-emulator/lib/virtual-meter.js +132 -0
  31. package/fronius-read/fronius-read.html +3944 -0
  32. package/fronius-read/fronius-read.js +1466 -0
  33. package/fronius-read/lib/inverter-profiles.js +711 -0
  34. package/fronius-read/lib/sunspec-core.js +303 -0
  35. package/fronius-read/lib/sunspec-model-parsers.js +845 -0
  36. package/fronius-write/fronius-write.html +1789 -0
  37. package/fronius-write/fronius-write.js +2531 -0
  38. package/influxdb/influxdb.html +1077 -0
  39. package/influxdb/influxdb.js +1487 -0
  40. package/janitza/janitza.html +2562 -0
  41. package/janitza/janitza.js +1500 -0
  42. package/janitza/lib/janitza-profiles.js +371 -0
  43. package/lib/a3-test-server.js +199 -0
  44. package/lib/adam-6000-profiles.js +173 -0
  45. package/lib/licensing/constants.js +103 -0
  46. package/lib/licensing/device-id.js +251 -0
  47. package/lib/licensing/errors.js +21 -0
  48. package/lib/licensing/license-client.js +377 -0
  49. package/lib/licensing/license-state.js +50 -0
  50. package/lib/licensing/node-guard.js +150 -0
  51. package/lib/licensing/package-integrity.js +167 -0
  52. package/lib/licensing/package-integrity.json +513 -0
  53. package/lib/licensing/public-key.pem +3 -0
  54. package/lib/licensing/public-key.pem.example +2 -0
  55. package/lib/licensing/public-keys.js +18 -0
  56. package/lib/licensing/storage.js +63 -0
  57. package/lib/licensing/token-verifier.js +84 -0
  58. package/lib/numbers.js +26 -0
  59. package/license/atelier-license.html +88 -0
  60. package/license/atelier-license.js +170 -0
  61. package/modbus-tcp-discover/modbus-tcp-discover.html +190 -0
  62. package/modbus-tcp-discover/modbus-tcp-discover.js +706 -0
  63. package/multi-battery/multi-battery.html +649 -0
  64. package/multi-battery/multi-battery.js +668 -0
  65. package/multi-grid/multi-grid.html +745 -0
  66. package/multi-grid/multi-grid.js +1392 -0
  67. package/multi-inverter/lib/engine-core.js +237 -0
  68. package/multi-inverter/multi-inverter.html +2674 -0
  69. package/multi-inverter/multi-inverter.js +1393 -0
  70. package/package.json +109 -0
  71. package/phoenix-contact/lib/eem-ma370-advanced.js +175 -0
  72. package/phoenix-contact/lib/eem-ma370.js +138 -0
  73. package/phoenix-contact/lib/integrations.js +149 -0
  74. package/phoenix-contact/lib/phoenix-contact-profile.js +35030 -0
  75. package/phoenix-contact/phoenix-contact.html +840 -0
  76. package/phoenix-contact/phoenix-contact.js +679 -0
  77. package/pv-generator/pv-generator.html +284 -0
  78. package/pv-generator/pv-generator.js +387 -0
  79. package/pylontech-read/pylontech-read.html +763 -0
  80. package/pylontech-read/pylontech-read.js +518 -0
  81. package/pylontech-write/pylontech-write.html +405 -0
  82. package/pylontech-write/pylontech-write.js +546 -0
  83. package/scada-aggregator/lib/aggregate.js +72 -0
  84. package/scada-aggregator/lib/groups.js +39 -0
  85. package/scada-aggregator/scada-aggregator.html +108 -0
  86. package/scada-aggregator/scada-aggregator.js +117 -0
  87. package/scada-server/lib/aggregator-map.js +58 -0
  88. package/scada-server/lib/encoding.js +235 -0
  89. package/scada-server/lib/modbus-server.js +492 -0
  90. package/scada-server/lib/object-path.js +18 -0
  91. package/scada-server/lib/register-map.js +288 -0
  92. package/scada-server/scada-server.html +1419 -0
  93. package/scada-server/scada-server.js +704 -0
  94. package/scripts/check-syntax.js +66 -0
  95. package/scripts/package-integrity.js +46 -0
  96. package/scripts/package-release.js +116 -0
  97. package/scripts/run-tests.js +2178 -0
  98. package/scripts/tgz-integrity.js +74 -0
  99. package/sma-read/lib/sunspec-core.js +83 -0
  100. package/sma-read/lib/sunspec-model-parsers.js +853 -0
  101. package/sma-read/sma-read.html +659 -0
  102. package/sma-read/sma-read.js +458 -0
  103. package/sma-write/sma-write.html +105 -0
  104. package/sma-write/sma-write.js +270 -0
  105. package/victron-read/lib/mqtt-state.js +176 -0
  106. package/victron-read/victron-read.html +1074 -0
  107. 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
+ };