@atelieruldetehnologii/node-red-custome-solutions 0.62.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +56 -0
- package/PROPRIETARY-LICENSE.txt +157 -0
- package/README.md +105 -0
- package/TERMS-AND-CONDITIONS.md +311 -0
- package/advantech-read/advantech-read.html +775 -0
- package/advantech-read/advantech-read.js +449 -0
- package/advantech-write/advantech-write.html +413 -0
- package/advantech-write/advantech-write.js +338 -0
- package/bess-control/bess-control.html +569 -0
- package/bess-control/bess-control.js +541 -0
- package/bess-control/lib/allocation.js +149 -0
- package/bess-control/lib/batteries.js +86 -0
- package/bess-control/lib/helpers.js +157 -0
- package/bess-control/lib/messages.js +131 -0
- package/bess-control/lib/runtime.js +156 -0
- package/carlo-gavazzi/carlo-gavazzi.html +4327 -0
- package/carlo-gavazzi/carlo-gavazzi.js +3081 -0
- package/control-export/control-export.html +3644 -0
- package/control-export/control-export.js +2192 -0
- package/control-reactive/control-reactive.html +3136 -0
- package/control-reactive/control-reactive.js +2124 -0
- package/fronius-emulator/README.md +258 -0
- package/fronius-emulator/fronius-emulator.html +170 -0
- package/fronius-emulator/fronius-emulator.js +520 -0
- package/fronius-emulator/lib/binary-codec.js +65 -0
- package/fronius-emulator/lib/fronius-register-map.js +185 -0
- package/fronius-emulator/lib/modbus-parser.js +61 -0
- package/fronius-emulator/lib/modbus-tcp-server.js +224 -0
- package/fronius-emulator/lib/validation.js +206 -0
- package/fronius-emulator/lib/virtual-meter.js +132 -0
- package/fronius-read/fronius-read.html +3944 -0
- package/fronius-read/fronius-read.js +1466 -0
- package/fronius-read/lib/inverter-profiles.js +711 -0
- package/fronius-read/lib/sunspec-core.js +303 -0
- package/fronius-read/lib/sunspec-model-parsers.js +845 -0
- package/fronius-write/fronius-write.html +1789 -0
- package/fronius-write/fronius-write.js +2531 -0
- package/influxdb/influxdb.html +1077 -0
- package/influxdb/influxdb.js +1487 -0
- package/janitza/janitza.html +2562 -0
- package/janitza/janitza.js +1500 -0
- package/janitza/lib/janitza-profiles.js +371 -0
- package/lib/a3-test-server.js +199 -0
- package/lib/adam-6000-profiles.js +173 -0
- package/lib/licensing/constants.js +103 -0
- package/lib/licensing/device-id.js +251 -0
- package/lib/licensing/errors.js +21 -0
- package/lib/licensing/license-client.js +377 -0
- package/lib/licensing/license-state.js +50 -0
- package/lib/licensing/node-guard.js +150 -0
- package/lib/licensing/package-integrity.js +167 -0
- package/lib/licensing/package-integrity.json +513 -0
- package/lib/licensing/public-key.pem +3 -0
- package/lib/licensing/public-key.pem.example +2 -0
- package/lib/licensing/public-keys.js +18 -0
- package/lib/licensing/storage.js +63 -0
- package/lib/licensing/token-verifier.js +84 -0
- package/lib/numbers.js +26 -0
- package/license/atelier-license.html +88 -0
- package/license/atelier-license.js +170 -0
- package/modbus-tcp-discover/modbus-tcp-discover.html +190 -0
- package/modbus-tcp-discover/modbus-tcp-discover.js +706 -0
- package/multi-battery/multi-battery.html +649 -0
- package/multi-battery/multi-battery.js +668 -0
- package/multi-grid/multi-grid.html +745 -0
- package/multi-grid/multi-grid.js +1392 -0
- package/multi-inverter/lib/engine-core.js +237 -0
- package/multi-inverter/multi-inverter.html +2674 -0
- package/multi-inverter/multi-inverter.js +1393 -0
- package/package.json +109 -0
- package/phoenix-contact/lib/eem-ma370-advanced.js +175 -0
- package/phoenix-contact/lib/eem-ma370.js +138 -0
- package/phoenix-contact/lib/integrations.js +149 -0
- package/phoenix-contact/lib/phoenix-contact-profile.js +35030 -0
- package/phoenix-contact/phoenix-contact.html +840 -0
- package/phoenix-contact/phoenix-contact.js +679 -0
- package/pv-generator/pv-generator.html +284 -0
- package/pv-generator/pv-generator.js +387 -0
- package/pylontech-read/pylontech-read.html +763 -0
- package/pylontech-read/pylontech-read.js +518 -0
- package/pylontech-write/pylontech-write.html +405 -0
- package/pylontech-write/pylontech-write.js +546 -0
- package/scada-aggregator/lib/aggregate.js +72 -0
- package/scada-aggregator/lib/groups.js +39 -0
- package/scada-aggregator/scada-aggregator.html +108 -0
- package/scada-aggregator/scada-aggregator.js +117 -0
- package/scada-server/lib/aggregator-map.js +58 -0
- package/scada-server/lib/encoding.js +235 -0
- package/scada-server/lib/modbus-server.js +492 -0
- package/scada-server/lib/object-path.js +18 -0
- package/scada-server/lib/register-map.js +288 -0
- package/scada-server/scada-server.html +1419 -0
- package/scada-server/scada-server.js +704 -0
- package/scripts/check-syntax.js +66 -0
- package/scripts/package-integrity.js +46 -0
- package/scripts/package-release.js +116 -0
- package/scripts/run-tests.js +2178 -0
- package/scripts/tgz-integrity.js +74 -0
- package/sma-read/lib/sunspec-core.js +83 -0
- package/sma-read/lib/sunspec-model-parsers.js +853 -0
- package/sma-read/sma-read.html +659 -0
- package/sma-read/sma-read.js +458 -0
- package/sma-write/sma-write.html +105 -0
- package/sma-write/sma-write.js +270 -0
- package/victron-read/lib/mqtt-state.js +176 -0
- package/victron-read/victron-read.html +1074 -0
- package/victron-read/victron-read.js +1222 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
|
|
3
|
+
function parseEditorTimeHours(value) {
|
|
4
|
+
if (value === null || value === undefined) return NaN;
|
|
5
|
+
const text = String(value).trim();
|
|
6
|
+
if (!text) return NaN;
|
|
7
|
+
|
|
8
|
+
const m = text.match(/^(\d{1,2}):(\d{1,2})$/);
|
|
9
|
+
if (m) {
|
|
10
|
+
const hh = Number(m[1]);
|
|
11
|
+
const mm = Number(m[2]);
|
|
12
|
+
if (Number.isFinite(hh) && Number.isFinite(mm) && hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) {
|
|
13
|
+
return hh + (mm / 60);
|
|
14
|
+
}
|
|
15
|
+
return NaN;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fallback = Number(text.replace(",", "."));
|
|
19
|
+
return Number.isFinite(fallback) ? fallback : NaN;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function hoursToHHMM(hours, fallback) {
|
|
23
|
+
const n = Number(hours);
|
|
24
|
+
if (!Number.isFinite(n)) return fallback;
|
|
25
|
+
|
|
26
|
+
const clamped = Math.min(Math.max(n, 0), 23.999);
|
|
27
|
+
let hh = Math.floor(clamped);
|
|
28
|
+
let mm = Math.round((clamped - hh) * 60);
|
|
29
|
+
|
|
30
|
+
if (mm >= 60) {
|
|
31
|
+
mm = 0;
|
|
32
|
+
hh += 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (hh > 23) hh = 23;
|
|
36
|
+
|
|
37
|
+
return String(hh).padStart(2, "0") + ":" + String(mm).padStart(2, "0");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
RED.nodes.registerType("pv-generator", {
|
|
41
|
+
|
|
42
|
+
category: "Atelierul de Tehnologii",
|
|
43
|
+
|
|
44
|
+
color: "#f4a261",
|
|
45
|
+
|
|
46
|
+
defaults: {
|
|
47
|
+
license: { value: "", type: "atelier-license", required: true },
|
|
48
|
+
name: { value: "" },
|
|
49
|
+
outputTopic: { value: "pv-simulator" },
|
|
50
|
+
ratedPowerW: {
|
|
51
|
+
value: 10000,
|
|
52
|
+
validate: function (v) {
|
|
53
|
+
return Number(v) >= 0;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
profile: { value: "sine" },
|
|
57
|
+
sunriseHour: {
|
|
58
|
+
value: "06:00",
|
|
59
|
+
validate: function (v) {
|
|
60
|
+
const n = parseEditorTimeHours(v);
|
|
61
|
+
if (!Number.isFinite(n) || n < 0 || n >= 24) return false;
|
|
62
|
+
|
|
63
|
+
const sunsetRaw = $("#node-input-sunsetHour").val();
|
|
64
|
+
if (sunsetRaw === undefined) return true;
|
|
65
|
+
|
|
66
|
+
const sunset = parseEditorTimeHours(sunsetRaw);
|
|
67
|
+
return !Number.isFinite(sunset) || sunset > n;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
sunsetHour: {
|
|
71
|
+
value: "20:00",
|
|
72
|
+
validate: function (v) {
|
|
73
|
+
const n = parseEditorTimeHours(v);
|
|
74
|
+
if (!Number.isFinite(n) || n <= 0 || n > 24) return false;
|
|
75
|
+
|
|
76
|
+
const sunriseRaw = $("#node-input-sunriseHour").val();
|
|
77
|
+
if (sunriseRaw === undefined) return true;
|
|
78
|
+
|
|
79
|
+
const sunrise = parseEditorTimeHours(sunriseRaw);
|
|
80
|
+
return !Number.isFinite(sunrise) || n > sunrise;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
cloudinessPct: {
|
|
84
|
+
value: 10,
|
|
85
|
+
validate: function (v) {
|
|
86
|
+
const n = Number(v);
|
|
87
|
+
return Number.isFinite(n) && n >= 0 && n <= 100;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
randomNoisePct: {
|
|
91
|
+
value: 3,
|
|
92
|
+
validate: function (v) {
|
|
93
|
+
const n = Number(v);
|
|
94
|
+
return Number.isFinite(n) && n >= 0 && n <= 100;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
limitW: {
|
|
98
|
+
value: "",
|
|
99
|
+
validate: function (v) {
|
|
100
|
+
if (v === "" || v === null || v === undefined) return true;
|
|
101
|
+
const n = Number(v);
|
|
102
|
+
return Number.isFinite(n) && n >= 0;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
sampleIntervalSec: {
|
|
106
|
+
value: 5,
|
|
107
|
+
validate: function (v) {
|
|
108
|
+
const n = Number(v);
|
|
109
|
+
return Number.isFinite(n) && n >= 0.25;
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
enabled: { value: true },
|
|
113
|
+
autostart: { value: true }
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
inputs: 1,
|
|
117
|
+
|
|
118
|
+
outputs: 1,
|
|
119
|
+
|
|
120
|
+
icon: "font-awesome/fa-sun-o",
|
|
121
|
+
|
|
122
|
+
label: function () {
|
|
123
|
+
return this.name || "pv-generator";
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
oneditprepare: function () {
|
|
127
|
+
const sunriseCurrent = parseEditorTimeHours($("#node-input-sunriseHour").val());
|
|
128
|
+
const sunsetCurrent = parseEditorTimeHours($("#node-input-sunsetHour").val());
|
|
129
|
+
|
|
130
|
+
if (Number.isFinite(sunriseCurrent)) {
|
|
131
|
+
$("#node-input-sunriseHour").val(hoursToHHMM(sunriseCurrent, "06:00"));
|
|
132
|
+
}
|
|
133
|
+
if (Number.isFinite(sunsetCurrent)) {
|
|
134
|
+
$("#node-input-sunsetHour").val(hoursToHHMM(sunsetCurrent, "20:00"));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function showHint() {
|
|
138
|
+
const sunrise = parseEditorTimeHours($("#node-input-sunriseHour").val());
|
|
139
|
+
const sunset = parseEditorTimeHours($("#node-input-sunsetHour").val());
|
|
140
|
+
|
|
141
|
+
if (Number.isFinite(sunrise) && Number.isFinite(sunset) && sunset <= sunrise) {
|
|
142
|
+
$("#pv-generator-validation")
|
|
143
|
+
.text("Sunset Hour trebuie sa fie mai mare decat Sunrise Hour.")
|
|
144
|
+
.show();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$("#pv-generator-validation").hide().text("");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
$("#node-input-sunriseHour, #node-input-sunsetHour")
|
|
152
|
+
.off(".pvgen")
|
|
153
|
+
.on("change.pvgen keyup.pvgen", showHint);
|
|
154
|
+
|
|
155
|
+
showHint();
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
oneditsave: function () {
|
|
159
|
+
const limitRaw = String($("#node-input-limitW").val() || "").trim();
|
|
160
|
+
if (!limitRaw) {
|
|
161
|
+
$("#node-input-limitW").val("");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sunrise = parseEditorTimeHours($("#node-input-sunriseHour").val());
|
|
165
|
+
const sunset = parseEditorTimeHours($("#node-input-sunsetHour").val());
|
|
166
|
+
|
|
167
|
+
$("#node-input-sunriseHour").val(hoursToHHMM(sunrise, "06:00"));
|
|
168
|
+
$("#node-input-sunsetHour").val(hoursToHHMM(sunset, "20:00"));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<script type="text/html" data-template-name="pv-generator">
|
|
174
|
+
<div class="form-row"><label for="node-input-license"><i class="fa fa-key"></i> License</label><input type="text" id="node-input-license"></div>
|
|
175
|
+
|
|
176
|
+
<div class="form-row">
|
|
177
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
178
|
+
<input type="text" id="node-input-name" placeholder="PV Generator">
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="form-row">
|
|
182
|
+
<label for="node-input-outputTopic"><i class="fa fa-bookmark"></i> Output Topic</label>
|
|
183
|
+
<input type="text" id="node-input-outputTopic" placeholder="pv-simulator">
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div class="form-row">
|
|
187
|
+
<label for="node-input-ratedPowerW"><i class="fa fa-bolt"></i> Rated Power (W)</label>
|
|
188
|
+
<input type="number" id="node-input-ratedPowerW" min="0" step="1">
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="form-row">
|
|
192
|
+
<label for="node-input-profile"><i class="fa fa-line-chart"></i> Profile</label>
|
|
193
|
+
<select id="node-input-profile">
|
|
194
|
+
<option value="sine">Sine (clasic)</option>
|
|
195
|
+
<option value="bell">Bell (nor variabil)</option>
|
|
196
|
+
<option value="plateau">Plateau (pranz stabil)</option>
|
|
197
|
+
</select>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="form-row">
|
|
201
|
+
<label for="node-input-sunriseHour"><i class="fa fa-sun-o"></i> Sunrise (hh:mm)</label>
|
|
202
|
+
<input type="time" id="node-input-sunriseHour" step="60">
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="form-row">
|
|
206
|
+
<label for="node-input-sunsetHour"><i class="fa fa-moon-o"></i> Sunset (hh:mm)</label>
|
|
207
|
+
<input type="time" id="node-input-sunsetHour" step="60">
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div class="form-row">
|
|
211
|
+
<label for="node-input-cloudinessPct"><i class="fa fa-cloud"></i> Cloudiness (%)</label>
|
|
212
|
+
<input type="number" id="node-input-cloudinessPct" min="0" max="100" step="1">
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div class="form-row">
|
|
216
|
+
<label for="node-input-randomNoisePct"><i class="fa fa-random"></i> Noise (%)</label>
|
|
217
|
+
<input type="number" id="node-input-randomNoisePct" min="0" max="100" step="0.1">
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<div class="form-row">
|
|
221
|
+
<label for="node-input-limitW"><i class="fa fa-sliders"></i> Limit (W)</label>
|
|
222
|
+
<input type="number" id="node-input-limitW" min="0" step="1" placeholder="gol = fara limitare">
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div class="form-row">
|
|
226
|
+
<label for="node-input-sampleIntervalSec"><i class="fa fa-clock-o"></i> Sample Interval (s)</label>
|
|
227
|
+
<input type="number" id="node-input-sampleIntervalSec" min="0.25" step="0.25">
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div class="form-row">
|
|
231
|
+
<label for="node-input-enabled"><i class="fa fa-power-off"></i> Enabled</label>
|
|
232
|
+
<input type="checkbox" id="node-input-enabled" style="width:auto; margin-left:8px;">
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="form-row">
|
|
236
|
+
<label for="node-input-autostart"><i class="fa fa-play"></i> Auto Start Timer</label>
|
|
237
|
+
<input type="checkbox" id="node-input-autostart" style="width:auto; margin-left:8px;">
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<div class="form-row" id="pv-generator-validation" style="display:none; color:#c0392b; font-weight:600;"></div>
|
|
241
|
+
|
|
242
|
+
</script>
|
|
243
|
+
|
|
244
|
+
<script type="text/html" data-help-name="pv-generator">
|
|
245
|
+
<p>Simulator invertor PV care genereaza productie in W, cu posibilitate de limitare.</p>
|
|
246
|
+
<p>Output:</p>
|
|
247
|
+
<ul>
|
|
248
|
+
<li><code>msg.payload</code>: productie PV efectiva (W)</li>
|
|
249
|
+
<li><code>msg.pv</code>: detalii complete (raw power, limit, curtailment, profil)</li>
|
|
250
|
+
</ul>
|
|
251
|
+
<p>Control dinamic prin input (object in <code>msg.payload</code> sau campuri in <code>msg</code>):</p>
|
|
252
|
+
<ul>
|
|
253
|
+
<li><code>limit_w</code> / <code>limitW</code>: limiteaza puterea la o valoare in W</li>
|
|
254
|
+
<li><code>rated_power_w</code>: schimba puterea nominala a invertorului</li>
|
|
255
|
+
<li><code>sunrise_hour</code>, <code>sunset_hour</code>: accepta <code>hh:mm</code> (ex: <code>06:15</code>) sau numeric</li>
|
|
256
|
+
<li><code>cloudiness_pct</code>: modifica gradul de innorare (0-100)</li>
|
|
257
|
+
<li><code>profile</code>: <code>sine</code>, <code>bell</code> sau <code>plateau</code></li>
|
|
258
|
+
<li><code>sample_interval_sec</code>: modifica perioada de esantionare</li>
|
|
259
|
+
<li><code>enabled</code>: porneste/opreste simularea</li>
|
|
260
|
+
</ul>
|
|
261
|
+
<p>Comenzi rapide in <code>msg.command</code> sau <code>msg.topic</code>: <code>start</code>, <code>stop</code>, <code>tick</code>.</p>
|
|
262
|
+
|
|
263
|
+
<h3>Referinta completa parametri</h3>
|
|
264
|
+
<ul>
|
|
265
|
+
<li><code>outputTopic</code>: topic-ul mesajelor emise.</li>
|
|
266
|
+
<li><code>ratedPowerW</code>: puterea nominala maxima a simulatorului.</li>
|
|
267
|
+
<li><code>profile</code>: forma curbei de productie: <code>sine</code>, <code>bell</code> sau <code>plateau</code>.</li>
|
|
268
|
+
<li><code>sunriseHour</code> si <code>sunsetHour</code>: intervalul zilnic in care se genereaza productie.</li>
|
|
269
|
+
<li><code>cloudinessPct</code>: reducerea determinista a productiei, 0..100%.</li>
|
|
270
|
+
<li><code>randomNoisePct</code>: variatie aleatoare aplicata productiei.</li>
|
|
271
|
+
<li><code>limitW</code>: limitare optionala a productiei simulate.</li>
|
|
272
|
+
<li><code>sampleIntervalSec</code>: perioada timerului intern.</li>
|
|
273
|
+
<li><code>enabled</code>: permite sau blocheaza generarea.</li>
|
|
274
|
+
<li><code>autostart</code>: porneste timerul automat dupa deploy.</li>
|
|
275
|
+
</ul>
|
|
276
|
+
|
|
277
|
+
<h3>Exemplu limitare dinamica</h3>
|
|
278
|
+
<pre>{
|
|
279
|
+
"payload": {
|
|
280
|
+
"limit_w": 5000,
|
|
281
|
+
"cloudiness_pct": 20
|
|
282
|
+
}
|
|
283
|
+
}</pre>
|
|
284
|
+
</script>
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
module.exports = function (RED) {
|
|
2
|
+
|
|
3
|
+
"use strict";
|
|
4
|
+
|
|
5
|
+
const { runLicensedNode } = require("../lib/licensing/node-guard");
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
function clamp(value, min, max) {
|
|
9
|
+
const n = Number(value);
|
|
10
|
+
if (!Number.isFinite(n)) return min;
|
|
11
|
+
return Math.min(Math.max(n, min), max);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function toFiniteOr(value, fallback) {
|
|
16
|
+
const n = Number(value);
|
|
17
|
+
return Number.isFinite(n) ? n : fallback;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseHourValue(value, fallback) {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
const text = value.trim();
|
|
23
|
+
const match = text.match(/^(\d{1,2}):(\d{1,2})$/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const hh = Number(match[1]);
|
|
26
|
+
const mm = Number(match[2]);
|
|
27
|
+
if (Number.isFinite(hh) && Number.isFinite(mm) && hh >= 0 && hh <= 23 && mm >= 0 && mm <= 59) {
|
|
28
|
+
return hh + (mm / 60);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const n = Number(value);
|
|
34
|
+
if (Number.isFinite(n)) return n;
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
function toOptionalLimit(value) {
|
|
40
|
+
if (value === null || value === undefined || value === "") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const n = Number(value);
|
|
45
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
function parseBool(value, fallback) {
|
|
54
|
+
if (value === true || value === false) return value;
|
|
55
|
+
if (value === "true") return true;
|
|
56
|
+
if (value === "false") return false;
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
function smoothstep(x) {
|
|
62
|
+
const t = clamp(x, 0, 1);
|
|
63
|
+
return t * t * (3 - 2 * t);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
function normalizeProfile(name) {
|
|
68
|
+
const value = String(name || "").trim().toLowerCase();
|
|
69
|
+
if (value === "sine" || value === "bell" || value === "plateau") {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
return "sine";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function computeProfileFactor(profile, progress) {
|
|
77
|
+
const x = clamp(progress, 0, 1);
|
|
78
|
+
|
|
79
|
+
if (profile === "bell") {
|
|
80
|
+
const sigma = 0.22;
|
|
81
|
+
const exponent = -Math.pow(x - 0.5, 2) / (2 * sigma * sigma);
|
|
82
|
+
return Math.exp(exponent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (profile === "plateau") {
|
|
86
|
+
const rampIn = smoothstep(x / 0.2);
|
|
87
|
+
const rampOut = smoothstep((1 - x) / 0.2);
|
|
88
|
+
return Math.min(rampIn, rampOut);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Math.sin(Math.PI * x);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function applyLimiterDynamics(rawPowerW, limitW, state) {
|
|
95
|
+
if (!Number.isFinite(limitW) || limitW < 0) {
|
|
96
|
+
return {
|
|
97
|
+
productionW: rawPowerW,
|
|
98
|
+
nextStateW: rawPowerW
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (rawPowerW <= limitW) {
|
|
103
|
+
return {
|
|
104
|
+
productionW: rawPowerW,
|
|
105
|
+
nextStateW: rawPowerW
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const controlAlpha = 0.35;
|
|
110
|
+
const jitterPct = 0.8;
|
|
111
|
+
const minJitterW = 15;
|
|
112
|
+
const jitterAmplitudeW = Math.max(minJitterW, limitW * (jitterPct / 100));
|
|
113
|
+
|
|
114
|
+
const prev = Number.isFinite(state) ? state : limitW;
|
|
115
|
+
const tracked = prev + ((limitW - prev) * controlAlpha);
|
|
116
|
+
|
|
117
|
+
const hunt = (Math.random() * 2 - 1) * jitterAmplitudeW;
|
|
118
|
+
const biasUnderLimit = Math.random() * jitterAmplitudeW * 0.35;
|
|
119
|
+
|
|
120
|
+
const maxAllowed = Math.min(rawPowerW, limitW * 1.01);
|
|
121
|
+
const minAllowed = Math.max(0, limitW - jitterAmplitudeW * 2.2);
|
|
122
|
+
const productionW = clamp(tracked + hunt - biasUnderLimit, minAllowed, maxAllowed);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
productionW: productionW,
|
|
126
|
+
nextStateW: tracked
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
function PvGeneratorNode(config) {
|
|
132
|
+
|
|
133
|
+
RED.nodes.createNode(this, config);
|
|
134
|
+
const node = this;
|
|
135
|
+
|
|
136
|
+
runLicensedNode(node, RED, config, function startLicensedRuntime() {
|
|
137
|
+
|
|
138
|
+
node.name = config.name || "";
|
|
139
|
+
node.outputTopic = String(config.outputTopic || "pv-simulator").trim() || "pv-simulator";
|
|
140
|
+
|
|
141
|
+
let ratedPowerW = Math.max(0, toFiniteOr(config.ratedPowerW, 10000));
|
|
142
|
+
let sunriseHour = clamp(parseHourValue(config.sunriseHour, 6), 0, 23.99);
|
|
143
|
+
let sunsetHour = clamp(parseHourValue(config.sunsetHour, 20), 0.01, 24);
|
|
144
|
+
let cloudinessPct = clamp(toFiniteOr(config.cloudinessPct, 10), 0, 100);
|
|
145
|
+
let randomNoisePct = clamp(toFiniteOr(config.randomNoisePct, 3), 0, 100);
|
|
146
|
+
let profile = normalizeProfile(config.profile);
|
|
147
|
+
let sampleIntervalMs = Math.max(250, Math.round(toFiniteOr(config.sampleIntervalSec, 5) * 1000));
|
|
148
|
+
|
|
149
|
+
let limitW = toOptionalLimit(config.limitW);
|
|
150
|
+
|
|
151
|
+
let enabled = parseBool(config.enabled, true);
|
|
152
|
+
let autostart = parseBool(config.autostart, true);
|
|
153
|
+
|
|
154
|
+
let timer = null;
|
|
155
|
+
let limiterStateW = null;
|
|
156
|
+
|
|
157
|
+
if (sunsetHour <= sunriseHour) {
|
|
158
|
+
sunsetHour = Math.min(24, sunriseHour + 0.5);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
function setStatus(color, shape, text) {
|
|
163
|
+
node.status({ fill: color, shape: shape, text: text });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
function daytimeProgress(hoursDecimal) {
|
|
168
|
+
if (hoursDecimal < sunriseHour || hoursDecimal > sunsetHour) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const daySpan = sunsetHour - sunriseHour;
|
|
173
|
+
if (daySpan <= 0) return null;
|
|
174
|
+
|
|
175
|
+
return (hoursDecimal - sunriseHour) / daySpan;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
function getHoursDecimal(dateObj) {
|
|
180
|
+
return dateObj.getHours() + (dateObj.getMinutes() / 60) + (dateObj.getSeconds() / 3600);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
function generateProduction(nowMs) {
|
|
185
|
+
const now = new Date(nowMs);
|
|
186
|
+
const hours = getHoursDecimal(now);
|
|
187
|
+
const progress = daytimeProgress(hours);
|
|
188
|
+
|
|
189
|
+
let rawPowerW = 0;
|
|
190
|
+
let isDaylight = false;
|
|
191
|
+
|
|
192
|
+
if (progress !== null) {
|
|
193
|
+
isDaylight = true;
|
|
194
|
+
const profileFactor = clamp(computeProfileFactor(profile, progress), 0, 1);
|
|
195
|
+
const cloudsFactor = clamp(1 - (cloudinessPct / 100), 0, 1);
|
|
196
|
+
const noNoisePower = ratedPowerW * profileFactor * cloudsFactor;
|
|
197
|
+
|
|
198
|
+
const noiseFactor = 1 + ((Math.random() * 2 - 1) * (randomNoisePct / 100));
|
|
199
|
+
rawPowerW = clamp(noNoisePower * noiseFactor, 0, ratedPowerW);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const appliedLimitW = Number.isFinite(limitW) ? Math.max(0, limitW) : null;
|
|
203
|
+
let productionW = rawPowerW;
|
|
204
|
+
|
|
205
|
+
if (appliedLimitW !== null) {
|
|
206
|
+
const limited = applyLimiterDynamics(rawPowerW, appliedLimitW, limiterStateW);
|
|
207
|
+
productionW = limited.productionW;
|
|
208
|
+
limiterStateW = limited.nextStateW;
|
|
209
|
+
} else {
|
|
210
|
+
limiterStateW = rawPowerW;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const curtailedW = Math.max(0, rawPowerW - productionW);
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
217
|
+
production_w: Math.round(productionW),
|
|
218
|
+
raw_power_w: Math.round(rawPowerW),
|
|
219
|
+
curtailed_w: Math.round(curtailedW),
|
|
220
|
+
limit_w: appliedLimitW === null ? null : Math.round(appliedLimitW),
|
|
221
|
+
inverter_rated_w: Math.round(ratedPowerW),
|
|
222
|
+
profile: profile,
|
|
223
|
+
cloudiness_pct: Number(cloudinessPct.toFixed(2)),
|
|
224
|
+
random_noise_pct: Number(randomNoisePct.toFixed(2)),
|
|
225
|
+
daylight: isDaylight,
|
|
226
|
+
day_window: {
|
|
227
|
+
sunrise_hour: Number(sunriseHour.toFixed(2)),
|
|
228
|
+
sunset_hour: Number(sunsetHour.toFixed(2))
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
function emitSample(baseMsg) {
|
|
235
|
+
const data = generateProduction(Date.now());
|
|
236
|
+
|
|
237
|
+
const msg = Object.assign({}, baseMsg || {});
|
|
238
|
+
msg.topic = node.outputTopic;
|
|
239
|
+
msg.payload = data.production_w;
|
|
240
|
+
msg.pv = data;
|
|
241
|
+
|
|
242
|
+
node.send(msg);
|
|
243
|
+
|
|
244
|
+
const limitText = data.limit_w === null ? "fara limit" : ("limita " + data.limit_w + "W");
|
|
245
|
+
setStatus(enabled ? "green" : "grey", "dot", data.production_w + "W | " + limitText);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
function startTimer() {
|
|
250
|
+
if (timer) return;
|
|
251
|
+
|
|
252
|
+
timer = setInterval(function () {
|
|
253
|
+
if (!enabled) return;
|
|
254
|
+
emitSample();
|
|
255
|
+
}, sampleIntervalMs);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
function stopTimer() {
|
|
260
|
+
if (!timer) return;
|
|
261
|
+
clearInterval(timer);
|
|
262
|
+
timer = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
function applyRuntimeUpdate(source) {
|
|
267
|
+
if (!source || typeof source !== "object") return;
|
|
268
|
+
|
|
269
|
+
if (source.limit_w !== undefined || source.limitW !== undefined || source.max_power_w !== undefined) {
|
|
270
|
+
const candidate = source.limit_w !== undefined
|
|
271
|
+
? source.limit_w
|
|
272
|
+
: (source.limitW !== undefined ? source.limitW : source.max_power_w);
|
|
273
|
+
limitW = toOptionalLimit(candidate);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (source.rated_power_w !== undefined || source.ratedPowerW !== undefined) {
|
|
277
|
+
const candidate = source.rated_power_w !== undefined ? source.rated_power_w : source.ratedPowerW;
|
|
278
|
+
ratedPowerW = Math.max(0, toFiniteOr(candidate, ratedPowerW));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (source.cloudiness_pct !== undefined || source.cloudinessPct !== undefined) {
|
|
282
|
+
const candidate = source.cloudiness_pct !== undefined ? source.cloudiness_pct : source.cloudinessPct;
|
|
283
|
+
cloudinessPct = clamp(toFiniteOr(candidate, cloudinessPct), 0, 100);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (source.random_noise_pct !== undefined || source.randomNoisePct !== undefined) {
|
|
287
|
+
const candidate = source.random_noise_pct !== undefined ? source.random_noise_pct : source.randomNoisePct;
|
|
288
|
+
randomNoisePct = clamp(toFiniteOr(candidate, randomNoisePct), 0, 100);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (source.profile !== undefined) {
|
|
292
|
+
profile = normalizeProfile(source.profile);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (source.sunrise_hour !== undefined || source.sunriseHour !== undefined) {
|
|
296
|
+
const candidate = source.sunrise_hour !== undefined ? source.sunrise_hour : source.sunriseHour;
|
|
297
|
+
sunriseHour = clamp(parseHourValue(candidate, sunriseHour), 0, 23.99);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (source.sunset_hour !== undefined || source.sunsetHour !== undefined) {
|
|
301
|
+
const candidate = source.sunset_hour !== undefined ? source.sunset_hour : source.sunsetHour;
|
|
302
|
+
sunsetHour = clamp(parseHourValue(candidate, sunsetHour), 0.01, 24);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (sunsetHour <= sunriseHour) {
|
|
306
|
+
sunsetHour = Math.min(24, sunriseHour + 0.5);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (source.sample_interval_sec !== undefined || source.sampleIntervalSec !== undefined) {
|
|
310
|
+
const candidate = source.sample_interval_sec !== undefined
|
|
311
|
+
? source.sample_interval_sec
|
|
312
|
+
: source.sampleIntervalSec;
|
|
313
|
+
|
|
314
|
+
sampleIntervalMs = Math.max(250, Math.round(toFiniteOr(candidate, sampleIntervalMs / 1000) * 1000));
|
|
315
|
+
|
|
316
|
+
if (timer) {
|
|
317
|
+
stopTimer();
|
|
318
|
+
startTimer();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (source.enabled !== undefined) {
|
|
323
|
+
enabled = parseBool(source.enabled, enabled);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (source.autostart !== undefined) {
|
|
327
|
+
autostart = parseBool(source.autostart, autostart);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
node.on("input", function (msg, send, done) {
|
|
333
|
+
try {
|
|
334
|
+
const command = String((msg && msg.command) || (msg && msg.topic) || "").toLowerCase().trim();
|
|
335
|
+
|
|
336
|
+
if (msg && typeof msg.payload === "object" && msg.payload !== null) {
|
|
337
|
+
applyRuntimeUpdate(msg.payload);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
applyRuntimeUpdate(msg);
|
|
341
|
+
|
|
342
|
+
if (typeof msg.payload === "number" && (command === "set-limit" || command === "limit")) {
|
|
343
|
+
limitW = toOptionalLimit(msg.payload);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (command === "stop") {
|
|
347
|
+
enabled = false;
|
|
348
|
+
setStatus("grey", "ring", "simulator oprit");
|
|
349
|
+
} else if (command === "start") {
|
|
350
|
+
enabled = true;
|
|
351
|
+
setStatus("green", "dot", "simulator pornit");
|
|
352
|
+
} else if (command === "tick" || command === "sample" || command === "emit") {
|
|
353
|
+
emitSample(msg);
|
|
354
|
+
} else {
|
|
355
|
+
emitSample(msg);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (done) done();
|
|
359
|
+
} catch (err) {
|
|
360
|
+
node.error(err, msg);
|
|
361
|
+
if (done) done(err);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
node.on("close", function () {
|
|
367
|
+
stopTimer();
|
|
368
|
+
node.status({});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if (autostart) {
|
|
373
|
+
startTimer();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setStatus(enabled ? "green" : "grey", enabled ? "dot" : "ring", enabled ? "simulator activ" : "simulator inactiv");
|
|
377
|
+
|
|
378
|
+
if (enabled && autostart) {
|
|
379
|
+
emitSample();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
RED.nodes.registerType("pv-generator", PvGeneratorNode);
|
|
387
|
+
};
|