@energiok/node-red-contrib-pricecontrol-thermal 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DASHBOARD-TEMPLATE-3-MODES.md +4 -4
- package/package.json +1 -1
- package/src/strategy-smart-thermal-functions.js +210 -160
- package/src/strategy-smart-thermal.js +2 -0
- package/test/strategy-smart-thermal-functions.test.js +258 -94
- package/test/strategy-smart-thermal-node.test.js +36 -58
- package/nul +0 -0
- package/test-3day-prices.js +0 -96
- package/test-cold-weather.js +0 -43
- package/test-day-transition.js +0 -161
- package/test-find-params.js +0 -78
- package/test-hourly-alignment.js +0 -114
- package/test-price-analysis.js +0 -77
- package/test-runway.js +0 -118
- package/test-saturday-peak.js +0 -132
- package/test-step-by-step.js +0 -213
- package/test-temp-scaling.js +0 -62
- package/test-thermal-budget.js +0 -174
- package/test-timezone.js +0 -83
- package/test-utc-machine.js +0 -90
|
@@ -282,7 +282,7 @@ export default {
|
|
|
282
282
|
2. **Boost** (Green) - Pre-heat when prices are significantly cheap AND there's an upcoming peak
|
|
283
283
|
3. **Coast** (Red) - Turn off when prices are significantly expensive
|
|
284
284
|
|
|
285
|
-
**Thresholds that control behavior:**
|
|
286
|
-
- `
|
|
287
|
-
- `
|
|
288
|
-
-
|
|
285
|
+
**Thresholds that control behavior:**
|
|
286
|
+
- `minSavingsPercent`: Minimum price variation before coast/boost activates
|
|
287
|
+
- `minModeDuration`: Minimum minutes per mode to prevent short cycling
|
|
288
|
+
- `numPriceGroups`: 3-5 price clusters (more groups = narrower boost/coast)
|
package/package.json
CHANGED
|
@@ -249,6 +249,141 @@ function classifyPricesWithCkmeans(prices, minVariationPercent = 2, numGroups =
|
|
|
249
249
|
};
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// ============================================================================
|
|
253
|
+
// Per-Day Clustering Functions
|
|
254
|
+
// ============================================================================
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Group price data by local calendar day
|
|
258
|
+
* @param {Array} validPrices - Array of {time, value} price data (UTC timestamps)
|
|
259
|
+
* @param {string} outputTimezone - IANA timezone for day boundaries (e.g., "Europe/Oslo")
|
|
260
|
+
* @returns {Map<string, Array<{originalIndex: number, time: string, value: number}>>}
|
|
261
|
+
*/
|
|
262
|
+
function groupPricesByLocalDay(validPrices, outputTimezone) {
|
|
263
|
+
const dayGroups = new Map();
|
|
264
|
+
|
|
265
|
+
for (let i = 0; i < validPrices.length; i++) {
|
|
266
|
+
const utcTime = DateTime.fromISO(validPrices[i].time, { zone: "UTC" });
|
|
267
|
+
const localTime = utcTime.setZone(outputTimezone);
|
|
268
|
+
const dayKey = localTime.toISODate(); // "YYYY-MM-DD"
|
|
269
|
+
|
|
270
|
+
if (!dayGroups.has(dayKey)) {
|
|
271
|
+
dayGroups.set(dayKey, []);
|
|
272
|
+
}
|
|
273
|
+
dayGroups.get(dayKey).push({
|
|
274
|
+
originalIndex: i,
|
|
275
|
+
time: validPrices[i].time,
|
|
276
|
+
value: validPrices[i].value,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return dayGroups;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Merge partial days (too few intervals) with adjacent full days
|
|
285
|
+
* @param {Array} sortedDays - Array of [dayKey, prices] tuples sorted by date
|
|
286
|
+
* @param {number} minIntervals - Minimum intervals to be considered a full day
|
|
287
|
+
* @returns {Array} Merged array of [dayKey, prices] tuples
|
|
288
|
+
*/
|
|
289
|
+
function mergePartialDays(sortedDays, minIntervals) {
|
|
290
|
+
if (sortedDays.length <= 1) return sortedDays;
|
|
291
|
+
|
|
292
|
+
const result = [];
|
|
293
|
+
let pendingMerge = null;
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < sortedDays.length; i++) {
|
|
296
|
+
const [dayKey, prices] = sortedDays[i];
|
|
297
|
+
|
|
298
|
+
if (pendingMerge) {
|
|
299
|
+
// Merge pending partial day with current day
|
|
300
|
+
const mergedKey = `${pendingMerge[0]}+${dayKey}`;
|
|
301
|
+
const mergedPrices = [...pendingMerge[1], ...prices];
|
|
302
|
+
|
|
303
|
+
if (mergedPrices.length >= minIntervals) {
|
|
304
|
+
result.push([mergedKey, mergedPrices]);
|
|
305
|
+
pendingMerge = null;
|
|
306
|
+
} else {
|
|
307
|
+
// Still too small, keep as pending
|
|
308
|
+
pendingMerge = [mergedKey, mergedPrices];
|
|
309
|
+
}
|
|
310
|
+
} else if (prices.length < minIntervals) {
|
|
311
|
+
// This day is too small, mark for merging
|
|
312
|
+
if (i === sortedDays.length - 1 && result.length > 0) {
|
|
313
|
+
// Last day, merge with previous
|
|
314
|
+
const prev = result.pop();
|
|
315
|
+
result.push([`${prev[0]}+${dayKey}`, [...prev[1], ...prices]]);
|
|
316
|
+
} else {
|
|
317
|
+
// Not last day, merge with next
|
|
318
|
+
pendingMerge = [dayKey, prices];
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
result.push([dayKey, prices]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Handle any remaining pending merge
|
|
326
|
+
if (pendingMerge) {
|
|
327
|
+
if (result.length > 0) {
|
|
328
|
+
const prev = result.pop();
|
|
329
|
+
result.push([`${prev[0]}+${pendingMerge[0]}`, [...prev[1], ...pendingMerge[1]]]);
|
|
330
|
+
} else {
|
|
331
|
+
result.push(pendingMerge);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Perform clustering for each day separately
|
|
340
|
+
* @param {Array} validPrices - Array of {time, value} price data
|
|
341
|
+
* @param {string} outputTimezone - Timezone for day boundaries
|
|
342
|
+
* @param {number} variationThreshold - Minimum variation % for clustering
|
|
343
|
+
* @param {number} numGroups - Number of price groups (3-5)
|
|
344
|
+
* @param {number} minIntervalsPerDay - Minimum intervals to cluster a day separately (default 8)
|
|
345
|
+
* @returns {object} { assignments: number[], dailyClustering: Array }
|
|
346
|
+
*/
|
|
347
|
+
function classifyPricesPerDay(validPrices, outputTimezone, variationThreshold, numGroups, minIntervalsPerDay = 8) {
|
|
348
|
+
const dayGroups = groupPricesByLocalDay(validPrices, outputTimezone);
|
|
349
|
+
const assignments = new Array(validPrices.length).fill(Math.floor(numGroups / 2)); // Default: normal
|
|
350
|
+
const dailyClustering = [];
|
|
351
|
+
|
|
352
|
+
// Convert to array and sort by date
|
|
353
|
+
const sortedDays = [...dayGroups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
354
|
+
|
|
355
|
+
// Merge small partial days with adjacent days
|
|
356
|
+
const mergedDays = mergePartialDays(sortedDays, minIntervalsPerDay);
|
|
357
|
+
|
|
358
|
+
for (const [dayKey, dayPrices] of mergedDays) {
|
|
359
|
+
const priceValues = dayPrices.map((p) => p.value);
|
|
360
|
+
|
|
361
|
+
// Cluster this day's prices
|
|
362
|
+
const clustering = classifyPricesWithCkmeans(priceValues, variationThreshold, numGroups);
|
|
363
|
+
|
|
364
|
+
// Map assignments back to original indices
|
|
365
|
+
for (let i = 0; i < dayPrices.length; i++) {
|
|
366
|
+
assignments[dayPrices[i].originalIndex] = clustering.assignments[i];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Record per-day tuning info
|
|
370
|
+
const avgPrice = priceValues.reduce((a, b) => a + b, 0) / priceValues.length;
|
|
371
|
+
dailyClustering.push({
|
|
372
|
+
date: dayKey,
|
|
373
|
+
breaks: clustering.breaks,
|
|
374
|
+
clusterMeans: clustering.clusterMeans,
|
|
375
|
+
cheapThreshold: clustering.breaks[0] ?? avgPrice * 0.95,
|
|
376
|
+
expensiveThreshold: clustering.breaks[clustering.breaks.length - 1] ?? avgPrice * 1.05,
|
|
377
|
+
priceCount: dayPrices.length,
|
|
378
|
+
avgPrice,
|
|
379
|
+
minPrice: Math.min(...priceValues),
|
|
380
|
+
maxPrice: Math.max(...priceValues),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return { assignments, dailyClustering };
|
|
385
|
+
}
|
|
386
|
+
|
|
252
387
|
// ============================================================================
|
|
253
388
|
// Thermal Calculations
|
|
254
389
|
// ============================================================================
|
|
@@ -293,42 +428,6 @@ function calculateCoastTime(indoorTemp, outdoorTemp, comfortMin, heatLossCoeffic
|
|
|
293
428
|
return Math.max(0, coastHours * 60); // Convert to minutes
|
|
294
429
|
}
|
|
295
430
|
|
|
296
|
-
/**
|
|
297
|
-
* Identify price peaks (top X% of prices)
|
|
298
|
-
* @param {Array} prices - Array of price objects with {time, value}
|
|
299
|
-
* @param {number} topPercentage - Percentage threshold for peaks (default 20)
|
|
300
|
-
* @returns {Array<boolean>} Boolean array marking peak periods
|
|
301
|
-
*/
|
|
302
|
-
function findPeaks(prices, topPercentage = 20) {
|
|
303
|
-
if (!prices || prices.length === 0) {
|
|
304
|
-
return [];
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const sorted = [...prices].sort((a, b) => b.value - a.value);
|
|
308
|
-
const thresholdIndex = Math.floor((prices.length * topPercentage) / 100);
|
|
309
|
-
const threshold = sorted[thresholdIndex]?.value ?? Infinity;
|
|
310
|
-
|
|
311
|
-
return prices.map((p) => p.value >= threshold);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Identify price troughs (bottom X% of prices)
|
|
316
|
-
* @param {Array} prices - Array of price objects with {time, value}
|
|
317
|
-
* @param {number} bottomPercentage - Percentage threshold for troughs (default 20)
|
|
318
|
-
* @returns {Array<boolean>} Boolean array marking trough periods
|
|
319
|
-
*/
|
|
320
|
-
function findTroughs(prices, bottomPercentage = 20) {
|
|
321
|
-
if (!prices || prices.length === 0) {
|
|
322
|
-
return [];
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const sorted = [...prices].sort((a, b) => a.value - b.value);
|
|
326
|
-
const thresholdIndex = Math.floor((prices.length * bottomPercentage) / 100);
|
|
327
|
-
const threshold = sorted[thresholdIndex]?.value ?? -Infinity;
|
|
328
|
-
|
|
329
|
-
return prices.map((p) => p.value <= threshold);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
431
|
/**
|
|
333
432
|
* Calculate how long boosted heat will be retained before dropping to comfort minimum
|
|
334
433
|
* @param {number} outdoorTemp - Outdoor temperature (°C)
|
|
@@ -347,53 +446,6 @@ function calculateBoostRetention(outdoorTemp, comfortMax, comfortMin, heatLossCo
|
|
|
347
446
|
return (tempBuffer / heatLossRate) * 60; // Convert hours to minutes
|
|
348
447
|
}
|
|
349
448
|
|
|
350
|
-
// ============================================================================
|
|
351
|
-
// Price Analysis
|
|
352
|
-
// ============================================================================
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Find contiguous windows of expensive prices (above threshold)
|
|
356
|
-
* @param {Array} prices - Array of price values
|
|
357
|
-
* @param {number} avgPrice - Average price
|
|
358
|
-
* @param {number} minSavingsThreshold - Minimum savings to consider (absolute value)
|
|
359
|
-
* @returns {Array} Array of {start, end, totalSavings, peakPrice} windows sorted by peak price
|
|
360
|
-
*/
|
|
361
|
-
function findExpensiveWindows(prices, avgPrice, minSavingsThreshold = 0) {
|
|
362
|
-
const windows = [];
|
|
363
|
-
let i = 0;
|
|
364
|
-
|
|
365
|
-
// Threshold for considering a price "expensive"
|
|
366
|
-
const expensiveThreshold = avgPrice + minSavingsThreshold;
|
|
367
|
-
|
|
368
|
-
while (i < prices.length) {
|
|
369
|
-
if (prices[i] > expensiveThreshold) {
|
|
370
|
-
const start = i;
|
|
371
|
-
let totalSavings = 0;
|
|
372
|
-
let peakPrice = prices[i];
|
|
373
|
-
|
|
374
|
-
while (i < prices.length && prices[i] > expensiveThreshold) {
|
|
375
|
-
totalSavings += prices[i] - avgPrice;
|
|
376
|
-
peakPrice = Math.max(peakPrice, prices[i]);
|
|
377
|
-
i++;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
windows.push({
|
|
381
|
-
start,
|
|
382
|
-
end: i - 1,
|
|
383
|
-
length: i - start,
|
|
384
|
-
totalSavings,
|
|
385
|
-
peakPrice,
|
|
386
|
-
avgSavingsPerHour: totalSavings / (i - start),
|
|
387
|
-
});
|
|
388
|
-
} else {
|
|
389
|
-
i++;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Sort windows by peak price (highest first) - prioritize biggest peaks
|
|
394
|
-
return windows.sort((a, b) => b.peakPrice - a.peakPrice);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
449
|
// ============================================================================
|
|
398
450
|
// Schedule Generation
|
|
399
451
|
// ============================================================================
|
|
@@ -454,6 +506,7 @@ function createSchedule(prices, outdoorTemp, config) {
|
|
|
454
506
|
numPriceGroups = 3, // Number of price clusters (3-5). More groups = narrower boost/coast bands
|
|
455
507
|
extremeWeatherThreshold = -15, // Disable coasting below this outdoor temp
|
|
456
508
|
outputTimezone = "Europe/Oslo", // Timezone for schedule output (IANA timezone name)
|
|
509
|
+
clusterPerDay = true, // Cluster prices per day (true) or globally across all days (false)
|
|
457
510
|
} = validatedConfig;
|
|
458
511
|
|
|
459
512
|
const n = validPrices.length;
|
|
@@ -486,13 +539,56 @@ function createSchedule(prices, outdoorTemp, config) {
|
|
|
486
539
|
// Use Ckmeans clustering to find natural price breaks
|
|
487
540
|
// Use effectiveVariationThreshold as minimum variation - if prices don't vary
|
|
488
541
|
// by at least this much, treat everything as normal (no boost/coast)
|
|
489
|
-
|
|
490
|
-
|
|
542
|
+
let assignments;
|
|
543
|
+
let breaks;
|
|
544
|
+
let clusterMeans;
|
|
545
|
+
let dailyClusteringInfo = null;
|
|
546
|
+
|
|
547
|
+
// Minimum intervals per day for separate clustering (at least 2 hours of data)
|
|
548
|
+
const minIntervalsPerDay = Math.ceil(120 / intervalMinutes);
|
|
549
|
+
|
|
550
|
+
if (clusterPerDay && n >= minIntervalsPerDay) {
|
|
551
|
+
// Per-day clustering: each day's prices evaluated relative to that day
|
|
552
|
+
const perDayResult = classifyPricesPerDay(
|
|
553
|
+
validPrices,
|
|
554
|
+
outputTimezone,
|
|
555
|
+
effectiveVariationThreshold,
|
|
556
|
+
effectiveNumGroups,
|
|
557
|
+
minIntervalsPerDay
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
assignments = perDayResult.assignments;
|
|
561
|
+
dailyClusteringInfo = perDayResult.dailyClustering;
|
|
562
|
+
|
|
563
|
+
// Use first day's clustering info for breaks/clusterMeans (for backward compatibility)
|
|
564
|
+
// but calculate aggregate thresholds for coast extension logic
|
|
565
|
+
if (dailyClusteringInfo.length > 0) {
|
|
566
|
+
breaks = dailyClusteringInfo[0].breaks;
|
|
567
|
+
clusterMeans = dailyClusteringInfo[0].clusterMeans;
|
|
568
|
+
} else {
|
|
569
|
+
breaks = [];
|
|
570
|
+
clusterMeans = [];
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
// Global clustering: all prices across all days clustered together (original behavior)
|
|
574
|
+
const clustering = classifyPricesWithCkmeans(priceValues, effectiveVariationThreshold, effectiveNumGroups);
|
|
575
|
+
assignments = clustering.assignments;
|
|
576
|
+
breaks = clustering.breaks;
|
|
577
|
+
clusterMeans = clustering.clusterMeans;
|
|
578
|
+
}
|
|
491
579
|
|
|
492
580
|
// First break = threshold between cheapest (boost) and next group
|
|
493
581
|
// Last break = threshold between second-most-expensive and most expensive (coast)
|
|
494
|
-
|
|
495
|
-
|
|
582
|
+
// For per-day clustering, use aggregate thresholds across all days
|
|
583
|
+
let cheapThreshold, expensiveThreshold;
|
|
584
|
+
if (dailyClusteringInfo && dailyClusteringInfo.length > 0) {
|
|
585
|
+
// Use the most conservative thresholds across all days for coast extension
|
|
586
|
+
cheapThreshold = Math.min(...dailyClusteringInfo.map((d) => d.cheapThreshold));
|
|
587
|
+
expensiveThreshold = Math.max(...dailyClusteringInfo.map((d) => d.expensiveThreshold));
|
|
588
|
+
} else {
|
|
589
|
+
cheapThreshold = breaks[0] ?? avgPrice * 0.95;
|
|
590
|
+
expensiveThreshold = breaks[breaks.length - 1] ?? avgPrice * 1.05;
|
|
591
|
+
}
|
|
496
592
|
|
|
497
593
|
// Calculate minimum savings threshold (for tuning info)
|
|
498
594
|
const minSavingsThreshold = avgPrice * (effectiveMinSavingsPercent / 100);
|
|
@@ -593,17 +689,30 @@ function createSchedule(prices, outdoorTemp, config) {
|
|
|
593
689
|
minModeDuration,
|
|
594
690
|
outputTimezone,
|
|
595
691
|
// Ckmeans clustering info
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
692
|
+
clusterPerDay,
|
|
693
|
+
clustering: dailyClusteringInfo
|
|
694
|
+
? {
|
|
695
|
+
method: "ckmeans-per-day",
|
|
696
|
+
numGroups: effectiveNumGroups,
|
|
697
|
+
numDays: dailyClusteringInfo.length,
|
|
698
|
+
aggregateCheapThreshold: cheapThreshold,
|
|
699
|
+
aggregateExpensiveThreshold: expensiveThreshold,
|
|
700
|
+
dailyClustering: dailyClusteringInfo,
|
|
701
|
+
rawBoostCount: assignments.filter((a) => a === 0).length,
|
|
702
|
+
rawNormalCount: assignments.filter((a) => a !== 0 && a !== lastGroup).length,
|
|
703
|
+
rawCoastCount: assignments.filter((a) => a === lastGroup).length,
|
|
704
|
+
}
|
|
705
|
+
: {
|
|
706
|
+
numGroups: effectiveNumGroups,
|
|
707
|
+
cheapThreshold,
|
|
708
|
+
expensiveThreshold,
|
|
709
|
+
breaks,
|
|
710
|
+
clusterMeans,
|
|
711
|
+
method: "ckmeans",
|
|
712
|
+
rawBoostCount: assignments.filter((a) => a === 0).length,
|
|
713
|
+
rawNormalCount: assignments.filter((a) => a !== 0 && a !== lastGroup).length,
|
|
714
|
+
rawCoastCount: assignments.filter((a) => a === lastGroup).length,
|
|
715
|
+
},
|
|
607
716
|
// Final mode counts after thermal constraints and min duration enforcement
|
|
608
717
|
finalModeCount: modeCount,
|
|
609
718
|
validationIssues: validatedConfig.validationIssues,
|
|
@@ -926,64 +1035,6 @@ function alignToHourlyBoundaries(modes, prices, intervalMinutes, outputTimezone)
|
|
|
926
1035
|
return result;
|
|
927
1036
|
}
|
|
928
1037
|
|
|
929
|
-
/**
|
|
930
|
-
* Enforce maximum coast duration based on outdoor temperature
|
|
931
|
-
* Splits long coast periods into coast + normal to prevent excessive temperature drop
|
|
932
|
-
* @param {Array<string>} modes - Array of mode strings
|
|
933
|
-
* @param {number} intervalMinutes - Minutes per interval
|
|
934
|
-
* @param {number} maxCoastMinutes - Maximum safe coast time based on outdoor temp
|
|
935
|
-
* @returns {Array<string>} Adjusted modes with coast duration limited
|
|
936
|
-
*/
|
|
937
|
-
function enforceMaxCoastDuration(modes, intervalMinutes, maxCoastMinutes) {
|
|
938
|
-
if (modes.length === 0 || maxCoastMinutes <= 0) return modes;
|
|
939
|
-
|
|
940
|
-
const maxCoastIntervals = Math.ceil(maxCoastMinutes / intervalMinutes);
|
|
941
|
-
const result = [...modes];
|
|
942
|
-
|
|
943
|
-
// Find all coast runs
|
|
944
|
-
let i = 0;
|
|
945
|
-
while (i < result.length) {
|
|
946
|
-
if (result[i] === "coast") {
|
|
947
|
-
// Found start of coast run
|
|
948
|
-
let coastLength = 1;
|
|
949
|
-
while (i + coastLength < result.length && result[i + coastLength] === "coast") {
|
|
950
|
-
coastLength++;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// If coast run exceeds maximum, split it
|
|
954
|
-
if (coastLength > maxCoastIntervals) {
|
|
955
|
-
// Keep first maxCoastIntervals as coast, convert rest to normal
|
|
956
|
-
for (let j = i + maxCoastIntervals; j < i + coastLength; j++) {
|
|
957
|
-
result[j] = "normal";
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
i += coastLength;
|
|
962
|
-
} else {
|
|
963
|
-
i++;
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
return result;
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
/**
|
|
971
|
-
* Find the next peak within a certain number of intervals
|
|
972
|
-
* @param {Array<boolean>} peaks - Boolean array marking peaks
|
|
973
|
-
* @param {number} currentIndex - Current position
|
|
974
|
-
* @param {number} lookAheadIntervals - How many intervals to look ahead
|
|
975
|
-
* @returns {number} Index of next peak, or -1 if none found
|
|
976
|
-
*/
|
|
977
|
-
function findNextPeak(peaks, currentIndex, lookAheadIntervals) {
|
|
978
|
-
const maxIndex = Math.min(currentIndex + lookAheadIntervals, peaks.length);
|
|
979
|
-
for (let i = currentIndex + 1; i < maxIndex; i++) {
|
|
980
|
-
if (peaks[i]) {
|
|
981
|
-
return i;
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
return -1;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
1038
|
/**
|
|
988
1039
|
* Get current mode from schedule
|
|
989
1040
|
*
|
|
@@ -1039,17 +1090,16 @@ function getCurrentMode(schedule, currentTime) {
|
|
|
1039
1090
|
module.exports = {
|
|
1040
1091
|
calculateCoastTime,
|
|
1041
1092
|
calculateBoostRetention,
|
|
1042
|
-
findPeaks,
|
|
1043
|
-
findTroughs,
|
|
1044
|
-
findExpensiveWindows,
|
|
1045
1093
|
createSchedule,
|
|
1046
1094
|
getCurrentMode,
|
|
1047
1095
|
enforceMinimumDurations,
|
|
1048
|
-
enforceMaxCoastDuration,
|
|
1049
1096
|
extendCoastToRunway,
|
|
1050
1097
|
applyRightAlignedWindows,
|
|
1051
1098
|
alignToHourlyBoundaries,
|
|
1052
1099
|
validateConfig,
|
|
1053
1100
|
ckmeans,
|
|
1054
1101
|
classifyPricesWithCkmeans,
|
|
1102
|
+
groupPricesByLocalDay,
|
|
1103
|
+
mergePartialDays,
|
|
1104
|
+
classifyPricesPerDay,
|
|
1055
1105
|
};
|
|
@@ -101,6 +101,7 @@ module.exports = function (RED) {
|
|
|
101
101
|
outputTimezone: config.outputTimezone || "Europe/Oslo",
|
|
102
102
|
extremeWeatherThreshold: toFiniteNumber(config.extremeWeatherThreshold, -15),
|
|
103
103
|
sendSchedule: config.sendSchedule !== false,
|
|
104
|
+
clusterPerDay: config.clusterPerDay !== false, // Default true: cluster prices per day
|
|
104
105
|
};
|
|
105
106
|
saveOriginalConfig(node, originalConfig);
|
|
106
107
|
|
|
@@ -132,6 +133,7 @@ module.exports = function (RED) {
|
|
|
132
133
|
msg.payload?.extremeWeatherThreshold,
|
|
133
134
|
storedConfig.extremeWeatherThreshold
|
|
134
135
|
),
|
|
136
|
+
clusterPerDay: msg.payload?.clusterPerDay ?? storedConfig.clusterPerDay,
|
|
135
137
|
};
|
|
136
138
|
|
|
137
139
|
// Extract price data (supports multiple formats)
|