@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,2124 @@
1
+ module.exports = function (RED) {
2
+
3
+
4
+ "use strict";
5
+
6
+ const { runLicensedNode } = require("../lib/licensing/node-guard");
7
+
8
+ const {
9
+ atLeast,
10
+ clampNumber,
11
+ toFiniteNumber
12
+ } = require("../lib/numbers");
13
+ const {
14
+ createA3TestServer
15
+ } = require("../lib/a3-test-server");
16
+
17
+
18
+
19
+ function clamp(value, min, max) {
20
+
21
+ if (!Number.isFinite(value)) return min;
22
+
23
+ return Math.min(Math.max(value, min), max);
24
+
25
+ }
26
+
27
+
28
+
29
+ function firstFinite() {
30
+
31
+ for (let i = 0; i < arguments.length; i++) {
32
+
33
+ if (arguments[i] === null || arguments[i] === undefined || arguments[i] === "") continue;
34
+
35
+ const n = Number(arguments[i]);
36
+
37
+ if (Number.isFinite(n)) return n;
38
+
39
+ }
40
+
41
+ return null;
42
+
43
+ }
44
+
45
+
46
+
47
+ function normalizeDebugLevel(value) {
48
+
49
+ const level = String(value || "").toLowerCase();
50
+
51
+ if (level === "normal" || level === "verbose") return level;
52
+
53
+ return "off";
54
+
55
+ }
56
+
57
+
58
+
59
+ function normalizeTuningPreset(value) {
60
+
61
+ const key = String(value || "").toLowerCase();
62
+
63
+ if (key === "custom" || key === "stable_grid") return key;
64
+
65
+ return "normal";
66
+
67
+ }
68
+
69
+
70
+
71
+ function extractTimestampMs(msg) {
72
+
73
+ const candidate = firstFinite(
74
+
75
+ msg && msg.timestamp,
76
+
77
+ msg && msg.ts,
78
+
79
+ msg && msg.time,
80
+
81
+ msg && msg.timestamp_ms,
82
+
83
+ msg && msg.time_ms,
84
+
85
+ msg && msg.payload && msg.payload.timestamp,
86
+
87
+ msg && msg.payload && msg.payload.ts,
88
+
89
+ msg && msg.payload && msg.payload.time,
90
+
91
+ msg && msg.payload && msg.payload.timestamp_ms,
92
+
93
+ msg && msg.payload && msg.payload.time_ms
94
+
95
+ );
96
+
97
+
98
+ if (candidate === null) return Date.now();
99
+
100
+ if (candidate < 1e11) return Math.round(candidate * 1000);
101
+
102
+ return Math.round(candidate);
103
+
104
+ }
105
+
106
+
107
+
108
+ function ReactiveControlNode(config) {
109
+
110
+ RED.nodes.createNode(this, config);
111
+
112
+
113
+ const node = this;
114
+
115
+ runLicensedNode(node, RED, config, function startLicensedRuntime() {
116
+
117
+
118
+
119
+ node.name = config.name || "";
120
+
121
+ node.mode = config.mode || "zero_feed_in";
122
+
123
+ node.zeroFeedInMode = config.zeroFeedInMode === "absolute" ? "absolute" : "relative";
124
+
125
+ node.tuningPreset = normalizeTuningPreset(config.tuningPreset);
126
+
127
+
128
+ node.gridSetPointVar = toFiniteNumber(config.gridSetPointVar, 0);
129
+
130
+ node.configuredNominalReactivePowerVar = atLeast(config.nominalReactivePowerVar, 1, 25000);
131
+ node.nominalReactivePowerFromGlobal = !!config.nominalReactivePowerFromGlobal;
132
+ node.nominalReactivePowerGlobalVar = String(config.nominalReactivePowerGlobalVar || "max_q").trim() || "max_q";
133
+ node.nominalReactivePowerVar = node.configuredNominalReactivePowerVar;
134
+
135
+ node.fixedReactivePowerVar = toFiniteNumber(config.fixedReactivePowerVar, 0);
136
+ node.fixedReactivePowerFromGlobal = !!config.fixedReactivePowerFromGlobal;
137
+ node.fixedReactivePowerGlobalVar = String(config.fixedReactivePowerGlobalVar || "nominal_reactive_power").trim() || "nominal_reactive_power";
138
+
139
+
140
+ node.deadbandVar = atLeast(config.deadbandVar, 0, 100);
141
+
142
+ node.commandDeadbandVar = atLeast(config.commandDeadbandVar ?? config.outputDeadbandVar, 0, 50);
143
+
144
+ node.filterTauMs = atLeast(config.filterTauMs ?? config.avgWindowMs, 0, 4000);
145
+
146
+ node.noiseThresholdVar = atLeast(config.noiseThresholdVar, 0, 120);
147
+
148
+ node.noiseReleaseMs = atLeast(config.noiseReleaseMs, 0, 3000);
149
+
150
+ node.kp = atLeast(config.kp, 0, 0.08);
151
+
152
+ node.ki = atLeast(config.ki, 0, 0);
153
+
154
+ node.controlPeriodMs = atLeast(config.controlPeriodMs ?? config.sendIntervalMs, 50, 1000);
155
+
156
+ node.minCommandIntervalMs = atLeast(config.minCommandIntervalMs, 0, 10000);
157
+
158
+ node.settleAfterCommandMs = atLeast(config.settleAfterCommandMs ?? config.minCommandIntervalMs, 0, 10000);
159
+
160
+ node.transientIgnoreMs = atLeast(config.transientIgnoreMs, 0, 0);
161
+
162
+ node.maxStepUpVar = atLeast(config.maxStepUpVar, 0, 200);
163
+
164
+ node.maxStepDownVar = atLeast(config.maxStepDownVar, 0, 200);
165
+
166
+ node.adaptiveGains = config.adaptiveGains === true;
167
+
168
+ node.adaptiveErrorBandVar = atLeast(config.adaptiveErrorBandVar, 1, 2500);
169
+
170
+ node.adaptiveMinScale = clampNumber(config.adaptiveMinScale, 0.05, 1, 0.25);
171
+
172
+
173
+ node.maxStepVar = atLeast(config.maxStepVar, 0, 0); // legacy
174
+
175
+ if (node.maxStepUpVar <= 0 && node.maxStepVar > 0) node.maxStepUpVar = node.maxStepVar;
176
+
177
+ if (node.maxStepDownVar <= 0 && node.maxStepVar > 0) node.maxStepDownVar = node.maxStepVar;
178
+
179
+
180
+ node.staleTimeoutMs = atLeast(config.staleTimeoutMs, 0, 5000);
181
+
182
+ node.maxFutureTimestampMs = atLeast(config.maxFutureTimestampMs, 0, 2000);
183
+
184
+ node.safeLimitOnStale = clampNumber(config.safeLimitOnStale, -node.nominalReactivePowerVar, node.nominalReactivePowerVar, 0);
185
+
186
+ node.debugLevel = normalizeDebugLevel(config.debugLevel);
187
+
188
+ node.startLimitMode = (function(v) {
189
+
190
+ const m = String(v || "").toLowerCase();
191
+
192
+ if (m === "last_limit") return m;
193
+
194
+ return "last_limit";
195
+
196
+ })(config.startLimitMode);
197
+
198
+ node.failSecureEnabled = !!config.failSecureEnabled;
199
+
200
+ node.failSecureTimeSec = atLeast(config.failSecureTimeSec, 1, 10);
201
+ node.a3TestsEnabled = !!config.a3TestsEnabled;
202
+ node.a3ModbusHost = String(config.a3ModbusHost || "0.0.0.0").trim() || "0.0.0.0";
203
+ node.a3ModbusPort = atLeast(config.a3ModbusPort, 1, 1503);
204
+ node.a3ModbusUnitId = atLeast(config.a3ModbusUnitId, 0, 1);
205
+ node.a3RampType = String(config.a3RampType || "none").trim() || "none";
206
+ node.a3RampValue = atLeast(config.a3RampValue, 0, 0);
207
+
208
+
209
+ node.outputTopic = String(config.outputTopic || "control-reactive").trim() || "control-reactive";
210
+
211
+ if (node.a3TestsEnabled) {
212
+ node.mode = "nominal_reactive_power";
213
+ node.fixedReactivePowerFromGlobal = false;
214
+ node.nominalReactivePowerFromGlobal = false;
215
+ }
216
+
217
+
218
+
219
+ const tuningPresets = {
220
+
221
+ normal: {
222
+
223
+ deadbandVar: 100,
224
+
225
+ filterTauMs: 2000,
226
+
227
+ kp: 0.08,
228
+
229
+ ki: 0,
230
+
231
+ transientIgnoreMs: 0,
232
+
233
+ maxStepUpVar: 200,
234
+
235
+ maxStepDownVar: 200,
236
+
237
+ adaptiveGains: false,
238
+
239
+ adaptiveErrorBandVar: 2000,
240
+
241
+ adaptiveMinScale: 0.2
242
+
243
+ },
244
+
245
+ stable_grid: {
246
+
247
+ deadbandVar: 150,
248
+
249
+ filterTauMs: 5000,
250
+
251
+ kp: 0.06,
252
+
253
+ ki: 0,
254
+
255
+ transientIgnoreMs: 0,
256
+
257
+ maxStepUpVar: 120,
258
+
259
+ maxStepDownVar: 120,
260
+
261
+ adaptiveGains: false,
262
+
263
+ adaptiveErrorBandVar: 2500,
264
+
265
+ adaptiveMinScale: 0.25
266
+
267
+ }
268
+
269
+ };
270
+
271
+
272
+ if (node.tuningPreset !== "custom") {
273
+
274
+ const preset = tuningPresets[node.tuningPreset] || tuningPresets.normal;
275
+
276
+ node.deadbandVar = preset.deadbandVar;
277
+
278
+ node.filterTauMs = preset.filterTauMs;
279
+
280
+ node.kp = preset.kp;
281
+
282
+ node.ki = preset.ki;
283
+
284
+ node.transientIgnoreMs = preset.transientIgnoreMs;
285
+
286
+ node.maxStepUpVar = preset.maxStepUpVar;
287
+
288
+ node.maxStepDownVar = preset.maxStepDownVar;
289
+
290
+ node.adaptiveGains = preset.adaptiveGains;
291
+
292
+ node.adaptiveErrorBandVar = preset.adaptiveErrorBandVar;
293
+
294
+ node.adaptiveMinScale = preset.adaptiveMinScale;
295
+
296
+ }
297
+
298
+
299
+
300
+ let enabled = true;
301
+
302
+ let lastLimit = null;
303
+
304
+ let filteredReactiveReference = null;
305
+
306
+ let lastMeasurementTs = null;
307
+
308
+ let lastControlTs = 0;
309
+
310
+ let lastRawReactiveReference = null;
311
+
312
+ let lastMsgTimestampMs = null;
313
+
314
+ let lastAcceptedMeasurementTs = null;
315
+
316
+ let integratorState = 0;
317
+
318
+ let outsideDeadbandSinceMs = null;
319
+
320
+ let failSecureBreachStartMs = null;
321
+
322
+ let failSecureActive = false;
323
+
324
+ let hasEmittedCommand = false;
325
+
326
+ let lastCommandSentAtMs = 0;
327
+
328
+ let lastFailSecureOutputActive = false;
329
+ let watchdogTimer = null;
330
+ let a3Server = null;
331
+ let lastInputAtMs = null;
332
+ let watchdogSafeEmitted = false;
333
+
334
+ let settleUntilMs = 0;
335
+
336
+ let noiseMetricVar = 0;
337
+
338
+ let noiseBelowSinceMs = null;
339
+
340
+ let lastNoiseReference = null;
341
+
342
+ let lastNoiseTimestampMs = null;
343
+
344
+
345
+ const nodeCtx = node.context();
346
+ const globalCtx = node.context().global;
347
+
348
+
349
+ function refreshNominalReactivePowerFromGlobal() {
350
+
351
+ let maxQ = node.configuredNominalReactivePowerVar;
352
+
353
+ if (node.nominalReactivePowerFromGlobal) {
354
+
355
+ const value = Number(globalCtx.get(node.nominalReactivePowerGlobalVar));
356
+
357
+ if (Number.isFinite(value)) maxQ = value;
358
+
359
+ }
360
+
361
+ node.nominalReactivePowerVar = Math.max(1, maxQ);
362
+
363
+ return node.nominalReactivePowerVar;
364
+
365
+ }
366
+
367
+ function validateRequiredGlobalValues() {
368
+
369
+ const requirements = [];
370
+ if (node.fixedReactivePowerFromGlobal) requirements.push([node.fixedReactivePowerGlobalVar, "Fixed Q", 0]);
371
+ if (node.nominalReactivePowerFromGlobal) requirements.push([node.nominalReactivePowerGlobalVar, "Max Q", 1]);
372
+
373
+ for (const requirement of requirements) {
374
+
375
+ const key = String(requirement[0] || "").trim();
376
+ const raw = key ? globalCtx.get(key) : undefined;
377
+ const value = Number(raw);
378
+ if (raw === undefined || raw === null || raw === "" || !Number.isFinite(value) || (requirement[2] > 0 && value < requirement[2])) {
379
+ node.status({ fill: "red", shape: "ring", text: "ALARM: global " + requirement[1] + " missing (" + key + ")" });
380
+ return false;
381
+ }
382
+
383
+ }
384
+
385
+ return true;
386
+
387
+ }
388
+
389
+ if (!validateRequiredGlobalValues()) {
390
+
391
+ node.warn("control-reactive blocked: required global value is missing or invalid");
392
+
393
+ }
394
+
395
+
396
+
397
+ function getPersistedLastLimit() {
398
+
399
+ const n = Number(nodeCtx.get("control_reactive_last_limit_var"));
400
+
401
+ if (!Number.isFinite(n)) return null;
402
+
403
+ return clamp(n, -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
404
+
405
+ }
406
+
407
+
408
+
409
+ function persistLastLimit(value) {
410
+
411
+ if (!Number.isFinite(value)) return;
412
+
413
+ nodeCtx.set(
414
+
415
+ "control_reactive_last_limit_var",
416
+
417
+ clamp(Number(value), -node.nominalReactivePowerVar, node.nominalReactivePowerVar)
418
+
419
+ );
420
+
421
+ }
422
+
423
+
424
+
425
+ (function initializeStartLimit() {
426
+
427
+ if (node.startLimitMode === "last_limit") {
428
+
429
+ const persisted = getPersistedLastLimit();
430
+
431
+ if (Number.isFinite(persisted)) {
432
+
433
+ lastLimit = persisted;
434
+
435
+ hasEmittedCommand = true;
436
+
437
+ return;
438
+
439
+ }
440
+
441
+ }
442
+
443
+ lastLimit = 0;
444
+
445
+ })();
446
+
447
+
448
+
449
+ function restoreStartLimitState(nowMs) {
450
+
451
+ if (node.startLimitMode === "last_limit") {
452
+
453
+ const persisted = getPersistedLastLimit();
454
+
455
+ if (Number.isFinite(persisted)) {
456
+
457
+ lastLimit = persisted;
458
+
459
+ hasEmittedCommand = true;
460
+
461
+ lastCommandSentAtMs = nowMs;
462
+
463
+ settleUntilMs = nowMs + node.settleAfterCommandMs;
464
+
465
+ resetPostCommandObservation();
466
+
467
+ return;
468
+
469
+ }
470
+
471
+ }
472
+
473
+
474
+ lastLimit = 0;
475
+
476
+ hasEmittedCommand = false;
477
+
478
+ lastCommandSentAtMs = 0;
479
+
480
+ settleUntilMs = 0;
481
+
482
+ }
483
+
484
+
485
+
486
+ function resetControlState() {
487
+
488
+ integratorState = 0;
489
+
490
+ filteredReactiveReference = null;
491
+
492
+ lastMeasurementTs = null;
493
+
494
+ lastControlTs = 0;
495
+
496
+ lastAcceptedMeasurementTs = null;
497
+
498
+ outsideDeadbandSinceMs = null;
499
+
500
+ failSecureBreachStartMs = null;
501
+
502
+ failSecureActive = false;
503
+
504
+ settleUntilMs = 0;
505
+
506
+ noiseMetricVar = 0;
507
+
508
+ noiseBelowSinceMs = null;
509
+
510
+ lastNoiseReference = null;
511
+
512
+ lastNoiseTimestampMs = null;
513
+
514
+ }
515
+
516
+
517
+
518
+ function resetPostCommandObservation() {
519
+
520
+ integratorState = 0;
521
+
522
+ outsideDeadbandSinceMs = null;
523
+
524
+ noiseMetricVar = 0;
525
+
526
+ noiseBelowSinceMs = null;
527
+
528
+ lastNoiseReference = null;
529
+
530
+ lastNoiseTimestampMs = null;
531
+
532
+ }
533
+
534
+
535
+
536
+ function shouldEmitCommand(nextLimit, reason, nowMs) {
537
+
538
+ if (!Number.isFinite(nextLimit)) return false;
539
+
540
+ if (!hasEmittedCommand) return true;
541
+
542
+
543
+ const previousLimit = Number.isFinite(lastLimit) ? Number(lastLimit) : null;
544
+
545
+ if (!Number.isFinite(previousLimit)) return true;
546
+
547
+
548
+ const delta = Math.abs(Number(nextLimit) - previousLimit);
549
+
550
+ const roundedChanged = Math.round(nextLimit) !== Math.round(previousLimit);
551
+
552
+ const intervalOk = node.minCommandIntervalMs <= 0 ||
553
+
554
+ !Number.isFinite(lastCommandSentAtMs) ||
555
+
556
+ lastCommandSentAtMs <= 0 ||
557
+
558
+ (nowMs - lastCommandSentAtMs) >= node.minCommandIntervalMs;
559
+
560
+
561
+ if (!roundedChanged) return false;
562
+
563
+ if (reason === "stale") return true;
564
+
565
+ if (delta < node.commandDeadbandVar) return false;
566
+
567
+ return intervalOk;
568
+
569
+ }
570
+
571
+
572
+
573
+ function updateNoiseState(referenceVar, measurementTsMs) {
574
+
575
+ if (!Number.isFinite(referenceVar)) {
576
+
577
+ return {
578
+
579
+ metricVar: noiseMetricVar,
580
+
581
+ stable: true,
582
+
583
+ durationMs: 0
584
+
585
+ };
586
+
587
+ }
588
+
589
+
590
+ if (!Number.isFinite(lastNoiseReference)) {
591
+
592
+ lastNoiseReference = Number(referenceVar);
593
+
594
+ lastNoiseTimestampMs = measurementTsMs;
595
+
596
+ noiseMetricVar = 0;
597
+
598
+ noiseBelowSinceMs = measurementTsMs;
599
+
600
+ return {
601
+
602
+ metricVar: noiseMetricVar,
603
+
604
+ stable: true,
605
+
606
+ durationMs: 0
607
+
608
+ };
609
+
610
+ }
611
+
612
+
613
+ const dtMs = Number.isFinite(measurementTsMs) && Number.isFinite(lastNoiseTimestampMs)
614
+
615
+ ? Math.max(1, measurementTsMs - lastNoiseTimestampMs)
616
+
617
+ : Math.max(1, node.controlPeriodMs);
618
+
619
+ const alpha = node.filterTauMs > 0
620
+
621
+ ? clamp(dtMs / (node.filterTauMs + dtMs), 0, 1)
622
+
623
+ : 1;
624
+
625
+
626
+ const deltaVar = Math.abs(Number(referenceVar) - Number(lastNoiseReference));
627
+
628
+ noiseMetricVar = alpha * deltaVar + (1 - alpha) * noiseMetricVar;
629
+
630
+ lastNoiseReference = Number(referenceVar);
631
+
632
+ lastNoiseTimestampMs = measurementTsMs;
633
+
634
+
635
+ if (node.noiseThresholdVar <= 0) {
636
+
637
+ noiseBelowSinceMs = measurementTsMs;
638
+
639
+ return {
640
+
641
+ metricVar: noiseMetricVar,
642
+
643
+ stable: true,
644
+
645
+ durationMs: 0
646
+
647
+ };
648
+
649
+ }
650
+
651
+
652
+ if (noiseMetricVar <= node.noiseThresholdVar) {
653
+
654
+ if (!Number.isFinite(noiseBelowSinceMs)) {
655
+
656
+ noiseBelowSinceMs = measurementTsMs;
657
+
658
+ }
659
+
660
+ } else {
661
+
662
+ noiseBelowSinceMs = null;
663
+
664
+ }
665
+
666
+
667
+ const stableDurationMs = Number.isFinite(noiseBelowSinceMs)
668
+
669
+ ? Math.max(0, measurementTsMs - noiseBelowSinceMs)
670
+
671
+ : 0;
672
+
673
+ const stable = noiseMetricVar <= node.noiseThresholdVar &&
674
+
675
+ (node.noiseReleaseMs <= 0 || stableDurationMs >= node.noiseReleaseMs);
676
+
677
+
678
+ return {
679
+
680
+ metricVar: noiseMetricVar,
681
+
682
+ stable: stable,
683
+
684
+ durationMs: stableDurationMs
685
+
686
+ };
687
+
688
+ }
689
+
690
+
691
+
692
+ function markCommandEmitted(siteLimit, nowMs) {
693
+
694
+ lastLimit = clamp(Number(siteLimit), -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
695
+
696
+ lastCommandSentAtMs = nowMs;
697
+
698
+ settleUntilMs = nowMs + node.settleAfterCommandMs;
699
+
700
+ hasEmittedCommand = true;
701
+
702
+ resetPostCommandObservation();
703
+
704
+ persistLastLimit(lastLimit);
705
+
706
+ }
707
+
708
+
709
+
710
+ function isStale(timestampMs, nowMs) {
711
+
712
+ if (node.staleTimeoutMs <= 0) return false;
713
+
714
+ if (!Number.isFinite(timestampMs)) return true;
715
+
716
+ return (nowMs - timestampMs) > node.staleTimeoutMs;
717
+
718
+ }
719
+
720
+
721
+
722
+ function effectiveGains(errorAbs) {
723
+
724
+ if (!node.adaptiveGains) {
725
+
726
+ return { kp: node.kp, ki: node.ki, scale: 1 };
727
+
728
+ }
729
+
730
+
731
+ const progress = clamp(Number(errorAbs || 0) / node.adaptiveErrorBandVar, 0, 1);
732
+
733
+ const scale = node.adaptiveMinScale + (1 - node.adaptiveMinScale) * progress;
734
+
735
+ return {
736
+
737
+ kp: node.kp * scale,
738
+
739
+ ki: node.ki * scale,
740
+
741
+ scale: scale
742
+
743
+ };
744
+
745
+ }
746
+
747
+
748
+
749
+ function extractReactiveTelemetry(msg) {
750
+
751
+ const p = msg && msg.payload;
752
+
753
+ if (!p || typeof p !== "object") {
754
+
755
+ return {
756
+
757
+ ok: false,
758
+
759
+ missing: ["reactive_l1_var", "reactive_l2_var", "reactive_l3_var", "reactive_total_var"],
760
+
761
+ values: null
762
+
763
+ };
764
+
765
+ }
766
+
767
+
768
+ const summary = p.summary || {};
769
+
770
+ const reactive = summary.reactive || {};
771
+
772
+ const reactivePower = p.reactive_power || {};
773
+
774
+
775
+ const l1 = firstFinite(
776
+
777
+ p.reactive_l1_var,
778
+
779
+ p.q_l1_var,
780
+
781
+ reactive.l1_var,
782
+
783
+ reactivePower.l1_var
784
+
785
+ );
786
+
787
+ const l2 = firstFinite(
788
+
789
+ p.reactive_l2_var,
790
+
791
+ p.q_l2_var,
792
+
793
+ reactive.l2_var,
794
+
795
+ reactivePower.l2_var
796
+
797
+ );
798
+
799
+ const l3 = firstFinite(
800
+
801
+ p.reactive_l3_var,
802
+
803
+ p.q_l3_var,
804
+
805
+ reactive.l3_var,
806
+
807
+ reactivePower.l3_var
808
+
809
+ );
810
+
811
+ const total = firstFinite(
812
+
813
+ p.reactive_total_var,
814
+
815
+ p.q_total_var,
816
+
817
+ p.grid_reactive_power_var,
818
+
819
+ p.reactive_power_var,
820
+
821
+ reactive.total_var,
822
+
823
+ reactivePower.total_var
824
+
825
+ );
826
+
827
+
828
+ const missing = [];
829
+
830
+ if (!Number.isFinite(l1)) missing.push("reactive_l1_var");
831
+
832
+ if (!Number.isFinite(l2)) missing.push("reactive_l2_var");
833
+
834
+ if (!Number.isFinite(l3)) missing.push("reactive_l3_var");
835
+
836
+ if (!Number.isFinite(total)) missing.push("reactive_total_var");
837
+
838
+
839
+ if (missing.length > 0) {
840
+
841
+ return { ok: false, missing: missing, values: null };
842
+
843
+ }
844
+
845
+
846
+ return {
847
+
848
+ ok: true,
849
+
850
+ missing: [],
851
+
852
+ values: { l1: Number(l1), l2: Number(l2), l3: Number(l3), total: Number(total) }
853
+
854
+ };
855
+
856
+ }
857
+
858
+
859
+ function closestPhaseReactiveValue(values) {
860
+
861
+ if (!values) return null;
862
+
863
+ const list = [values.l1, values.l2, values.l3].filter(function (v) { return Number.isFinite(v); });
864
+
865
+ if (!list.length) return null;
866
+
867
+ return list.reduce(function (best, value) {
868
+
869
+ if (!Number.isFinite(best)) return value;
870
+
871
+ return Math.abs(value) < Math.abs(best) ? value : best;
872
+
873
+ }, null);
874
+
875
+ }
876
+
877
+
878
+ function extractControlReactiveReference(msg) {
879
+
880
+ const telemetry = extractReactiveTelemetry(msg);
881
+
882
+ if (!telemetry.ok) {
883
+
884
+ return telemetry;
885
+
886
+ }
887
+
888
+
889
+ const values = telemetry.values;
890
+
891
+ if (node.mode === "zero_feed_in" && node.zeroFeedInMode === "absolute") {
892
+
893
+ const closest = closestPhaseReactiveValue(values);
894
+
895
+ if (!Number.isFinite(closest)) {
896
+
897
+ return {
898
+
899
+ ok: false,
900
+
901
+ missing: ["reactive_l1_var", "reactive_l2_var", "reactive_l3_var"],
902
+
903
+ values: null
904
+
905
+ };
906
+
907
+ }
908
+
909
+ return { ok: true, missing: [], values: values, reference: Number(closest) };
910
+
911
+ }
912
+
913
+
914
+ return { ok: true, missing: [], values: values, reference: Number(values.total) };
915
+
916
+ }
917
+
918
+
919
+
920
+ function updateFilter(reactiveReference, measurementTsMs) {
921
+
922
+ if (!Number.isFinite(reactiveReference)) return filteredReactiveReference;
923
+
924
+
925
+ if (filteredReactiveReference === null || !Number.isFinite(lastMeasurementTs)) {
926
+
927
+ filteredReactiveReference = reactiveReference;
928
+
929
+ lastMeasurementTs = measurementTsMs;
930
+
931
+ return filteredReactiveReference;
932
+
933
+ }
934
+
935
+
936
+ const dtMs = Math.max(1, measurementTsMs - lastMeasurementTs);
937
+
938
+ const alpha = node.filterTauMs > 0
939
+
940
+ ? clamp(dtMs / (node.filterTauMs + dtMs), 0, 1)
941
+
942
+ : 1;
943
+
944
+
945
+ filteredReactiveReference = alpha * reactiveReference + (1 - alpha) * filteredReactiveReference;
946
+
947
+ lastMeasurementTs = measurementTsMs;
948
+
949
+ return filteredReactiveReference;
950
+
951
+ }
952
+
953
+
954
+
955
+ function getReactiveTargetVar() {
956
+
957
+ if (node.mode === "zero_feed_in") return 0;
958
+
959
+ if (node.mode === "grid_set_point") return Number(node.gridSetPointVar || 0);
960
+
961
+ return null;
962
+
963
+ }
964
+
965
+ function readFixedReactivePowerVar() {
966
+ if (node.a3TestsEnabled && a3Server) {
967
+ const command = a3Server.getCommand();
968
+ if (command && Number.isFinite(command.nominalPower)) {
969
+ return command.nominalPower;
970
+ }
971
+ }
972
+
973
+ if (!node.fixedReactivePowerFromGlobal) {
974
+ return Number(node.fixedReactivePowerVar);
975
+ }
976
+
977
+ const key = String(node.fixedReactivePowerGlobalVar || "").trim();
978
+ if (!key) {
979
+ return Number(node.fixedReactivePowerVar);
980
+ }
981
+
982
+ const raw = globalCtx.get(key);
983
+ const n = Number(raw);
984
+ if (Number.isFinite(n)) {
985
+ return n;
986
+ }
987
+
988
+ return Number(node.fixedReactivePowerVar);
989
+ }
990
+
991
+
992
+
993
+ function computeClosedLoopLimit(filteredReference) {
994
+
995
+ const target = getReactiveTargetVar();
996
+
997
+ const error = filteredReference - target;
998
+
999
+ const previousLimit = Number.isFinite(lastLimit)
1000
+
1001
+ ? lastLimit
1002
+
1003
+ : 0;
1004
+
1005
+
1006
+ if (Math.abs(error) <= node.deadbandVar && Number.isFinite(lastLimit)) {
1007
+
1008
+ return {
1009
+
1010
+ siteLimit: previousLimit,
1011
+
1012
+ reactiveTargetVar: target,
1013
+
1014
+ correction: 0,
1015
+
1016
+ error: error,
1017
+
1018
+ pTerm: 0,
1019
+
1020
+ iTerm: 0,
1021
+
1022
+ active: false,
1023
+
1024
+ rampDirection: "hold",
1025
+
1026
+ reason: "deadband",
1027
+
1028
+ controllerState: "inside_deadband",
1029
+
1030
+ clampedByMin: false,
1031
+
1032
+ clampedByMax: false,
1033
+
1034
+ effectiveKp: node.kp,
1035
+
1036
+ effectiveKi: 0,
1037
+
1038
+ gainsScale: 1
1039
+
1040
+ };
1041
+
1042
+ }
1043
+
1044
+
1045
+ const proportionalStep = node.kp > 0
1046
+
1047
+ ? error * node.kp
1048
+
1049
+ : error;
1050
+
1051
+ let candidateLimit = previousLimit + proportionalStep;
1052
+
1053
+
1054
+ const maxStepUp = node.maxStepUpVar > 0 ? node.maxStepUpVar : Infinity;
1055
+
1056
+ const maxStepDown = node.maxStepDownVar > 0 ? node.maxStepDownVar : Infinity;
1057
+
1058
+ const rawDelta = candidateLimit - previousLimit;
1059
+
1060
+
1061
+ if (rawDelta > maxStepUp) {
1062
+
1063
+ candidateLimit = previousLimit + maxStepUp;
1064
+
1065
+ } else if (rawDelta < -maxStepDown) {
1066
+
1067
+ candidateLimit = previousLimit - maxStepDown;
1068
+
1069
+ }
1070
+
1071
+
1072
+ const unclampedCandidate = candidateLimit;
1073
+
1074
+ candidateLimit = clamp(candidateLimit, -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
1075
+
1076
+
1077
+ const clampedByMin = candidateLimit <= -node.nominalReactivePowerVar && unclampedCandidate < -node.nominalReactivePowerVar;
1078
+
1079
+ const clampedByMax = candidateLimit >= node.nominalReactivePowerVar && unclampedCandidate > node.nominalReactivePowerVar;
1080
+
1081
+
1082
+ const rampDirection = candidateLimit > previousLimit
1083
+
1084
+ ? "up"
1085
+
1086
+ : candidateLimit < previousLimit
1087
+
1088
+ ? "down"
1089
+
1090
+ : "hold";
1091
+
1092
+
1093
+ let controllerState = "tracking";
1094
+
1095
+ if (clampedByMin) controllerState = "at_min_limit";
1096
+
1097
+ else if (clampedByMax) controllerState = "at_max_limit";
1098
+
1099
+ else if (rampDirection === "up") controllerState = "increasing_limit";
1100
+
1101
+ else if (rampDirection === "down") controllerState = "decreasing_limit";
1102
+
1103
+
1104
+ return {
1105
+
1106
+ siteLimit: candidateLimit,
1107
+
1108
+ reactiveTargetVar: target,
1109
+
1110
+ correction: candidateLimit - previousLimit,
1111
+
1112
+ error: error,
1113
+
1114
+ pTerm: candidateLimit - previousLimit,
1115
+
1116
+ iTerm: 0,
1117
+
1118
+ active: rampDirection !== "hold",
1119
+
1120
+ rampDirection: rampDirection,
1121
+
1122
+ reason: "tracking",
1123
+
1124
+ controllerState: controllerState,
1125
+
1126
+ clampedByMin: clampedByMin,
1127
+
1128
+ clampedByMax: clampedByMax,
1129
+
1130
+ effectiveKp: node.kp,
1131
+
1132
+ effectiveKi: 0,
1133
+
1134
+ gainsScale: 1
1135
+
1136
+ };
1137
+
1138
+ }
1139
+
1140
+
1141
+ function buildStatus(info) {
1142
+
1143
+ if (info.reason === "stale") {
1144
+
1145
+ node.status({ fill: "yellow", shape: "ring", text: "stale | limit " + Math.round(info.siteLimit) + "VAr" });
1146
+
1147
+ return;
1148
+
1149
+ }
1150
+
1151
+
1152
+ if (info.mode === "nominal_reactive_power") {
1153
+
1154
+ node.status({ fill: "blue", shape: "dot", text: "nominal | limit " + Math.round(info.siteLimit) + "VAr" });
1155
+
1156
+ return;
1157
+
1158
+ }
1159
+
1160
+
1161
+ const reactiveNow = Number.isFinite(info.reactiveReference) ? Math.round(info.reactiveReference) : 0;
1162
+
1163
+ const target = Number.isFinite(info.reactiveTargetVar) ? Math.round(info.reactiveTargetVar) : 0;
1164
+
1165
+ const limit = Math.round(info.siteLimit);
1166
+
1167
+ const state = info.controllerState || info.reason || "idle";
1168
+
1169
+ const text = "q " + reactiveNow + "VAr | target " + target + "VAr | limit " + limit + "VAr | " + state;
1170
+
1171
+
1172
+ if (info.active) {
1173
+
1174
+ node.status({ fill: "green", shape: "dot", text: text });
1175
+
1176
+ return;
1177
+
1178
+ }
1179
+
1180
+
1181
+ node.status({ fill: "blue", shape: "ring", text: text });
1182
+
1183
+ }
1184
+
1185
+
1186
+
1187
+ function computeAutoRampSeconds() {
1188
+
1189
+ const intervalSeconds = Math.round(Math.max(0, Number(node.minCommandIntervalMs || 0)) / 1000);
1190
+
1191
+ return Math.max(0, intervalSeconds - 1);
1192
+
1193
+ }
1194
+
1195
+
1196
+
1197
+ function buildOutputPayload(context) {
1198
+
1199
+ const autoRampSeconds = computeAutoRampSeconds();
1200
+
1201
+ const basePayload = {
1202
+
1203
+ powerType: "reactive",
1204
+
1205
+ mode: "vars",
1206
+
1207
+ limit_var: Math.round(context.siteLimit),
1208
+
1209
+ ramp: node.a3TestsEnabled && a3Server ? {
1210
+
1211
+ type: a3Server.getCommand().rampType,
1212
+
1213
+ value: a3Server.getCommand().rampValue
1214
+
1215
+ } : {
1216
+
1217
+ type: "time",
1218
+
1219
+ value: autoRampSeconds
1220
+
1221
+ },
1222
+
1223
+ grid_reference_var: Number.isFinite(context.filteredReference) ? Math.round(context.filteredReference) : null,
1224
+
1225
+ grid_target_var: Number.isFinite(context.reactiveTargetVar) ? Math.round(context.reactiveTargetVar) : null,
1226
+
1227
+ reason: context.reason,
1228
+
1229
+ controller_state: context.controllerState,
1230
+
1231
+ stale: context.stale,
1232
+
1233
+ timestamp_ms: context.msgTimestampMs
1234
+
1235
+ };
1236
+
1237
+ if (node.a3TestsEnabled) {
1238
+ const command = a3Server ? a3Server.getCommand() : null;
1239
+ basePayload.a3_tests = true;
1240
+ basePayload.a3_sequence = command ? command.sequence : 0;
1241
+ }
1242
+
1243
+
1244
+ if (node.debugLevel === "off") {
1245
+
1246
+ return basePayload;
1247
+
1248
+ }
1249
+
1250
+
1251
+ const normalPayload = Object.assign({}, basePayload, {
1252
+
1253
+ raw_grid_reference_var: Number.isFinite(context.rawReactiveReference) ? Math.round(context.rawReactiveReference) : null,
1254
+
1255
+ filtered_grid_reference_var: Number.isFinite(context.filteredReference) ? Math.round(context.filteredReference) : null,
1256
+
1257
+ error_var: Number.isFinite(context.error) ? Math.round(context.error) : null,
1258
+
1259
+ correction_var: Number.isFinite(context.correction) ? Math.round(context.correction) : null,
1260
+
1261
+ noise_metric_var: Number.isFinite(context.noiseMetricVar) ? Math.round(context.noiseMetricVar) : null,
1262
+
1263
+ noise_stable: context.noiseStable,
1264
+
1265
+ noise_stable_duration_ms: context.noiseStableDurationMs,
1266
+
1267
+ settle_remaining_ms: context.settleRemainingMs,
1268
+
1269
+ auto_ramp_seconds: autoRampSeconds,
1270
+
1271
+ control_active: context.active,
1272
+
1273
+ ramp_direction: context.rampDirection,
1274
+
1275
+ clamped_by_min: context.clampedByMin,
1276
+
1277
+ clamped_by_max: context.clampedByMax,
1278
+
1279
+ measurement_age_ms: context.measurementAgeMs
1280
+
1281
+ });
1282
+
1283
+
1284
+ if (node.debugLevel === "normal") {
1285
+
1286
+ return normalPayload;
1287
+
1288
+ }
1289
+
1290
+
1291
+ return Object.assign({}, normalPayload, {
1292
+
1293
+ p_term_var: context.pTerm,
1294
+
1295
+ i_term_var: context.iTerm,
1296
+
1297
+ effective_kp: context.effectiveKp,
1298
+
1299
+ effective_ki: context.effectiveKi,
1300
+
1301
+ adaptive_gain_scale: context.gainsScale,
1302
+
1303
+ kp: node.kp,
1304
+
1305
+ ki: node.ki,
1306
+
1307
+ max_step_up_var: node.maxStepUpVar,
1308
+
1309
+ max_step_down_var: node.maxStepDownVar,
1310
+
1311
+ adaptive_gains: node.adaptiveGains,
1312
+
1313
+ adaptive_error_band_var: node.adaptiveErrorBandVar,
1314
+
1315
+ adaptive_min_scale: node.adaptiveMinScale,
1316
+
1317
+ control_period_ms: node.controlPeriodMs,
1318
+
1319
+ filter_tau_ms: node.filterTauMs,
1320
+
1321
+ debug_level: node.debugLevel
1322
+
1323
+ });
1324
+
1325
+ }
1326
+
1327
+
1328
+
1329
+ function evaluateFailSecure(context, nowMs) {
1330
+
1331
+ if (!node.failSecureEnabled) {
1332
+
1333
+ failSecureBreachStartMs = null;
1334
+
1335
+ failSecureActive = false;
1336
+
1337
+ return {
1338
+
1339
+ active: false,
1340
+
1341
+ durationMs: 0,
1342
+
1343
+ nominalReactivePowerVar: node.nominalReactivePowerVar,
1344
+
1345
+ siteLimitVar: Number(context && context.siteLimit)
1346
+
1347
+ };
1348
+
1349
+ }
1350
+
1351
+
1352
+ const siteLimitVar = Number(context && context.siteLimit);
1353
+
1354
+ const epsilon = 1e-6;
1355
+
1356
+ const eligible = node.mode !== "nominal_reactive_power" && Number.isFinite(siteLimitVar);
1357
+
1358
+ const violation = eligible && Math.abs(siteLimitVar) >= (node.nominalReactivePowerVar - epsilon);
1359
+
1360
+
1361
+ if (violation) {
1362
+
1363
+ if (!Number.isFinite(failSecureBreachStartMs)) {
1364
+
1365
+ failSecureBreachStartMs = nowMs;
1366
+
1367
+ }
1368
+
1369
+ const durationMs = Math.max(0, nowMs - failSecureBreachStartMs);
1370
+
1371
+ const triggerMs = Math.max(1000, Math.round(node.failSecureTimeSec * 1000));
1372
+
1373
+ failSecureActive = durationMs >= triggerMs;
1374
+
1375
+ return {
1376
+
1377
+ active: failSecureActive,
1378
+
1379
+ durationMs: durationMs,
1380
+
1381
+ nominalReactivePowerVar: node.nominalReactivePowerVar,
1382
+
1383
+ siteLimitVar: siteLimitVar
1384
+
1385
+ };
1386
+
1387
+ }
1388
+
1389
+
1390
+ failSecureBreachStartMs = null;
1391
+
1392
+ failSecureActive = false;
1393
+
1394
+ return {
1395
+
1396
+ active: false,
1397
+
1398
+ durationMs: 0,
1399
+
1400
+ nominalReactivePowerVar: node.nominalReactivePowerVar,
1401
+
1402
+ siteLimitVar: siteLimitVar
1403
+
1404
+ };
1405
+
1406
+ }
1407
+
1408
+
1409
+
1410
+ function emitStopZero(send, nowMs) {
1411
+
1412
+ const out = {
1413
+
1414
+ topic: node.outputTopic,
1415
+
1416
+ payload: buildOutputPayload({
1417
+
1418
+ siteLimit: 0,
1419
+
1420
+ rawReactiveReference: null,
1421
+
1422
+ filteredReference: null,
1423
+
1424
+ reactiveTargetVar: 0,
1425
+
1426
+ correction: 0,
1427
+
1428
+ error: 0,
1429
+
1430
+ pTerm: 0,
1431
+
1432
+ iTerm: 0,
1433
+
1434
+ active: false,
1435
+
1436
+ reason: "stop_zero",
1437
+
1438
+ controllerState: "stopped",
1439
+
1440
+ stale: false,
1441
+
1442
+ rampDirection: "hold",
1443
+
1444
+ clampedByMin: false,
1445
+
1446
+ clampedByMax: false,
1447
+
1448
+ measurementAgeMs: 0,
1449
+
1450
+ effectiveKp: node.kp,
1451
+
1452
+ effectiveKi: node.ki,
1453
+
1454
+ gainsScale: 1,
1455
+
1456
+ msgTimestampMs: nowMs
1457
+
1458
+ }),
1459
+
1460
+ controller: {
1461
+
1462
+ type: "control-reactive",
1463
+
1464
+ mode: node.mode,
1465
+
1466
+ reason: "stop_zero",
1467
+
1468
+ timestamp_ms: nowMs
1469
+
1470
+ }
1471
+
1472
+ };
1473
+
1474
+
1475
+ node.status({ fill: "red", shape: "dot", text: "AUTO Q STOP -> send Q=0" });
1476
+
1477
+ markCommandEmitted(0, nowMs);
1478
+
1479
+ send(out);
1480
+
1481
+ }
1482
+
1483
+
1484
+ function emitA3NominalCommand(nowMs, reason) {
1485
+ const command = a3Server ? a3Server.getCommand() : {
1486
+ nominalPower: node.fixedReactivePowerVar,
1487
+ rampType: node.a3RampType,
1488
+ rampValue: node.a3RampValue,
1489
+ sequence: 0
1490
+ };
1491
+ const siteLimit = clamp(command.nominalPower, -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
1492
+
1493
+ lastLimit = siteLimit;
1494
+ hasEmittedCommand = true;
1495
+ lastCommandSentAtMs = nowMs;
1496
+ persistLastLimit(lastLimit);
1497
+
1498
+ buildStatus({
1499
+ mode: "nominal_reactive_power",
1500
+ siteLimit: siteLimit,
1501
+ reactiveReference: lastRawReactiveReference,
1502
+ reactiveTargetVar: null,
1503
+ active: false,
1504
+ reason: reason || "a3_modbus_command",
1505
+ rampDirection: "hold",
1506
+ controllerState: "a3_nominal_mode"
1507
+ });
1508
+
1509
+ node.send({
1510
+ topic: node.outputTopic,
1511
+ payload: buildOutputPayload({
1512
+ siteLimit: siteLimit,
1513
+ rawReactiveReference: null,
1514
+ filteredReference: filteredReactiveReference,
1515
+ reactiveTargetVar: null,
1516
+ correction: 0,
1517
+ error: null,
1518
+ pTerm: 0,
1519
+ effectiveKp: node.kp,
1520
+ effectiveKi: node.ki,
1521
+ gainsScale: 1,
1522
+ iTerm: 0,
1523
+ active: false,
1524
+ reason: reason || "a3_modbus_command",
1525
+ controllerState: "a3_nominal_mode",
1526
+ stale: false,
1527
+ rampDirection: "hold",
1528
+ clampedByMin: siteLimit <= -node.nominalReactivePowerVar,
1529
+ clampedByMax: siteLimit >= node.nominalReactivePowerVar,
1530
+ noiseMetricVar: noiseMetricVar,
1531
+ noiseStable: true,
1532
+ noiseStableDurationMs: 0,
1533
+ settleRemainingMs: 0,
1534
+ measurementAgeMs: null,
1535
+ msgTimestampMs: nowMs
1536
+ })
1537
+ });
1538
+
1539
+ }
1540
+
1541
+
1542
+
1543
+ node.on("input", function (msg, send, done) {
1544
+
1545
+ try {
1546
+ const nowMs = Date.now();
1547
+
1548
+ const topic = String(msg && msg.topic || "").trim().toLowerCase();
1549
+
1550
+ if (topic !== "stop" && !validateRequiredGlobalValues()) {
1551
+
1552
+ done();
1553
+
1554
+ return;
1555
+
1556
+ }
1557
+
1558
+
1559
+ if (topic === "start") {
1560
+
1561
+ enabled = true;
1562
+
1563
+ resetControlState();
1564
+
1565
+ restoreStartLimitState(nowMs);
1566
+
1567
+ node.status({
1568
+
1569
+ fill: "green",
1570
+
1571
+ shape: "dot",
1572
+
1573
+ text: "AUTO Q START | limit " + Math.round(lastLimit || 0) + "VAr"
1574
+
1575
+ });
1576
+
1577
+ done();
1578
+
1579
+ return;
1580
+
1581
+ }
1582
+
1583
+
1584
+ if (topic === "stop") {
1585
+
1586
+ enabled = false;
1587
+
1588
+ resetControlState();
1589
+
1590
+ emitStopZero(send, nowMs);
1591
+
1592
+ done();
1593
+
1594
+ return;
1595
+
1596
+ }
1597
+
1598
+ refreshNominalReactivePowerFromGlobal();
1599
+
1600
+
1601
+ if (!enabled) {
1602
+
1603
+ node.status({ fill: "grey", shape: "ring", text: "AUTO disabled -> idle" });
1604
+
1605
+ done();
1606
+
1607
+ return;
1608
+
1609
+ }
1610
+
1611
+
1612
+ const msgTimestampMs = extractTimestampMs(msg);
1613
+
1614
+ if ((msgTimestampMs - nowMs) > node.maxFutureTimestampMs) {
1615
+ throw new Error("Timestamp din viitor peste limita admisa");
1616
+ }
1617
+
1618
+ const stale = isStale(msgTimestampMs, nowMs);
1619
+
1620
+ if (!stale && Number.isFinite(lastAcceptedMeasurementTs) && msgTimestampMs < lastAcceptedMeasurementTs) {
1621
+ node.status({ fill: "yellow", shape: "ring", text: "ignored older telemetry" });
1622
+ done();
1623
+ return;
1624
+ }
1625
+
1626
+ const controlDue = !hasEmittedCommand || (nowMs - lastCommandSentAtMs) >= node.minCommandIntervalMs;
1627
+
1628
+
1629
+ let siteLimit;
1630
+
1631
+ let reactiveReference = lastRawReactiveReference;
1632
+
1633
+ let filteredReference = filteredReactiveReference;
1634
+
1635
+ let reactiveTargetVar = null;
1636
+
1637
+ let correction = null;
1638
+
1639
+ let error = null;
1640
+
1641
+ let pTerm = null;
1642
+
1643
+ let effectiveKp = null;
1644
+
1645
+ let effectiveKi = null;
1646
+
1647
+ let gainsScale = null;
1648
+
1649
+ let iTerm = integratorState;
1650
+
1651
+ let active = false;
1652
+
1653
+ let reason = "nominal";
1654
+
1655
+ let rampDirection = "hold";
1656
+
1657
+ let controllerState = node.mode === "nominal_reactive_power" ? "nominal_mode" : "waiting_measurement";
1658
+
1659
+ let clampedByMin = false;
1660
+
1661
+ let clampedByMax = false;
1662
+
1663
+ let rawReactiveReference = null;
1664
+
1665
+ let noiseStable = true;
1666
+
1667
+ let noiseStableDurationMs = 0;
1668
+
1669
+ let settleRemainingMs = Math.max(0, lastCommandSentAtMs > 0 ? (node.minCommandIntervalMs - (nowMs - lastCommandSentAtMs)) : 0);
1670
+
1671
+
1672
+ if (node.mode === "nominal_reactive_power") {
1673
+
1674
+ outsideDeadbandSinceMs = null;
1675
+
1676
+ siteLimit = clamp(readFixedReactivePowerVar(), -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
1677
+
1678
+ reason = "nominal";
1679
+
1680
+ integratorState = 0;
1681
+
1682
+ } else if (stale) {
1683
+
1684
+ outsideDeadbandSinceMs = null;
1685
+
1686
+ siteLimit = clamp(node.safeLimitOnStale, -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
1687
+
1688
+ reason = "stale";
1689
+
1690
+ controllerState = "stale_fallback";
1691
+
1692
+ integratorState = 0;
1693
+
1694
+ } else {
1695
+
1696
+ const reactiveInput = extractControlReactiveReference(msg);
1697
+
1698
+ if (!reactiveInput.ok || !Number.isFinite(reactiveInput.reference)) {
1699
+
1700
+ const missingList = reactiveInput && Array.isArray(reactiveInput.missing)
1701
+
1702
+ ? reactiveInput.missing.join(", ")
1703
+
1704
+ : "reactive_l1_var, reactive_l2_var, reactive_l3_var, reactive_total_var";
1705
+
1706
+ throw new Error("Lipsesc valori reactive meter: " + missingList);
1707
+
1708
+ }
1709
+
1710
+ rawReactiveReference = reactiveInput.reference;
1711
+ lastInputAtMs = nowMs;
1712
+ watchdogSafeEmitted = false;
1713
+
1714
+
1715
+ reactiveReference = rawReactiveReference;
1716
+
1717
+ filteredReference = updateFilter(reactiveReference, msgTimestampMs);
1718
+
1719
+ noiseMetricVar = 0;
1720
+
1721
+ noiseStable = true;
1722
+
1723
+ noiseStableDurationMs = 0;
1724
+
1725
+ lastRawReactiveReference = reactiveReference;
1726
+
1727
+ lastMsgTimestampMs = msgTimestampMs;
1728
+
1729
+ lastAcceptedMeasurementTs = msgTimestampMs;
1730
+
1731
+
1732
+ if (controlDue) {
1733
+
1734
+ const result = computeClosedLoopLimit(filteredReference);
1735
+
1736
+
1737
+ siteLimit = result.siteLimit;
1738
+
1739
+ reactiveTargetVar = result.reactiveTargetVar;
1740
+
1741
+ correction = result.correction;
1742
+
1743
+ error = result.error;
1744
+
1745
+ pTerm = result.pTerm;
1746
+
1747
+ effectiveKp = result.effectiveKp;
1748
+
1749
+ effectiveKi = result.effectiveKi;
1750
+
1751
+ gainsScale = result.gainsScale;
1752
+
1753
+ iTerm = result.iTerm;
1754
+
1755
+ active = result.active;
1756
+
1757
+ reason = result.reason;
1758
+
1759
+ rampDirection = result.rampDirection;
1760
+
1761
+ controllerState = result.controllerState;
1762
+
1763
+ clampedByMin = result.clampedByMin;
1764
+
1765
+ clampedByMax = result.clampedByMax;
1766
+
1767
+ } else {
1768
+
1769
+ siteLimit = Number.isFinite(lastLimit) ? lastLimit : 0;
1770
+
1771
+ reactiveTargetVar = getReactiveTargetVar();
1772
+
1773
+ error = Number.isFinite(filteredReference) ? (filteredReference - reactiveTargetVar) : null;
1774
+
1775
+ pTerm = Number.isFinite(error) ? node.kp * error : null;
1776
+
1777
+ effectiveKp = node.kp;
1778
+
1779
+ effectiveKi = node.ki;
1780
+
1781
+ gainsScale = 1;
1782
+
1783
+ iTerm = integratorState;
1784
+
1785
+ correction = 0;
1786
+
1787
+ active = false;
1788
+
1789
+ rampDirection = "hold";
1790
+
1791
+ reason = "hold_period";
1792
+
1793
+ controllerState = "hold_period";
1794
+
1795
+ }
1796
+
1797
+ }
1798
+
1799
+
1800
+ siteLimit = clamp(siteLimit, -node.nominalReactivePowerVar, node.nominalReactivePowerVar);
1801
+
1802
+
1803
+ const effectiveSiteLimit = Number.isFinite(lastLimit) ? Number(lastLimit) : 0;
1804
+
1805
+ const shouldEmitMain = controlDue && shouldEmitCommand(siteLimit, reason, nowMs);
1806
+
1807
+
1808
+ if (!shouldEmitMain && reason === "hold_period") {
1809
+
1810
+ controllerState = "waiting_command_window";
1811
+
1812
+ }
1813
+
1814
+
1815
+ buildStatus({
1816
+
1817
+ mode: node.mode,
1818
+
1819
+ siteLimit: shouldEmitMain ? siteLimit : effectiveSiteLimit,
1820
+
1821
+ reactiveReference: filteredReference !== null ? filteredReference : reactiveReference,
1822
+
1823
+ reactiveTargetVar: reactiveTargetVar,
1824
+
1825
+ active: active,
1826
+
1827
+ reason: reason,
1828
+
1829
+ rampDirection: rampDirection,
1830
+
1831
+ controllerState: controllerState
1832
+
1833
+ });
1834
+
1835
+
1836
+ const measurementAgeMs = Number.isFinite(lastMsgTimestampMs) ? Math.max(0, nowMs - lastMsgTimestampMs) : null;
1837
+
1838
+
1839
+ const mainMsg = {
1840
+
1841
+ topic: node.outputTopic,
1842
+
1843
+ payload: buildOutputPayload({
1844
+
1845
+ siteLimit: siteLimit,
1846
+
1847
+ rawReactiveReference: rawReactiveReference,
1848
+
1849
+ filteredReference: filteredReference,
1850
+
1851
+ reactiveTargetVar: reactiveTargetVar,
1852
+
1853
+ correction: correction,
1854
+
1855
+ error: error,
1856
+
1857
+ pTerm: pTerm,
1858
+
1859
+ effectiveKp: effectiveKp,
1860
+
1861
+ effectiveKi: effectiveKi,
1862
+
1863
+ gainsScale: gainsScale,
1864
+
1865
+ iTerm: iTerm,
1866
+
1867
+ active: active,
1868
+
1869
+ reason: reason,
1870
+
1871
+ controllerState: controllerState,
1872
+
1873
+ stale: stale,
1874
+
1875
+ rampDirection: rampDirection,
1876
+
1877
+ clampedByMin: clampedByMin,
1878
+
1879
+ clampedByMax: clampedByMax,
1880
+
1881
+ noiseMetricVar: noiseMetricVar,
1882
+
1883
+ noiseStable: noiseStable,
1884
+
1885
+ noiseStableDurationMs: noiseStableDurationMs,
1886
+
1887
+ settleRemainingMs: settleRemainingMs,
1888
+
1889
+ measurementAgeMs: measurementAgeMs,
1890
+
1891
+ msgTimestampMs: msgTimestampMs
1892
+
1893
+ }),
1894
+
1895
+ controller: {
1896
+
1897
+ type: "control-reactive",
1898
+
1899
+ mode: node.mode,
1900
+
1901
+ zero_feed_in_mode: node.zeroFeedInMode,
1902
+
1903
+ reason: reason,
1904
+
1905
+ q_avg_var: Number.isFinite(filteredReference) ? Math.round(filteredReference) : null,
1906
+
1907
+ grid_target_var: Number.isFinite(reactiveTargetVar) ? Math.round(reactiveTargetVar) : null,
1908
+
1909
+ error_var: Number.isFinite(error) ? Math.round(error) : null,
1910
+
1911
+ kp: node.kp,
1912
+
1913
+ ki: node.ki,
1914
+
1915
+ deadband_var: node.deadbandVar,
1916
+
1917
+ filter_tau_ms: node.filterTauMs,
1918
+
1919
+ control_period_ms: node.controlPeriodMs,
1920
+
1921
+ max_step_up_var: node.maxStepUpVar,
1922
+
1923
+ max_step_down_var: node.maxStepDownVar,
1924
+
1925
+ noise_metric_var: Math.round(noiseMetricVar),
1926
+
1927
+ noise_threshold_var: node.noiseThresholdVar,
1928
+
1929
+ noise_release_ms: node.noiseReleaseMs,
1930
+
1931
+ noise_stable: noiseStable,
1932
+
1933
+ noise_stable_duration_ms: noiseStableDurationMs,
1934
+
1935
+ settle_after_command_ms: node.minCommandIntervalMs,
1936
+
1937
+ settle_remaining_ms: settleRemainingMs,
1938
+
1939
+ nominal_reactive_power_var: node.nominalReactivePowerVar,
1940
+
1941
+ fixed_reactive_power_var: node.fixedReactivePowerVar,
1942
+
1943
+ timestamp_ms: msgTimestampMs
1944
+
1945
+ }
1946
+
1947
+ };
1948
+
1949
+
1950
+ const failSecureState = evaluateFailSecure({
1951
+
1952
+ stale: stale,
1953
+
1954
+ error: error,
1955
+
1956
+ siteLimit: shouldEmitMain ? siteLimit : effectiveSiteLimit
1957
+
1958
+ }, nowMs);
1959
+
1960
+
1961
+ const failSecureStateChanged = failSecureState.active !== lastFailSecureOutputActive;
1962
+
1963
+ let failMsg = null;
1964
+
1965
+
1966
+ if (node.failSecureEnabled && failSecureState.active) {
1967
+
1968
+ failMsg = {
1969
+
1970
+ topic: "control-reactive/fail-secure",
1971
+
1972
+ payload: 1,
1973
+
1974
+ fail_secure: {
1975
+
1976
+ active: true,
1977
+
1978
+ duration_ms: failSecureState.durationMs,
1979
+
1980
+ nominal_reactive_power_var: failSecureState.nominalReactivePowerVar,
1981
+
1982
+ site_limit_var: failSecureState.siteLimitVar,
1983
+
1984
+ timeout_s: node.failSecureTimeSec,
1985
+
1986
+ timestamp_ms: nowMs
1987
+
1988
+ }
1989
+
1990
+ };
1991
+
1992
+ }
1993
+
1994
+
1995
+ lastFailSecureOutputActive = failSecureState.active;
1996
+
1997
+
1998
+ if (shouldEmitMain) {
1999
+
2000
+ markCommandEmitted(siteLimit, nowMs);
2001
+
2002
+ }
2003
+
2004
+
2005
+ if (node.failSecureEnabled) {
2006
+
2007
+ if (shouldEmitMain) {
2008
+
2009
+ send([mainMsg, failMsg]);
2010
+
2011
+ } else if (failSecureStateChanged && failMsg) {
2012
+
2013
+ send([null, failMsg]);
2014
+
2015
+ }
2016
+
2017
+ } else if (shouldEmitMain) {
2018
+
2019
+ send(mainMsg);
2020
+
2021
+ }
2022
+
2023
+
2024
+ done();
2025
+
2026
+ } catch (err) {
2027
+
2028
+ node.status({ fill: "red", shape: "ring", text: err.message || "error" });
2029
+
2030
+ done(err);
2031
+
2032
+ }
2033
+
2034
+ });
2035
+
2036
+
2037
+
2038
+ if (node.staleTimeoutMs > 0 && node.mode !== "nominal_reactive_power") {
2039
+ watchdogTimer = setInterval(function () {
2040
+ const nowMs = Date.now();
2041
+ if (!enabled || watchdogSafeEmitted || !Number.isFinite(lastInputAtMs) || (nowMs - lastInputAtMs) <= node.staleTimeoutMs) {
2042
+ return;
2043
+ }
2044
+
2045
+ if (!validateRequiredGlobalValues()) return;
2046
+
2047
+ watchdogSafeEmitted = true;
2048
+ integratorState = 0;
2049
+ refreshNominalReactivePowerFromGlobal();
2050
+ lastLimit = 0;
2051
+ emitStopZero(function (message) {
2052
+ node.send(message);
2053
+ }, nowMs);
2054
+ }, Math.max(250, Math.min(node.staleTimeoutMs, 1000)));
2055
+ }
2056
+
2057
+ if (node.a3TestsEnabled) {
2058
+ a3Server = createA3TestServer({
2059
+ host: node.a3ModbusHost,
2060
+ port: node.a3ModbusPort,
2061
+ unitId: node.a3ModbusUnitId,
2062
+ nominalPower: node.fixedReactivePowerVar,
2063
+ minNominal: -node.nominalReactivePowerVar,
2064
+ maxNominal: node.nominalReactivePowerVar,
2065
+ rampType: node.a3RampType,
2066
+ rampValue: node.a3RampValue,
2067
+ onCommand: function () {
2068
+ emitA3NominalCommand(Date.now(), "a3_modbus_command");
2069
+ },
2070
+ onError: function (err) {
2071
+ node.warn("control-reactive A3 Modbus: " + err.message);
2072
+ },
2073
+ onProtocolIssue: function (issue) {
2074
+ if (node.debugLevel !== "off") {
2075
+ node.warn("control-reactive A3 Modbus issue: " + issue.code);
2076
+ }
2077
+ }
2078
+ });
2079
+
2080
+ a3Server.start().then(function () {
2081
+ node.status({
2082
+ fill: "blue",
2083
+ shape: "dot",
2084
+ text: "A3 Modbus " + node.a3ModbusHost + ":" + node.a3ModbusPort
2085
+ });
2086
+ emitA3NominalCommand(Date.now(), "a3_start");
2087
+ }).catch(function (err) {
2088
+ node.status({ fill: "red", shape: "ring", text: "A3 Modbus: " + err.message });
2089
+ node.error("control-reactive A3 Modbus: " + err.message);
2090
+ });
2091
+ }
2092
+
2093
+ node.on("close", function(done) {
2094
+ if (watchdogTimer) {
2095
+ clearInterval(watchdogTimer);
2096
+ watchdogTimer = null;
2097
+ }
2098
+
2099
+ persistLastLimit(lastLimit);
2100
+
2101
+ if (a3Server) {
2102
+ const server = a3Server;
2103
+ a3Server = null;
2104
+ server.stop().finally(function () {
2105
+ if (typeof done === "function") done();
2106
+ });
2107
+ return;
2108
+ }
2109
+
2110
+ if (typeof done === "function") done();
2111
+
2112
+ });
2113
+
2114
+
2115
+ });
2116
+ }
2117
+
2118
+
2119
+
2120
+ RED.nodes.registerType("control-reactive", ReactiveControlNode);
2121
+
2122
+ RED.nodes.registerType("reactive-control", ReactiveControlNode);
2123
+
2124
+ };