@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
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
const { expect } = require("chai");
|
|
2
2
|
const {
|
|
3
3
|
calculateCoastTime,
|
|
4
|
-
findPeaks,
|
|
5
|
-
findTroughs,
|
|
6
4
|
createSchedule,
|
|
7
5
|
getCurrentMode,
|
|
8
6
|
enforceMinimumDurations,
|
|
9
|
-
findExpensiveWindows,
|
|
10
7
|
validateConfig,
|
|
11
8
|
ckmeans,
|
|
12
9
|
classifyPricesWithCkmeans,
|
|
10
|
+
groupPricesByLocalDay,
|
|
11
|
+
mergePartialDays,
|
|
12
|
+
classifyPricesPerDay,
|
|
13
13
|
} = require("../src/strategy-smart-thermal-functions.js");
|
|
14
14
|
const { DateTime } = require("luxon");
|
|
15
15
|
|
|
@@ -71,61 +71,6 @@ describe("strategy-smart-thermal-functions", function () {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
describe("findPeaks", function () {
|
|
75
|
-
it("should identify top 20% as peaks", function () {
|
|
76
|
-
const prices = [
|
|
77
|
-
{ time: "2026-01-15T00:00:00+01:00", value: 0.5 },
|
|
78
|
-
{ time: "2026-01-15T01:00:00+01:00", value: 0.6 },
|
|
79
|
-
{ time: "2026-01-15T02:00:00+01:00", value: 0.8 },
|
|
80
|
-
{ time: "2026-01-15T03:00:00+01:00", value: 1.2 }, // peak
|
|
81
|
-
{ time: "2026-01-15T04:00:00+01:00", value: 1.5 }, // peak
|
|
82
|
-
];
|
|
83
|
-
const peaks = findPeaks(prices, 20);
|
|
84
|
-
expect(peaks).to.deep.equal([false, false, false, true, true]);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("should handle empty array", function () {
|
|
88
|
-
const peaks = findPeaks([], 20);
|
|
89
|
-
expect(peaks).to.deep.equal([]);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("should handle single price", function () {
|
|
93
|
-
const prices = [{ time: "2026-01-15T00:00:00+01:00", value: 1.0 }];
|
|
94
|
-
const peaks = findPeaks(prices, 20);
|
|
95
|
-
expect(peaks).to.deep.equal([true]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("should handle different threshold", function () {
|
|
99
|
-
const prices = [
|
|
100
|
-
{ time: "2026-01-15T00:00:00+01:00", value: 0.5 },
|
|
101
|
-
{ time: "2026-01-15T01:00:00+01:00", value: 0.8 }, // peak
|
|
102
|
-
{ time: "2026-01-15T02:00:00+01:00", value: 1.0 }, // peak
|
|
103
|
-
{ time: "2026-01-15T03:00:00+01:00", value: 1.2 }, // peak
|
|
104
|
-
];
|
|
105
|
-
const peaks = findPeaks(prices, 50); // Top 50% (includes threshold value)
|
|
106
|
-
expect(peaks).to.deep.equal([false, true, true, true]);
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe("findTroughs", function () {
|
|
111
|
-
it("should identify bottom 20% as troughs", function () {
|
|
112
|
-
const prices = [
|
|
113
|
-
{ time: "2026-01-15T00:00:00+01:00", value: 0.3 }, // trough
|
|
114
|
-
{ time: "2026-01-15T01:00:00+01:00", value: 0.4 }, // trough
|
|
115
|
-
{ time: "2026-01-15T02:00:00+01:00", value: 0.8 },
|
|
116
|
-
{ time: "2026-01-15T03:00:00+01:00", value: 1.0 },
|
|
117
|
-
{ time: "2026-01-15T04:00:00+01:00", value: 1.2 },
|
|
118
|
-
];
|
|
119
|
-
const troughs = findTroughs(prices, 20);
|
|
120
|
-
expect(troughs).to.deep.equal([true, true, false, false, false]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("should handle empty array", function () {
|
|
124
|
-
const troughs = findTroughs([], 20);
|
|
125
|
-
expect(troughs).to.deep.equal([]);
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
74
|
describe("createSchedule", function () {
|
|
130
75
|
it("should create schedule with boost and coast periods", function () {
|
|
131
76
|
const prices = [
|
|
@@ -327,14 +272,9 @@ describe("strategy-smart-thermal-functions", function () {
|
|
|
327
272
|
}
|
|
328
273
|
|
|
329
274
|
const config = {
|
|
330
|
-
setpoint: 22,
|
|
331
|
-
comfortMin: 20,
|
|
332
|
-
comfortMax: 24,
|
|
333
275
|
heatLossCoefficient: 0.05,
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
minCoastMinutes: 60, // Require 1 hour minimum
|
|
337
|
-
minBoostMinutes: 60,
|
|
276
|
+
minModeDuration: 60, // Require 1 hour minimum
|
|
277
|
+
minSavingsPercent: 5,
|
|
338
278
|
};
|
|
339
279
|
|
|
340
280
|
const schedule = createSchedule(prices, 5, config);
|
|
@@ -403,35 +343,6 @@ describe("strategy-smart-thermal-functions", function () {
|
|
|
403
343
|
});
|
|
404
344
|
});
|
|
405
345
|
|
|
406
|
-
describe("findExpensiveWindows", function () {
|
|
407
|
-
it("should find expensive windows above average", function () {
|
|
408
|
-
const prices = [0.5, 0.6, 1.2, 1.5, 0.7, 0.4];
|
|
409
|
-
const avgPrice = 0.82;
|
|
410
|
-
const windows = findExpensiveWindows(prices, avgPrice, 0);
|
|
411
|
-
expect(windows.length).to.equal(1);
|
|
412
|
-
expect(windows[0].start).to.equal(2);
|
|
413
|
-
expect(windows[0].end).to.equal(3);
|
|
414
|
-
expect(windows[0].peakPrice).to.equal(1.5);
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
it("should respect minimum savings threshold", function () {
|
|
418
|
-
const prices = [0.5, 0.6, 0.85, 0.9, 0.7, 0.4]; // prices close to avg
|
|
419
|
-
const avgPrice = 0.65;
|
|
420
|
-
const threshold = 0.3; // High threshold
|
|
421
|
-
const windows = findExpensiveWindows(prices, avgPrice, threshold);
|
|
422
|
-
// Only 0.9 exceeds avgPrice + 0.3 = 0.95? No, 0.9 < 0.95
|
|
423
|
-
expect(windows.length).to.equal(0);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("should sort windows by peak price (highest first)", function () {
|
|
427
|
-
const prices = [0.5, 1.0, 0.5, 0.5, 1.5, 0.5]; // Two separate peaks
|
|
428
|
-
const avgPrice = 0.75;
|
|
429
|
-
const windows = findExpensiveWindows(prices, avgPrice, 0);
|
|
430
|
-
expect(windows.length).to.equal(2);
|
|
431
|
-
expect(windows[0].peakPrice).to.be.greaterThan(windows[1].peakPrice);
|
|
432
|
-
});
|
|
433
|
-
});
|
|
434
|
-
|
|
435
346
|
describe("createSchedule with negative prices", function () {
|
|
436
347
|
it("should handle negative prices", function () {
|
|
437
348
|
const prices = [
|
|
@@ -859,6 +770,7 @@ describe("strategy-smart-thermal-functions", function () {
|
|
|
859
770
|
heatLossCoefficient: 0.05,
|
|
860
771
|
minModeDuration: 15,
|
|
861
772
|
minSavingsPercent: 0, // Allow any clustering
|
|
773
|
+
clusterPerDay: false, // Use global clustering for this test
|
|
862
774
|
};
|
|
863
775
|
|
|
864
776
|
const schedule = createSchedule(prices, 5, config);
|
|
@@ -871,4 +783,256 @@ describe("strategy-smart-thermal-functions", function () {
|
|
|
871
783
|
expect(schedule[0].tuning.clustering.method).to.equal("ckmeans");
|
|
872
784
|
});
|
|
873
785
|
});
|
|
786
|
+
|
|
787
|
+
describe("Per-day clustering", function () {
|
|
788
|
+
describe("groupPricesByLocalDay", function () {
|
|
789
|
+
it("should group prices by local calendar day", function () {
|
|
790
|
+
const prices = [
|
|
791
|
+
{ time: "2026-01-15T23:00:00Z", value: 100 }, // 00:00 Oslo = Jan 16
|
|
792
|
+
{ time: "2026-01-16T00:00:00Z", value: 110 }, // 01:00 Oslo = Jan 16
|
|
793
|
+
{ time: "2026-01-16T10:00:00Z", value: 120 }, // 11:00 Oslo = Jan 16
|
|
794
|
+
{ time: "2026-01-16T23:00:00Z", value: 130 }, // 00:00 Oslo = Jan 17
|
|
795
|
+
{ time: "2026-01-17T05:00:00Z", value: 140 }, // 06:00 Oslo = Jan 17
|
|
796
|
+
];
|
|
797
|
+
|
|
798
|
+
const groups = groupPricesByLocalDay(prices, "Europe/Oslo");
|
|
799
|
+
|
|
800
|
+
expect(groups.size).to.equal(2);
|
|
801
|
+
expect(groups.get("2026-01-16")).to.have.length(3);
|
|
802
|
+
expect(groups.get("2026-01-17")).to.have.length(2);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("should preserve original indices", function () {
|
|
806
|
+
const prices = [
|
|
807
|
+
{ time: "2026-01-15T23:00:00Z", value: 100 },
|
|
808
|
+
{ time: "2026-01-16T05:00:00Z", value: 110 },
|
|
809
|
+
];
|
|
810
|
+
|
|
811
|
+
const groups = groupPricesByLocalDay(prices, "Europe/Oslo");
|
|
812
|
+
const day16 = groups.get("2026-01-16");
|
|
813
|
+
|
|
814
|
+
expect(day16[0].originalIndex).to.equal(0);
|
|
815
|
+
expect(day16[1].originalIndex).to.equal(1);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
describe("mergePartialDays", function () {
|
|
820
|
+
it("should not merge days with enough intervals", function () {
|
|
821
|
+
const sortedDays = [
|
|
822
|
+
["2026-01-15", [{ originalIndex: 0 }, { originalIndex: 1 }, { originalIndex: 2 }, { originalIndex: 3 }]],
|
|
823
|
+
["2026-01-16", [{ originalIndex: 4 }, { originalIndex: 5 }, { originalIndex: 6 }, { originalIndex: 7 }]],
|
|
824
|
+
];
|
|
825
|
+
|
|
826
|
+
const result = mergePartialDays(sortedDays, 4);
|
|
827
|
+
|
|
828
|
+
expect(result).to.have.length(2);
|
|
829
|
+
expect(result[0][0]).to.equal("2026-01-15");
|
|
830
|
+
expect(result[1][0]).to.equal("2026-01-16");
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("should merge small first day with next day", function () {
|
|
834
|
+
const sortedDays = [
|
|
835
|
+
["2026-01-15", [{ originalIndex: 0 }, { originalIndex: 1 }]], // Too small
|
|
836
|
+
["2026-01-16", [{ originalIndex: 2 }, { originalIndex: 3 }, { originalIndex: 4 }, { originalIndex: 5 }]],
|
|
837
|
+
];
|
|
838
|
+
|
|
839
|
+
const result = mergePartialDays(sortedDays, 4);
|
|
840
|
+
|
|
841
|
+
expect(result).to.have.length(1);
|
|
842
|
+
expect(result[0][0]).to.equal("2026-01-15+2026-01-16");
|
|
843
|
+
expect(result[0][1]).to.have.length(6);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("should merge small last day with previous day", function () {
|
|
847
|
+
const sortedDays = [
|
|
848
|
+
["2026-01-15", [{ originalIndex: 0 }, { originalIndex: 1 }, { originalIndex: 2 }, { originalIndex: 3 }]],
|
|
849
|
+
["2026-01-16", [{ originalIndex: 4 }, { originalIndex: 5 }]], // Too small
|
|
850
|
+
];
|
|
851
|
+
|
|
852
|
+
const result = mergePartialDays(sortedDays, 4);
|
|
853
|
+
|
|
854
|
+
expect(result).to.have.length(1);
|
|
855
|
+
expect(result[0][0]).to.equal("2026-01-15+2026-01-16");
|
|
856
|
+
expect(result[0][1]).to.have.length(6);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
describe("classifyPricesPerDay", function () {
|
|
861
|
+
it("should cluster each day independently", function () {
|
|
862
|
+
// Day 1: Low prices (50-100), Day 2: High prices (150-200)
|
|
863
|
+
const prices = [
|
|
864
|
+
// Day 1 (Oslo time) - all low prices
|
|
865
|
+
{ time: "2026-01-15T23:00:00Z", value: 50 }, // Jan 16 00:00 Oslo
|
|
866
|
+
{ time: "2026-01-16T02:00:00Z", value: 60 }, // Jan 16 03:00 Oslo
|
|
867
|
+
{ time: "2026-01-16T05:00:00Z", value: 70 }, // Jan 16 06:00 Oslo
|
|
868
|
+
{ time: "2026-01-16T08:00:00Z", value: 80 }, // Jan 16 09:00 Oslo
|
|
869
|
+
{ time: "2026-01-16T11:00:00Z", value: 90 }, // Jan 16 12:00 Oslo
|
|
870
|
+
{ time: "2026-01-16T14:00:00Z", value: 100 }, // Jan 16 15:00 Oslo
|
|
871
|
+
{ time: "2026-01-16T17:00:00Z", value: 90 }, // Jan 16 18:00 Oslo
|
|
872
|
+
{ time: "2026-01-16T20:00:00Z", value: 80 }, // Jan 16 21:00 Oslo
|
|
873
|
+
// Day 2 (Oslo time) - all high prices
|
|
874
|
+
{ time: "2026-01-16T23:00:00Z", value: 150 }, // Jan 17 00:00 Oslo
|
|
875
|
+
{ time: "2026-01-17T02:00:00Z", value: 160 }, // Jan 17 03:00 Oslo
|
|
876
|
+
{ time: "2026-01-17T05:00:00Z", value: 170 }, // Jan 17 06:00 Oslo
|
|
877
|
+
{ time: "2026-01-17T08:00:00Z", value: 180 }, // Jan 17 09:00 Oslo
|
|
878
|
+
{ time: "2026-01-17T11:00:00Z", value: 190 }, // Jan 17 12:00 Oslo
|
|
879
|
+
{ time: "2026-01-17T14:00:00Z", value: 200 }, // Jan 17 15:00 Oslo
|
|
880
|
+
{ time: "2026-01-17T17:00:00Z", value: 190 }, // Jan 17 18:00 Oslo
|
|
881
|
+
{ time: "2026-01-17T20:00:00Z", value: 180 }, // Jan 17 21:00 Oslo
|
|
882
|
+
];
|
|
883
|
+
|
|
884
|
+
const result = classifyPricesPerDay(prices, "Europe/Oslo", 5, 3, 4);
|
|
885
|
+
|
|
886
|
+
expect(result.assignments).to.have.length(16);
|
|
887
|
+
expect(result.dailyClustering).to.have.length(2);
|
|
888
|
+
|
|
889
|
+
// Each day should have its own clustering
|
|
890
|
+
expect(result.dailyClustering[0].date).to.equal("2026-01-16");
|
|
891
|
+
expect(result.dailyClustering[1].date).to.equal("2026-01-17");
|
|
892
|
+
|
|
893
|
+
// Day 1: 50 should be cheapest (boost=0), 100 should be expensive (coast=2)
|
|
894
|
+
expect(result.assignments[0]).to.equal(0); // 50 = boost
|
|
895
|
+
expect(result.assignments[5]).to.equal(2); // 100 = coast
|
|
896
|
+
|
|
897
|
+
// Day 2: 150 should be cheapest (boost=0), 200 should be expensive (coast=2)
|
|
898
|
+
expect(result.assignments[8]).to.equal(0); // 150 = boost (for day 2!)
|
|
899
|
+
expect(result.assignments[13]).to.equal(2); // 200 = coast
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("should include per-day stats in dailyClustering", function () {
|
|
903
|
+
const prices = [
|
|
904
|
+
{ time: "2026-01-15T23:00:00Z", value: 50 },
|
|
905
|
+
{ time: "2026-01-16T02:00:00Z", value: 60 },
|
|
906
|
+
{ time: "2026-01-16T05:00:00Z", value: 70 },
|
|
907
|
+
{ time: "2026-01-16T08:00:00Z", value: 80 },
|
|
908
|
+
{ time: "2026-01-16T11:00:00Z", value: 90 },
|
|
909
|
+
{ time: "2026-01-16T14:00:00Z", value: 100 },
|
|
910
|
+
{ time: "2026-01-16T17:00:00Z", value: 90 },
|
|
911
|
+
{ time: "2026-01-16T20:00:00Z", value: 80 },
|
|
912
|
+
];
|
|
913
|
+
|
|
914
|
+
const result = classifyPricesPerDay(prices, "Europe/Oslo", 0, 3, 4);
|
|
915
|
+
|
|
916
|
+
expect(result.dailyClustering[0]).to.have.property("avgPrice");
|
|
917
|
+
expect(result.dailyClustering[0]).to.have.property("minPrice");
|
|
918
|
+
expect(result.dailyClustering[0]).to.have.property("maxPrice");
|
|
919
|
+
expect(result.dailyClustering[0]).to.have.property("breaks");
|
|
920
|
+
expect(result.dailyClustering[0]).to.have.property("clusterMeans");
|
|
921
|
+
expect(result.dailyClustering[0].minPrice).to.equal(50);
|
|
922
|
+
expect(result.dailyClustering[0].maxPrice).to.equal(100);
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
describe("createSchedule with clusterPerDay", function () {
|
|
927
|
+
it("should use per-day clustering by default", function () {
|
|
928
|
+
// Day 1: prices 50-100, Day 2: prices 150-200
|
|
929
|
+
const prices = [
|
|
930
|
+
{ time: "2026-01-15T23:00:00Z", value: 50 },
|
|
931
|
+
{ time: "2026-01-16T02:00:00Z", value: 75 },
|
|
932
|
+
{ time: "2026-01-16T05:00:00Z", value: 100 },
|
|
933
|
+
{ time: "2026-01-16T08:00:00Z", value: 75 },
|
|
934
|
+
{ time: "2026-01-16T11:00:00Z", value: 50 },
|
|
935
|
+
{ time: "2026-01-16T14:00:00Z", value: 75 },
|
|
936
|
+
{ time: "2026-01-16T17:00:00Z", value: 100 },
|
|
937
|
+
{ time: "2026-01-16T20:00:00Z", value: 75 },
|
|
938
|
+
{ time: "2026-01-16T23:00:00Z", value: 150 },
|
|
939
|
+
{ time: "2026-01-17T02:00:00Z", value: 175 },
|
|
940
|
+
{ time: "2026-01-17T05:00:00Z", value: 200 },
|
|
941
|
+
{ time: "2026-01-17T08:00:00Z", value: 175 },
|
|
942
|
+
{ time: "2026-01-17T11:00:00Z", value: 150 },
|
|
943
|
+
{ time: "2026-01-17T14:00:00Z", value: 175 },
|
|
944
|
+
{ time: "2026-01-17T17:00:00Z", value: 200 },
|
|
945
|
+
{ time: "2026-01-17T20:00:00Z", value: 175 },
|
|
946
|
+
];
|
|
947
|
+
|
|
948
|
+
const config = {
|
|
949
|
+
heatLossCoefficient: 0.05,
|
|
950
|
+
minModeDuration: 60,
|
|
951
|
+
minSavingsPercent: 0,
|
|
952
|
+
// clusterPerDay defaults to true
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const schedule = createSchedule(prices, 5, config);
|
|
956
|
+
|
|
957
|
+
expect(schedule[0].tuning.clusterPerDay).to.equal(true);
|
|
958
|
+
expect(schedule[0].tuning.clustering.method).to.equal("ckmeans-per-day");
|
|
959
|
+
expect(schedule[0].tuning.clustering).to.have.property("dailyClustering");
|
|
960
|
+
expect(schedule[0].tuning.clustering.dailyClustering).to.have.length(2);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
it("should use global clustering when clusterPerDay is false", function () {
|
|
964
|
+
const prices = [
|
|
965
|
+
{ time: "2026-01-15T23:00:00Z", value: 50 },
|
|
966
|
+
{ time: "2026-01-16T02:00:00Z", value: 75 },
|
|
967
|
+
{ time: "2026-01-16T05:00:00Z", value: 100 },
|
|
968
|
+
{ time: "2026-01-16T08:00:00Z", value: 75 },
|
|
969
|
+
{ time: "2026-01-16T11:00:00Z", value: 50 },
|
|
970
|
+
{ time: "2026-01-16T14:00:00Z", value: 75 },
|
|
971
|
+
{ time: "2026-01-16T17:00:00Z", value: 100 },
|
|
972
|
+
{ time: "2026-01-16T20:00:00Z", value: 75 },
|
|
973
|
+
];
|
|
974
|
+
|
|
975
|
+
const config = {
|
|
976
|
+
heatLossCoefficient: 0.05,
|
|
977
|
+
minModeDuration: 60,
|
|
978
|
+
minSavingsPercent: 0,
|
|
979
|
+
clusterPerDay: false,
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const schedule = createSchedule(prices, 5, config);
|
|
983
|
+
|
|
984
|
+
expect(schedule[0].tuning.clusterPerDay).to.equal(false);
|
|
985
|
+
expect(schedule[0].tuning.clustering.method).to.equal("ckmeans");
|
|
986
|
+
expect(schedule[0].tuning.clustering).to.not.have.property("dailyClustering");
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
it("should find boost/coast on EACH day with per-day clustering", function () {
|
|
990
|
+
// This is the key test: without per-day clustering, day 1's prices
|
|
991
|
+
// would all be "normal" compared to day 2's higher prices.
|
|
992
|
+
// With per-day clustering, each day gets its own boost/coast hours.
|
|
993
|
+
const prices = [
|
|
994
|
+
// Day 1: Low price range (50-100)
|
|
995
|
+
{ time: "2026-01-15T23:00:00Z", value: 50 }, // Cheapest on day 1
|
|
996
|
+
{ time: "2026-01-16T02:00:00Z", value: 75 },
|
|
997
|
+
{ time: "2026-01-16T05:00:00Z", value: 100 }, // Most expensive on day 1
|
|
998
|
+
{ time: "2026-01-16T08:00:00Z", value: 75 },
|
|
999
|
+
{ time: "2026-01-16T11:00:00Z", value: 50 }, // Cheapest on day 1
|
|
1000
|
+
{ time: "2026-01-16T14:00:00Z", value: 75 },
|
|
1001
|
+
{ time: "2026-01-16T17:00:00Z", value: 100 }, // Most expensive on day 1
|
|
1002
|
+
{ time: "2026-01-16T20:00:00Z", value: 75 },
|
|
1003
|
+
// Day 2: High price range (150-200)
|
|
1004
|
+
{ time: "2026-01-16T23:00:00Z", value: 150 }, // Cheapest on day 2
|
|
1005
|
+
{ time: "2026-01-17T02:00:00Z", value: 175 },
|
|
1006
|
+
{ time: "2026-01-17T05:00:00Z", value: 200 }, // Most expensive on day 2
|
|
1007
|
+
{ time: "2026-01-17T08:00:00Z", value: 175 },
|
|
1008
|
+
{ time: "2026-01-17T11:00:00Z", value: 150 }, // Cheapest on day 2
|
|
1009
|
+
{ time: "2026-01-17T14:00:00Z", value: 175 },
|
|
1010
|
+
{ time: "2026-01-17T17:00:00Z", value: 200 }, // Most expensive on day 2
|
|
1011
|
+
{ time: "2026-01-17T20:00:00Z", value: 175 },
|
|
1012
|
+
];
|
|
1013
|
+
|
|
1014
|
+
const config = {
|
|
1015
|
+
heatLossCoefficient: 0.02, // Well-insulated building (long coast time)
|
|
1016
|
+
minModeDuration: 60,
|
|
1017
|
+
minSavingsPercent: 0,
|
|
1018
|
+
clusterPerDay: true,
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const schedule = createSchedule(prices, 5, config);
|
|
1022
|
+
|
|
1023
|
+
// Count modes per day
|
|
1024
|
+
const day1Modes = schedule.slice(0, 8).map(s => s.mode);
|
|
1025
|
+
const day2Modes = schedule.slice(8, 16).map(s => s.mode);
|
|
1026
|
+
|
|
1027
|
+
// Day 1 should have some boost (at price 50) and some coast (at price 100)
|
|
1028
|
+
expect(day1Modes).to.include("boost");
|
|
1029
|
+
expect(day1Modes).to.include("coast");
|
|
1030
|
+
|
|
1031
|
+
// Day 2 should ALSO have boost (at price 150) and coast (at price 200)
|
|
1032
|
+
// This is the key difference from global clustering!
|
|
1033
|
+
expect(day2Modes).to.include("boost");
|
|
1034
|
+
expect(day2Modes).to.include("coast");
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
});
|
|
874
1038
|
});
|
|
@@ -24,21 +24,16 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("should output current
|
|
27
|
+
it("should output current mode on output 1", function (done) {
|
|
28
28
|
const flow = [
|
|
29
29
|
{
|
|
30
|
-
id: "n1",
|
|
31
|
-
type: "ps-strategy-smart-thermal",
|
|
32
|
-
name: "thermal test",
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
peakThreshold: 20,
|
|
38
|
-
intervalMinutes: 60,
|
|
39
|
-
sendSchedule: true,
|
|
40
|
-
wires: [["n2"], ["n3"]],
|
|
41
|
-
},
|
|
30
|
+
id: "n1",
|
|
31
|
+
type: "ps-strategy-smart-thermal",
|
|
32
|
+
name: "thermal test",
|
|
33
|
+
heatLossCoefficient: 0.05,
|
|
34
|
+
sendSchedule: true,
|
|
35
|
+
wires: [["n2"], ["n3"]],
|
|
36
|
+
},
|
|
42
37
|
{ id: "n2", type: "helper" },
|
|
43
38
|
{ id: "n3", type: "helper" },
|
|
44
39
|
];
|
|
@@ -75,18 +70,13 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
75
70
|
it("should output schedule on output 2 when sendSchedule is true", function (done) {
|
|
76
71
|
const flow = [
|
|
77
72
|
{
|
|
78
|
-
id: "n1",
|
|
79
|
-
type: "ps-strategy-smart-thermal",
|
|
80
|
-
name: "thermal test",
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
peakThreshold: 20,
|
|
86
|
-
intervalMinutes: 60,
|
|
87
|
-
sendSchedule: true,
|
|
88
|
-
wires: [["n2"], ["n3"]],
|
|
89
|
-
},
|
|
73
|
+
id: "n1",
|
|
74
|
+
type: "ps-strategy-smart-thermal",
|
|
75
|
+
name: "thermal test",
|
|
76
|
+
heatLossCoefficient: 0.05,
|
|
77
|
+
sendSchedule: true,
|
|
78
|
+
wires: [["n2"], ["n3"]],
|
|
79
|
+
},
|
|
90
80
|
{ id: "n2", type: "helper" },
|
|
91
81
|
{ id: "n3", type: "helper" },
|
|
92
82
|
];
|
|
@@ -235,14 +225,11 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
235
225
|
it("should ignore message not for this node", function (done) {
|
|
236
226
|
const flow = [
|
|
237
227
|
{
|
|
238
|
-
id: "n1",
|
|
239
|
-
type: "ps-strategy-smart-thermal",
|
|
240
|
-
name: "thermal test",
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
comfortMax: 24,
|
|
244
|
-
wires: [["n2"], ["n3"]],
|
|
245
|
-
},
|
|
228
|
+
id: "n1",
|
|
229
|
+
type: "ps-strategy-smart-thermal",
|
|
230
|
+
name: "thermal test",
|
|
231
|
+
wires: [["n2"], ["n3"]],
|
|
232
|
+
},
|
|
246
233
|
{ id: "n2", type: "helper" },
|
|
247
234
|
{ id: "n3", type: "helper" },
|
|
248
235
|
];
|
|
@@ -274,14 +261,11 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
274
261
|
it("should warn when price data is missing", function (done) {
|
|
275
262
|
const flow = [
|
|
276
263
|
{
|
|
277
|
-
id: "n1",
|
|
278
|
-
type: "ps-strategy-smart-thermal",
|
|
279
|
-
name: "thermal test",
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
comfortMax: 24,
|
|
283
|
-
wires: [["n2"], ["n3"]],
|
|
284
|
-
},
|
|
264
|
+
id: "n1",
|
|
265
|
+
type: "ps-strategy-smart-thermal",
|
|
266
|
+
name: "thermal test",
|
|
267
|
+
wires: [["n2"], ["n3"]],
|
|
268
|
+
},
|
|
285
269
|
{ id: "n2", type: "helper" },
|
|
286
270
|
{ id: "n3", type: "helper" },
|
|
287
271
|
];
|
|
@@ -312,14 +296,11 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
312
296
|
it("should warn when outdoor temperature is missing", function (done) {
|
|
313
297
|
const flow = [
|
|
314
298
|
{
|
|
315
|
-
id: "n1",
|
|
316
|
-
type: "ps-strategy-smart-thermal",
|
|
317
|
-
name: "thermal test",
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
comfortMax: 24,
|
|
321
|
-
wires: [["n2"], ["n3"]],
|
|
322
|
-
},
|
|
299
|
+
id: "n1",
|
|
300
|
+
type: "ps-strategy-smart-thermal",
|
|
301
|
+
name: "thermal test",
|
|
302
|
+
wires: [["n2"], ["n3"]],
|
|
303
|
+
},
|
|
323
304
|
{ id: "n2", type: "helper" },
|
|
324
305
|
{ id: "n3", type: "helper" },
|
|
325
306
|
];
|
|
@@ -350,15 +331,12 @@ describe("ps-strategy-smart-thermal node", function () {
|
|
|
350
331
|
it("should allow config override via message", function (done) {
|
|
351
332
|
const flow = [
|
|
352
333
|
{
|
|
353
|
-
id: "n1",
|
|
354
|
-
type: "ps-strategy-smart-thermal",
|
|
355
|
-
name: "thermal test",
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
heatLossCoefficient: 0.05,
|
|
360
|
-
wires: [["n2"], ["n3"]],
|
|
361
|
-
},
|
|
334
|
+
id: "n1",
|
|
335
|
+
type: "ps-strategy-smart-thermal",
|
|
336
|
+
name: "thermal test",
|
|
337
|
+
heatLossCoefficient: 0.05,
|
|
338
|
+
wires: [["n2"], ["n3"]],
|
|
339
|
+
},
|
|
362
340
|
{ id: "n2", type: "helper" },
|
|
363
341
|
{ id: "n3", type: "helper" },
|
|
364
342
|
];
|
package/nul
DELETED
|
File without changes
|