@energiok/node-red-contrib-pricecontrol-thermal 1.2.1 → 1.2.3

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.1",
3
+ "version": "1.2.3",
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
  // ============================================================================
@@ -294,50 +429,49 @@ function calculateCoastTime(indoorTemp, outdoorTemp, comfortMin, heatLossCoeffic
294
429
  }
295
430
 
296
431
  /**
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
432
+ * Calculate how long boosted heat will be retained before dropping to comfort minimum
433
+ * @param {number} outdoorTemp - Outdoor temperature (°C)
434
+ * @param {number} comfortMax - Maximum comfort temperature after boost (°C)
435
+ * @param {number} comfortMin - Minimum comfort temperature (°C)
436
+ * @param {number} heatLossCoefficient - Building heat loss rate
437
+ * @returns {number} Retention time in minutes
301
438
  */
302
- function findPeaks(prices, topPercentage = 20) {
303
- if (!prices || prices.length === 0) {
304
- return [];
439
+ function calculateBoostRetention(outdoorTemp, comfortMax, comfortMin, heatLossCoefficient) {
440
+ // Boost duration should INCREASE in cold weather (building needs more heat)
441
+ // and DECREASE in mild weather (risk of overheating)
442
+ //
443
+ // Logic: In cold weather, we want to store more thermal energy during cheap hours
444
+ // because we'll need it. In mild weather, limit boost to avoid overshooting comfort.
445
+ //
446
+ // Base: 2 hours at 10°C (mild), scaling up to 6 hours at -20°C (very cold)
447
+ const baseBoostMinutes = 120; // 2 hours minimum
448
+ const maxBoostMinutes = 360; // 6 hours maximum
449
+
450
+ // Reference temperatures for scaling
451
+ const mildTemp = 10; // At this temp or above, use base boost
452
+ const coldTemp = -20; // At this temp or below, use max boost
453
+
454
+ if (outdoorTemp >= mildTemp) {
455
+ return baseBoostMinutes;
305
456
  }
306
457
 
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 [];
458
+ if (outdoorTemp <= coldTemp) {
459
+ return maxBoostMinutes;
323
460
  }
324
461
 
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;
462
+ // Linear interpolation between mild and cold
463
+ const tempRange = mildTemp - coldTemp; // 30 degrees
464
+ const tempBelow = mildTemp - outdoorTemp; // How far below mild
465
+ const boostRange = maxBoostMinutes - baseBoostMinutes; // 240 minutes
328
466
 
329
- return prices.map((p) => p.value <= threshold);
467
+ return baseBoostMinutes + (boostRange * tempBelow) / tempRange;
330
468
  }
331
469
 
332
470
  /**
333
- * Calculate how long boosted heat will be retained before dropping to comfort minimum
334
- * @param {number} outdoorTemp - Outdoor temperature (°C)
335
- * @param {number} comfortMax - Maximum comfort temperature after boost (°C)
336
- * @param {number} comfortMin - Minimum comfort temperature (°C)
337
- * @param {number} heatLossCoefficient - Building heat loss rate
338
- * @returns {number} Retention time in minutes
471
+ * Legacy calculation - kept for reference but no longer used
472
+ * Calculates heat retention based on thermal loss (inverse of what we want)
339
473
  */
340
- function calculateBoostRetention(outdoorTemp, comfortMax, comfortMin, heatLossCoefficient) {
474
+ function calculateBoostRetentionLegacy(outdoorTemp, comfortMax, comfortMin, heatLossCoefficient) {
341
475
  const tempBuffer = comfortMax - comfortMin;
342
476
  const tempDelta = comfortMax - outdoorTemp;
343
477
  const heatLossRate = tempDelta * heatLossCoefficient;
@@ -347,52 +481,9 @@ function calculateBoostRetention(outdoorTemp, comfortMax, comfortMin, heatLossCo
347
481
  return (tempBuffer / heatLossRate) * 60; // Convert hours to minutes
348
482
  }
349
483
 
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
- }
484
+ // Remove the legacy function from export but keep for backwards compat if called directly
485
+ // eslint-disable-next-line no-unused-vars
486
+ const _legacyBoostRetention = calculateBoostRetentionLegacy;
396
487
 
397
488
  // ============================================================================
398
489
  // Schedule Generation
@@ -454,6 +545,7 @@ function createSchedule(prices, outdoorTemp, config) {
454
545
  numPriceGroups = 3, // Number of price clusters (3-5). More groups = narrower boost/coast bands
455
546
  extremeWeatherThreshold = -15, // Disable coasting below this outdoor temp
456
547
  outputTimezone = "Europe/Oslo", // Timezone for schedule output (IANA timezone name)
548
+ clusterPerDay = true, // Cluster prices per day (true) or globally across all days (false)
457
549
  } = validatedConfig;
458
550
 
459
551
  const n = validPrices.length;
@@ -486,13 +578,56 @@ function createSchedule(prices, outdoorTemp, config) {
486
578
  // Use Ckmeans clustering to find natural price breaks
487
579
  // Use effectiveVariationThreshold as minimum variation - if prices don't vary
488
580
  // 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;
581
+ let assignments;
582
+ let breaks;
583
+ let clusterMeans;
584
+ let dailyClusteringInfo = null;
585
+
586
+ // Minimum intervals per day for separate clustering (at least 2 hours of data)
587
+ const minIntervalsPerDay = Math.ceil(120 / intervalMinutes);
588
+
589
+ if (clusterPerDay && n >= minIntervalsPerDay) {
590
+ // Per-day clustering: each day's prices evaluated relative to that day
591
+ const perDayResult = classifyPricesPerDay(
592
+ validPrices,
593
+ outputTimezone,
594
+ effectiveVariationThreshold,
595
+ effectiveNumGroups,
596
+ minIntervalsPerDay
597
+ );
598
+
599
+ assignments = perDayResult.assignments;
600
+ dailyClusteringInfo = perDayResult.dailyClustering;
601
+
602
+ // Use first day's clustering info for breaks/clusterMeans (for backward compatibility)
603
+ // but calculate aggregate thresholds for coast extension logic
604
+ if (dailyClusteringInfo.length > 0) {
605
+ breaks = dailyClusteringInfo[0].breaks;
606
+ clusterMeans = dailyClusteringInfo[0].clusterMeans;
607
+ } else {
608
+ breaks = [];
609
+ clusterMeans = [];
610
+ }
611
+ } else {
612
+ // Global clustering: all prices across all days clustered together (original behavior)
613
+ const clustering = classifyPricesWithCkmeans(priceValues, effectiveVariationThreshold, effectiveNumGroups);
614
+ assignments = clustering.assignments;
615
+ breaks = clustering.breaks;
616
+ clusterMeans = clustering.clusterMeans;
617
+ }
491
618
 
492
619
  // First break = threshold between cheapest (boost) and next group
493
620
  // 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;
621
+ // For per-day clustering, use aggregate thresholds across all days
622
+ let cheapThreshold, expensiveThreshold;
623
+ if (dailyClusteringInfo && dailyClusteringInfo.length > 0) {
624
+ // Use the most conservative thresholds across all days for coast extension
625
+ cheapThreshold = Math.min(...dailyClusteringInfo.map((d) => d.cheapThreshold));
626
+ expensiveThreshold = Math.max(...dailyClusteringInfo.map((d) => d.expensiveThreshold));
627
+ } else {
628
+ cheapThreshold = breaks[0] ?? avgPrice * 0.95;
629
+ expensiveThreshold = breaks[breaks.length - 1] ?? avgPrice * 1.05;
630
+ }
496
631
 
497
632
  // Calculate minimum savings threshold (for tuning info)
498
633
  const minSavingsThreshold = avgPrice * (effectiveMinSavingsPercent / 100);
@@ -593,17 +728,30 @@ function createSchedule(prices, outdoorTemp, config) {
593
728
  minModeDuration,
594
729
  outputTimezone,
595
730
  // 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
- },
731
+ clusterPerDay,
732
+ clustering: dailyClusteringInfo
733
+ ? {
734
+ method: "ckmeans-per-day",
735
+ numGroups: effectiveNumGroups,
736
+ numDays: dailyClusteringInfo.length,
737
+ aggregateCheapThreshold: cheapThreshold,
738
+ aggregateExpensiveThreshold: expensiveThreshold,
739
+ dailyClustering: dailyClusteringInfo,
740
+ rawBoostCount: assignments.filter((a) => a === 0).length,
741
+ rawNormalCount: assignments.filter((a) => a !== 0 && a !== lastGroup).length,
742
+ rawCoastCount: assignments.filter((a) => a === lastGroup).length,
743
+ }
744
+ : {
745
+ numGroups: effectiveNumGroups,
746
+ cheapThreshold,
747
+ expensiveThreshold,
748
+ breaks,
749
+ clusterMeans,
750
+ method: "ckmeans",
751
+ rawBoostCount: assignments.filter((a) => a === 0).length,
752
+ rawNormalCount: assignments.filter((a) => a !== 0 && a !== lastGroup).length,
753
+ rawCoastCount: assignments.filter((a) => a === lastGroup).length,
754
+ },
607
755
  // Final mode counts after thermal constraints and min duration enforcement
608
756
  finalModeCount: modeCount,
609
757
  validationIssues: validatedConfig.validationIssues,
@@ -926,64 +1074,6 @@ function alignToHourlyBoundaries(modes, prices, intervalMinutes, outputTimezone)
926
1074
  return result;
927
1075
  }
928
1076
 
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
1077
  /**
988
1078
  * Get current mode from schedule
989
1079
  *
@@ -1039,17 +1129,16 @@ function getCurrentMode(schedule, currentTime) {
1039
1129
  module.exports = {
1040
1130
  calculateCoastTime,
1041
1131
  calculateBoostRetention,
1042
- findPeaks,
1043
- findTroughs,
1044
- findExpensiveWindows,
1045
1132
  createSchedule,
1046
1133
  getCurrentMode,
1047
1134
  enforceMinimumDurations,
1048
- enforceMaxCoastDuration,
1049
1135
  extendCoastToRunway,
1050
1136
  applyRightAlignedWindows,
1051
1137
  alignToHourlyBoundaries,
1052
1138
  validateConfig,
1053
1139
  ckmeans,
1054
1140
  classifyPricesWithCkmeans,
1141
+ groupPricesByLocalDay,
1142
+ mergePartialDays,
1143
+ classifyPricesPerDay,
1055
1144
  };
@@ -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)