@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.
@@ -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
- - `minCoastSaving`: Only coast if price is 10+ øre/kWh above average
287
- - `minBoostSaving`: Only boost if price is 10+ øre/kWh below average
288
- - This prevents mode switching for trivial price differences!
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energiok/node-red-contrib-pricecontrol-thermal",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "restricted"
@@ -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
- const clustering = classifyPricesWithCkmeans(priceValues, effectiveVariationThreshold, effectiveNumGroups);
490
- const { assignments, breaks, clusterMeans } = clustering;
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
- const cheapThreshold = breaks[0] ?? avgPrice * 0.95;
495
- const expensiveThreshold = breaks[breaks.length - 1] ?? avgPrice * 1.05;
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
- clustering: {
597
- numGroups: effectiveNumGroups,
598
- cheapThreshold,
599
- expensiveThreshold,
600
- breaks,
601
- clusterMeans,
602
- method: "ckmeans",
603
- rawBoostCount: assignments.filter((a) => a === 0).length,
604
- rawNormalCount: assignments.filter((a) => a !== 0 && a !== lastGroup).length,
605
- rawCoastCount: assignments.filter((a) => a === lastGroup).length,
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)