feat(backend): active period, heater state and weekly program from history
The heat pump view says whether CWU and room are in comfort or economy, from the target compared with the setpoints (valid with program 1 only). GET /api/program reads the comfort intervals per weekday from the polls instead of asking the controller, whose program registers need writes. Adds the history analysis of 2026-10 to docs/frisko. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8ada0e6a8
commit
36875dd103
25 files changed
+929
-34
No files matched your search
@@ -13,6 +13,14 @@ day (Europe/Warsaw, today included) from the raw `heat_pump` polls: `days[]` wit
|
||||
`heaterHours`, `avgOutdoorTemp`, `minCwuTemp`, `maxCwuTemp` (all null on a day without polls) and `totals` over the range. A poll holds its outputs
|
||||
until the next one, at most 600 s, so gaps add no hours; a start is a poll above 0 after a poll at 0, so the 300 s polling misses short cycles.
|
||||
|
||||
`GET /api/program?days=14` (7 to 30, default 14; 400 otherwise, 502 when InfluxDB is down; example `src/test/resources/api/program.json`) is the weekly
|
||||
program as the history shows it, not read from the controller: `weekdays[]` from Monday (`day` 1) to Sunday, each with `cwu` and `room`:
|
||||
`comfort` (intervals `from`/`to`, local "HH:MM", "24:00" at midnight), `observedDays`, `agreeingDays` and `confidence` (`confirmed`: at least
|
||||
two days of that weekday agree within 5 minutes, `seen_once`, `no_data`). A boundary is the middle between the two polls where the period
|
||||
changes (`Periods`), rounded to 5 minutes; an override, ambiguous setpoints (e.g. during our writes) or a gap over 600 s drop the interval.
|
||||
Today is left out. `/api/heat-pump` has `periods` (`cwu`, `room`: `comfort`, `economy`, `unknown`, `override`; `program`, `programText`),
|
||||
and `heaterOn` is also true while `zb_state` = 2.
|
||||
|
||||
`GET /api/battery` is the Deye battery, example `src/test/resources/api/battery.json`: live `socPct`, `powerW` (+ discharge / − charge), `voltageV`,
|
||||
`currentA`, `temperatureC`, `today` and `total` energy (`chargeKwh`, `dischargeKwh`), null when the Deye is stale (older than 10 min or down).
|
||||
The settings come from the gateway's slower `deye_settings` reading and are stale after 30 min (`battery-settings-stale.json`):
|
||||
|
||||
@@ -19,6 +19,7 @@ import home.backend.application.EnergyService;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.LeaseRenewer;
|
||||
import home.backend.application.PiSensorService;
|
||||
@@ -60,6 +61,7 @@ final class Backend {
|
||||
new HistoryService(influx, clock), setpoints, new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, commands, clock),
|
||||
new AuditService(new InfluxAuditRepository(influxSettings), clock), new StatsService(influx, clock),
|
||||
new ProgramService(influx, clock),
|
||||
weather(influxSettings, clock), commands,
|
||||
Authentication.create(settings.auth(), clock));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import home.backend.application.GatewayRefresh;
|
||||
import home.backend.application.HealthService;
|
||||
import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.SetpointService;
|
||||
@@ -88,7 +89,7 @@ public class ApiServer {
|
||||
public ApiServer(int port, HeatPumpService heatPump, EnergyService energy,
|
||||
EnergyDetailsService energyDetails, HealthService health,
|
||||
HistoryService history, SetpointService setpoints, PiSensorService piSensor,
|
||||
BatteryService battery, AuditService audit, StatsService stats, WeatherService weather,
|
||||
BatteryService battery, AuditService audit, StatsService stats, ProgramService program, WeatherService weather,
|
||||
GatewayRefresh refresh, Authentication auth) throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(port), 0);
|
||||
server.setExecutor(executor);
|
||||
@@ -131,6 +132,7 @@ public class ApiServer {
|
||||
route("/api/history", exchange -> handleHistory(exchange, history));
|
||||
route("/api/audit", exchange -> handleAudit(exchange, audit));
|
||||
route("/api/stats", exchange -> handleStats(exchange, stats));
|
||||
route("/api/program", exchange -> handleProgram(exchange, program));
|
||||
route("/api/setpoints", exchange -> handle(exchange, Map.of(
|
||||
"GET", () -> getSetpoints(exchange, setpoints),
|
||||
"POST", () -> guarded(exchange, body -> postSetpoint(exchange, body, setpoints)))));
|
||||
@@ -410,6 +412,18 @@ public class ApiServer {
|
||||
}));
|
||||
}
|
||||
|
||||
private void handleProgram(HttpExchange exchange, ProgramService program) throws IOException {
|
||||
handle(exchange, Map.of("GET", () -> {
|
||||
try {
|
||||
send(exchange, 200, program.program(parseQuery(exchange.getRequestURI().getRawQuery()).get("days")));
|
||||
} catch (IllegalArgumentException e) {
|
||||
send(exchange, 400, error(e.getMessage()));
|
||||
} catch (IOException e) {
|
||||
send(exchange, 502, error("program unavailable"));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
interface Action {
|
||||
void run() throws IOException;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
@@ -19,7 +22,9 @@ final class InfluxSamples {
|
||||
}
|
||||
|
||||
static String query(long fromMs, long toMs) {
|
||||
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
return "SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp,"
|
||||
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
|
||||
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
|
||||
+ " FROM \"heat_pump\" WHERE time >= " + fromMs + "ms AND time < " + toMs + "ms";
|
||||
}
|
||||
|
||||
@@ -39,13 +44,24 @@ final class InfluxSamples {
|
||||
for (JsonNode row : series.path("values")) {
|
||||
samples.add(new HeatPumpSample(Instant.ofEpochMilli(row.get(0).asLong()),
|
||||
number(row, 1), number(row, 2), number(row, 3), number(row, 4),
|
||||
number(row, 5), number(row, 6)));
|
||||
number(row, 5), number(row, 6), periods(row)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
private static Periods periods(JsonNode row) {
|
||||
var targets = new Targets(number(row, 8), null, number(row, 9));
|
||||
var setpoints = new Setpoints(whole(row, 10), whole(row, 11), whole(row, 12), whole(row, 13));
|
||||
return Periods.of(whole(row, 7), targets, setpoints);
|
||||
}
|
||||
|
||||
private static Integer whole(JsonNode row, int column) {
|
||||
Double value = number(row, column);
|
||||
return value == null ? null : (int) Math.round(value);
|
||||
}
|
||||
|
||||
private static Double number(JsonNode row, int column) {
|
||||
return row.get(column).isNumber() ? row.get(column).asDouble() : null;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.HeatPumpView.Temperatures;
|
||||
import home.backend.domain.Periods;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
@@ -22,6 +23,7 @@ import static home.backend.application.SnapshotFields.whole;
|
||||
public class HeatPumpService {
|
||||
|
||||
private static final String DATABASE = "Dom_Pompa";
|
||||
private static final int HEATER_STATE = 2;
|
||||
private static final State[] STATES = State.values();
|
||||
|
||||
private final GatewayClient gateway;
|
||||
@@ -43,7 +45,11 @@ public class HeatPumpService {
|
||||
|
||||
private static HeatPumpView view(Instant time, boolean stale, Map<String, Double> f) {
|
||||
Integer alarmCode = whole(f, "alarm_code");
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), on(f, "output_heater"),
|
||||
var targets = new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp"));
|
||||
var setpoints = new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort"));
|
||||
return new HeatPumpView(time, stale, state(f), whole(f, "output_compressor"), heaterOn(f),
|
||||
on(f, "alarm") || on(f, "alarm_code"), alarmCode, alarmText(alarmCode),
|
||||
new Pumps(on(f, "output_co"), on(f, "output_buffer"), on(f, "output_circulation"),
|
||||
on(f, "output_ground"), on(f, "output_cwu")),
|
||||
@@ -51,10 +57,12 @@ public class HeatPumpService {
|
||||
tenths(f, "co_buffer_temp"), tenths(f, "cwu_temp"), tenths(f, "ground_in_temp"),
|
||||
tenths(f, "ground_out_temp"), tenths(f, "pump_outlet_temp"),
|
||||
tenths(f, "outdoor_avg_temp")),
|
||||
new Targets(tenths(f, "target_room_temp"), tenths(f, "target_co_temp"),
|
||||
tenths(f, "target_cwu_temp")),
|
||||
new Setpoints(whole(f, "setpoint_co_economy"), whole(f, "setpoint_co_comfort"),
|
||||
whole(f, "setpoint_cwu_economy"), whole(f, "setpoint_cwu_comfort")));
|
||||
targets, setpoints, Periods.of(whole(f, "program"), targets, setpoints));
|
||||
}
|
||||
|
||||
/** The heater output, or zb_state 2, which is the CWU heater too. */
|
||||
private static boolean heaterOn(Map<String, Double> f) {
|
||||
return on(f, "output_heater") || Integer.valueOf(HEATER_STATE).equals(whole(f, "zb_state"));
|
||||
}
|
||||
|
||||
/** Codes of register 4011 as the vendor app names them; unverified on our device. */
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import home.backend.domain.ProgramView.Weekday;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* F6: the weekly program as the history shows it, without asking the controller (its program
|
||||
* registers sit behind a shared selector, so reading them would mean writing). A transition between
|
||||
* two adjacent polls, economy to comfort or back, puts a comfort boundary halfway between them; a
|
||||
* poll in another period (override, or setpoints that make the periods ambiguous, for instance while
|
||||
* we write one) breaks the chain, as does a gap in the data. Times are local, so a day with a change
|
||||
* of time needs no special case. Today is left out: it is not over.
|
||||
*/
|
||||
public class ProgramService {
|
||||
|
||||
private static final int DEFAULT_DAYS = 14;
|
||||
private static final int MIN_DAYS = 7;
|
||||
private static final int MAX_DAYS = 30;
|
||||
private static final Duration MAX_GAP = StatsService.MAX_HOLD;
|
||||
private static final int STEP_MINUTES = 5;
|
||||
private static final int MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
private record Span(int from, int to) {
|
||||
}
|
||||
|
||||
/** What the history says about one circuit: days with a known period and their comfort spans. */
|
||||
private record Observed(Set<LocalDate> days, Map<LocalDate, List<Span>> comfort) {
|
||||
}
|
||||
|
||||
private final HeatPumpSamples repository;
|
||||
private final Clock clock;
|
||||
|
||||
public ProgramService(HeatPumpSamples repository, Clock clock) {
|
||||
this.repository = repository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param days how many past days to read, 7 to 30; null means 14
|
||||
* @throws IllegalArgumentException for anything else
|
||||
*/
|
||||
public ProgramView program(String days) throws IOException {
|
||||
int length = length(days);
|
||||
LocalDate today = clock.instant().atZone(StatsService.ZONE).toLocalDate();
|
||||
List<HeatPumpSample> samples = repository.samples(
|
||||
today.minusDays(length).atStartOfDay(StatsService.ZONE).toInstant(),
|
||||
today.atStartOfDay(StatsService.ZONE).toInstant());
|
||||
Observed cwu = observe(samples, Periods::cwu);
|
||||
Observed room = observe(samples, Periods::room);
|
||||
List<Weekday> weekdays = new ArrayList<>();
|
||||
for (int day = 1; day <= 7; day++) {
|
||||
weekdays.add(new Weekday(day, circuit(cwu, day), circuit(room, day)));
|
||||
}
|
||||
return new ProgramView(length, weekdays);
|
||||
}
|
||||
|
||||
private static int length(String days) {
|
||||
if (days == null) {
|
||||
return DEFAULT_DAYS;
|
||||
}
|
||||
try {
|
||||
int length = Integer.parseInt(days);
|
||||
if (length >= MIN_DAYS && length <= MAX_DAYS) {
|
||||
return length;
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// reported below
|
||||
}
|
||||
throw new IllegalArgumentException("days must be " + MIN_DAYS + " to " + MAX_DAYS);
|
||||
}
|
||||
|
||||
private static Observed observe(List<HeatPumpSample> samples, Function<Periods, Period> circuit) {
|
||||
Set<LocalDate> days = new TreeSet<>();
|
||||
Map<LocalDate, List<Span>> comfort = new TreeMap<>();
|
||||
Period previous = null;
|
||||
Instant previousTime = null;
|
||||
Instant comfortStart = null;
|
||||
for (HeatPumpSample sample : samples) {
|
||||
Period period = circuit.apply(sample.periods());
|
||||
if (period != Period.COMFORT && period != Period.ECONOMY) {
|
||||
previous = null;
|
||||
comfortStart = null;
|
||||
continue;
|
||||
}
|
||||
days.add(sample.t().atZone(StatsService.ZONE).toLocalDate());
|
||||
boolean adjacent = previous != null && Duration.between(previousTime, sample.t()).compareTo(MAX_GAP) <= 0;
|
||||
if (!adjacent) {
|
||||
comfortStart = null;
|
||||
} else if (period != previous) {
|
||||
Instant boundary = previousTime.plus(Duration.between(previousTime, sample.t()).dividedBy(2));
|
||||
if (period == Period.COMFORT) {
|
||||
comfortStart = boundary;
|
||||
} else if (comfortStart != null) {
|
||||
addSpans(comfort, comfortStart, boundary);
|
||||
comfortStart = null;
|
||||
}
|
||||
}
|
||||
previous = period;
|
||||
previousTime = sample.t();
|
||||
}
|
||||
return new Observed(days, comfort);
|
||||
}
|
||||
|
||||
/** Splits at local midnight; a start at 23:58 and an end at 00:03 are rounded before that. */
|
||||
private static void addSpans(Map<LocalDate, List<Span>> comfort, Instant from, Instant to) {
|
||||
LocalDateTime start = rounded(from.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
LocalDateTime end = rounded(to.atZone(StatsService.ZONE).toLocalDateTime());
|
||||
for (LocalDate date = start.toLocalDate(); !date.isAfter(end.toLocalDate()); date = date.plusDays(1)) {
|
||||
int spanStart = date.equals(start.toLocalDate()) ? minutes(start) : 0;
|
||||
int spanEnd = date.equals(end.toLocalDate()) ? minutes(end) : MINUTES_PER_DAY;
|
||||
if (spanEnd > spanStart) {
|
||||
comfort.computeIfAbsent(date, d -> new ArrayList<>()).add(new Span(spanStart, spanEnd));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDateTime rounded(LocalDateTime time) {
|
||||
long steps = Math.round((time.getMinute() * 60 + time.getSecond()) / (STEP_MINUTES * 60.0));
|
||||
return time.truncatedTo(ChronoUnit.HOURS).plusMinutes(steps * STEP_MINUTES);
|
||||
}
|
||||
|
||||
private static int minutes(LocalDateTime time) {
|
||||
return time.getHour() * 60 + time.getMinute();
|
||||
}
|
||||
|
||||
/**
|
||||
* The pattern most days of this weekday agree on (the latest wins a tie); {@code CONFIRMED} when at
|
||||
* least two days show it.
|
||||
*/
|
||||
private static Circuit circuit(Observed observed, int weekday) {
|
||||
List<List<Span>> patterns = observed.days().stream()
|
||||
.filter(date -> date.getDayOfWeek().getValue() == weekday)
|
||||
.map(date -> observed.comfort().getOrDefault(date, List.of()))
|
||||
.toList();
|
||||
if (patterns.isEmpty()) {
|
||||
return new Circuit(Confidence.NO_DATA, 0, 0, List.of());
|
||||
}
|
||||
List<Span> best = null;
|
||||
int bestCount = 0;
|
||||
for (List<Span> pattern : patterns) {
|
||||
int count = (int) patterns.stream().filter(other -> agree(pattern, other)).count();
|
||||
if (count >= bestCount) {
|
||||
best = pattern;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return new Circuit(bestCount >= 2 ? Confidence.CONFIRMED : Confidence.SEEN_ONCE, patterns.size(), bestCount,
|
||||
best.stream().map(ProgramService::interval).toList());
|
||||
}
|
||||
|
||||
/** Equal within one step at both ends of every span. */
|
||||
private static boolean agree(List<Span> a, List<Span> b) {
|
||||
if (a.size() != b.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < a.size(); i++) {
|
||||
if (Math.abs(a.get(i).from() - b.get(i).from()) > STEP_MINUTES
|
||||
|| Math.abs(a.get(i).to() - b.get(i).to()) > STEP_MINUTES) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Interval interval(Span span) {
|
||||
return new Interval(hhmm(span.from()), hhmm(span.to()));
|
||||
}
|
||||
|
||||
private static String hhmm(int minutes) {
|
||||
return String.format("%02d:%02d", minutes / 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import java.time.Instant;
|
||||
|
||||
/** One poll of the heat pump from InfluxDB; a field the poll did not carry is null. */
|
||||
public record HeatPumpSample(Instant t, Double compressor, Double cwu, Double co, Double heater,
|
||||
Double outdoorTemp, Double cwuTemp) {
|
||||
Double outdoorTemp, Double cwuTemp, Periods periods) {
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import java.time.Instant;
|
||||
*/
|
||||
public record HeatPumpView(Instant time, boolean stale, State state, Integer compressorPercent, boolean heaterOn,
|
||||
boolean alarm, Integer alarmCode, String alarmText, Pumps pumps, Temperatures temperatures,
|
||||
Targets targets, Setpoints setpoints) {
|
||||
Targets targets, Setpoints setpoints, Periods periods) {
|
||||
|
||||
/** From register 4227: 0 stop, 1 CO, 2 CWU, 3 both. */
|
||||
public enum State { IDLE, HEATING_CO, HEATING_CWU, HEATING_BOTH }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
/** Which period of the weekly program a circuit is in, read from its target (see {@link Periods}). */
|
||||
public enum Period {
|
||||
COMFORT, ECONOMY, UNKNOWN, OVERRIDE;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
|
||||
/**
|
||||
* The period of CWU and room, from the target the controller works to now (verified on history,
|
||||
* docs/frisko/history-2026-10.md). Only the weekly program (register 4226 = 1) switches between
|
||||
* economy and comfort; any other program overrides both. {@code programText} names such a program.
|
||||
*/
|
||||
public record Periods(Period cwu, Period room, Integer program, String programText) {
|
||||
|
||||
public static final Periods UNKNOWN = new Periods(Period.UNKNOWN, Period.UNKNOWN, null, null);
|
||||
private static final int WEEKLY = 1;
|
||||
private static final double SAME_TARGET = 0.05;
|
||||
|
||||
public static Periods of(Integer program, Targets targets, Setpoints setpoints) {
|
||||
if (program == null) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
return new Periods(
|
||||
period(program, targets.cwu(), setpoints.cwuEconomy(), setpoints.cwuComfort()),
|
||||
period(program, targets.room(), setpoints.coEconomy(), setpoints.coComfort()),
|
||||
program, program == WEEKLY ? null : programText(program));
|
||||
}
|
||||
|
||||
private static Period period(int program, Double target, Integer economy, Integer comfort) {
|
||||
if (program != WEEKLY) {
|
||||
return Period.OVERRIDE;
|
||||
}
|
||||
if (target == null || economy == null || comfort == null || economy.equals(comfort)) {
|
||||
return Period.UNKNOWN;
|
||||
}
|
||||
if (Math.abs(target - comfort) < SAME_TARGET) {
|
||||
return Period.COMFORT;
|
||||
}
|
||||
return Math.abs(target - economy) < SAME_TARGET ? Period.ECONOMY : Period.UNKNOWN;
|
||||
}
|
||||
|
||||
private static String programText(int program) {
|
||||
return switch (program) {
|
||||
case 0 -> "Ferie";
|
||||
case 2 -> "Party";
|
||||
case 3 -> "Rozruch";
|
||||
case 4 -> "Stop";
|
||||
case 5 -> "Dezynfekcja";
|
||||
default -> "Program " + program;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The weekly program as the history shows it (F6): per weekday the comfort intervals of CWU and room.
|
||||
* Nothing is read from the controller's program registers. {@code weekdays} runs Monday (1) to Sunday (7).
|
||||
*/
|
||||
public record ProgramView(int days, List<Weekday> weekdays) {
|
||||
|
||||
public record Weekday(int day, Circuit cwu, Circuit room) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code observedDays}: days of this weekday with known periods; {@code agreeingDays}: how many of them
|
||||
* show {@code comfort} (within 5 minutes).
|
||||
*/
|
||||
public record Circuit(Confidence confidence, int observedDays, int agreeingDays, List<Interval> comfort) {
|
||||
}
|
||||
|
||||
/** Local times "HH:MM"; an interval that runs to midnight ends at "24:00". */
|
||||
public record Interval(String from, String to) {
|
||||
}
|
||||
|
||||
public enum Confidence {
|
||||
CONFIRMED, SEEN_ONCE, NO_DATA;
|
||||
|
||||
@JsonValue
|
||||
public String id() {
|
||||
return name().toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import home.backend.application.HeatPumpService;
|
||||
import home.backend.application.HistoryRepository;
|
||||
import home.backend.application.HeatPumpSamples;
|
||||
import home.backend.application.HistoryService;
|
||||
import home.backend.application.ProgramService;
|
||||
import home.backend.application.StatsService;
|
||||
import home.backend.application.PiSensorService;
|
||||
import home.backend.application.WeatherService;
|
||||
@@ -39,6 +40,9 @@ import home.backend.domain.GatewaySnapshot.DeviceSnapshot;
|
||||
import home.backend.domain.GatewaySnapshot.Measurement;
|
||||
import home.backend.domain.WeatherView.Warning;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -53,6 +57,7 @@ import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
@@ -116,10 +121,10 @@ class ApiServerTest {
|
||||
throw new IOException("influx down");
|
||||
}
|
||||
return List.of(
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0));
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 0.0, 0.0, 0.0, 0.0, 8.0, 44.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 100.0, 0.0, 0.0, 8.5, 43.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 100.0, 0.0, 0.0, 100.0, 9.0, 45.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:15:00Z"), 0.0, 0.0, 0.0, 0.0, 9.0, 46.0, Periods.UNKNOWN));
|
||||
};
|
||||
verdict = Status.APPLIED;
|
||||
restoreOutcome = Outcome.RESTORED;
|
||||
@@ -206,6 +211,7 @@ class ApiServerTest {
|
||||
new SetpointService(commands, PresetFile.load(null)), new PiSensorService(gateway, clock),
|
||||
new BatteryService(gateway, batteryCommands, clock),
|
||||
new AuditService(auditRows(), clock), new StatsService(samples, clock),
|
||||
new ProgramService((from, to) -> samples.samples(from, to), clock),
|
||||
new WeatherService(() -> exampleForecast(), () -> List.of(exampleWarning()),
|
||||
(from, to) -> exampleMeasuredPv(), clock),
|
||||
() -> {
|
||||
@@ -253,7 +259,7 @@ class ApiServerTest {
|
||||
"output_compressor", 0.0, "output_heater", 0.0, "alarm", 0.0,
|
||||
"setpoint_co_economy", 18.0, "setpoint_co_comfort", 19.0, "setpoint_cwu_economy", 8.0));
|
||||
pump.putAll(Map.of("setpoint_cwu_comfort", 45.0, "output_co", 0.0, "output_buffer", 0.0,
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0));
|
||||
"output_circulation", 0.0, "output_ground", 0.0, "output_cwu", 0.0, "program", 1.0));
|
||||
Instant pumpTime = Instant.parse("2026-10-23T22:13:35Z");
|
||||
Instant energyTime = Instant.parse("2026-10-23T22:26:00Z");
|
||||
Map<String, Double> deye = new HashMap<>(Map.of("pv1_power_w", 0.0, "pv2_power_w", 0.0, "pv_today_kwh", 29.9,
|
||||
@@ -528,6 +534,38 @@ class ApiServerTest {
|
||||
assertEquals(502, get("/api/stats?range=7d").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programMatchesExample() throws Exception {
|
||||
// Friday 2026-10-23, CEST: CWU comfort from 11:25 (09:25 UTC) to 12:05.
|
||||
Periods economy = Periods.of(1, new Targets(18.0, 31.0, 8.0), new Setpoints(18, 19, 8, 43));
|
||||
Periods comfort = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 8, 43));
|
||||
samples = (from, to) -> {
|
||||
List<HeatPumpSample> polls = new ArrayList<>();
|
||||
for (int minute = 20; minute <= 125; minute += 5) {
|
||||
Instant t = Instant.parse("2026-10-23T09:00:00Z").plus(Duration.ofMinutes(minute));
|
||||
polls.add(new HeatPumpSample(t, null, null, null, null, null, null,
|
||||
minute >= 25 && minute <= 60 ? comfort : economy));
|
||||
}
|
||||
return polls;
|
||||
};
|
||||
|
||||
assertMatchesExample("/api/program?days=7", "program");
|
||||
}
|
||||
|
||||
@Test
|
||||
void programNeedsDaysFromSevenToThirty() throws Exception {
|
||||
assertEquals(200, get("/api/program").statusCode());
|
||||
assertEquals(400, get("/api/program?days=3").statusCode());
|
||||
assertEquals(400, get("/api/program?days=x").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void programIs502WhenInfluxIsDown() throws Exception {
|
||||
historyDown = true;
|
||||
|
||||
assertEquals(502, get("/api/program").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void historyRangeDefaultsTo24Hours() throws Exception {
|
||||
assertEquals(200, get("/api/history?series=cwu_temp").statusCode());
|
||||
|
||||
@@ -2,6 +2,8 @@ package home.backend.adapters.influx;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -216,13 +218,16 @@ class InfluxHistoryRepositoryTest {
|
||||
List<HeatPumpSample> samples = repository(TIMEOUT).samples(FROM, TO);
|
||||
|
||||
assertEquals("Dom_Pompa", requests.get(0).get("db"));
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp"
|
||||
assertEquals("SELECT output_compressor, output_cwu, output_co, output_heater, outdoor_temp, cwu_temp,"
|
||||
+ " program, target_room_temp, target_cwu_temp, setpoint_co_economy, setpoint_co_comfort,"
|
||||
+ " setpoint_cwu_economy, setpoint_cwu_comfort"
|
||||
+ " FROM \"heat_pump\" WHERE time >= 1792800000000ms AND time < 1792821600000ms",
|
||||
requests.get(0).get("q"));
|
||||
assertEquals(3, samples.size());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9),
|
||||
samples.get(1));
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null),
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771500000L), 100.0, 100.0, 0.0, 0.0, 8.5, 43.9,
|
||||
new Periods(Period.COMFORT, Period.COMFORT, 1, null)), samples.get(1));
|
||||
assertEquals(Period.ECONOMY, samples.get(0).periods().cwu());
|
||||
assertEquals(new HeatPumpSample(Instant.ofEpochMilli(1792771800000L), 50.0, 0.0, 100.0, 100.0, null, null, Periods.UNKNOWN),
|
||||
samples.get(2));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.ProgramView;
|
||||
import home.backend.domain.ProgramView.Circuit;
|
||||
import home.backend.domain.ProgramView.Confidence;
|
||||
import home.backend.domain.ProgramView.Interval;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ProgramServiceTest {
|
||||
|
||||
/** Tuesday 2026-10-27 12:00 in Warsaw; the Sunday before it, 10-25, is the day of the change to winter time. */
|
||||
private static final Instant NOW = Instant.parse("2026-10-27T11:00:00Z");
|
||||
private static final int POLL_MINUTES = 5;
|
||||
|
||||
private Instant askedFrom;
|
||||
private Instant askedTo;
|
||||
|
||||
private ProgramView program(String days, List<HeatPumpSample> samples) throws IOException {
|
||||
HeatPumpSamples repository = (from, to) -> {
|
||||
askedFrom = from;
|
||||
askedTo = to;
|
||||
return samples;
|
||||
};
|
||||
return new ProgramService(repository, Clock.fixed(NOW, ZoneOffset.UTC)).program(days);
|
||||
}
|
||||
|
||||
private static HeatPumpSample poll(String utc, Periods periods) {
|
||||
return new HeatPumpSample(Instant.parse(utc), null, null, null, null, null, null, periods);
|
||||
}
|
||||
|
||||
/** CWU economy 8, comfort 43; room economy 18, comfort 19, as on the real controller. */
|
||||
private static Periods weekly(double cwuTarget, double roomTarget) {
|
||||
return program(1, cwuTarget, roomTarget, 8, 43);
|
||||
}
|
||||
|
||||
private static Periods program(int program, double cwuTarget, double roomTarget, int cwuEconomy, int cwuComfort) {
|
||||
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget),
|
||||
new Setpoints(18, 19, cwuEconomy, cwuComfort));
|
||||
}
|
||||
|
||||
private static Periods economy() {
|
||||
return weekly(8.0, 18.0);
|
||||
}
|
||||
|
||||
private static Periods cwuComfort() {
|
||||
return weekly(43.0, 18.0);
|
||||
}
|
||||
|
||||
/** Polls every 5 minutes from {@code from} to {@code to} (UTC, "2026-10-19T09:00"), all of them {@code periods}. */
|
||||
private static List<HeatPumpSample> polls(String from, String to, Periods periods) {
|
||||
List<HeatPumpSample> samples = new ArrayList<>();
|
||||
Instant end = Instant.parse(to + ":00Z");
|
||||
for (Instant t = Instant.parse(from + ":00Z"); !t.isAfter(end); t = t.plus(Duration.ofMinutes(POLL_MINUTES))) {
|
||||
samples.add(new HeatPumpSample(t, null, null, null, null, null, null, periods));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static List<HeatPumpSample> concat(List<HeatPumpSample>... parts) {
|
||||
return Arrays.stream(parts).flatMap(List::stream).toList();
|
||||
}
|
||||
|
||||
/** "2026-10-19T10:25" for a local time of that date, given the offset from UTC. */
|
||||
private static String utc(String date, String local, int offsetHours, int plusMinutes) {
|
||||
int total = Integer.parseInt(local.substring(0, 2)) * 60 + Integer.parseInt(local.substring(3))
|
||||
- offsetHours * 60 + plusMinutes;
|
||||
return String.format("%sT%02d:%02d", date, total / 60, total % 60);
|
||||
}
|
||||
|
||||
/** CWU economy, then comfort from {@code from} to {@code to} (local), then economy, polled every 5 minutes. */
|
||||
private static List<HeatPumpSample> cwuDay(String date, String from, String to, int offsetHours) {
|
||||
return concat(
|
||||
polls(utc(date, from, offsetHours, -30), utc(date, from, offsetHours, -5), economy()),
|
||||
polls(utc(date, from, offsetHours, 0), utc(date, to, offsetHours, -5), cwuComfort()),
|
||||
polls(utc(date, to, offsetHours, 0), utc(date, to, offsetHours, 30), economy()));
|
||||
}
|
||||
|
||||
private static Circuit monday(ProgramView view) {
|
||||
return view.weekdays().get(0).cwu();
|
||||
}
|
||||
|
||||
@Test
|
||||
void asksForWholeLocalDaysBeforeToday() throws IOException {
|
||||
ProgramView view = program("14", List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(Instant.parse("2026-10-12T22:00:00Z"), askedFrom);
|
||||
assertEquals(Instant.parse("2026-10-26T23:00:00Z"), askedTo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultsToFourteenDaysAndListsMondayToSunday() throws IOException {
|
||||
ProgramView view = program(null, List.of());
|
||||
|
||||
assertEquals(14, view.days());
|
||||
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), view.weekdays().stream().map(ProgramView.Weekday::day).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDaysOutsideSevenToThirty() {
|
||||
for (String days : new String[] {"6", "31", "abc", ""}) {
|
||||
assertThrows(IllegalArgumentException.class, () -> program(days, List.of()), days);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDataGivesNoDataForEveryWeekday() throws IOException {
|
||||
Circuit circuit = monday(program("14", List.of()));
|
||||
|
||||
assertEquals(Confidence.NO_DATA, circuit.confidence());
|
||||
assertEquals(0, circuit.observedDays());
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBoundaryIsHalfwayBetweenTheTwoPollsRoundedToFiveMinutes() throws IOException {
|
||||
// Economy at 11:23:48 local (CEST), comfort at 11:28:48: the change was about 11:26, so 11:25.
|
||||
// Comfort at 12:04:56, economy at 12:09:56: about 12:07, so 12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
List.of(poll("2026-10-19T09:23:48Z", economy()), poll("2026-10-19T09:28:48Z", cwuComfort())),
|
||||
polls("2026-10-19T09:33", "2026-10-19T10:00", cwuComfort()),
|
||||
List.of(poll("2026-10-19T10:04:56Z", cwuComfort()), poll("2026-10-19T10:09:56Z", economy())));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIntervalsOnTwoMondaysWithinFiveMinutesAreConfirmed() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "11:30", "12:05", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(2, circuit.agreeingDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentIntervalsOnTwoMondaysAreSeenOnceAndTheLatestIsShown() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-19", "11:25", "12:05", 2), cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(Confidence.SEEN_ONCE, circuit.confidence());
|
||||
assertEquals(2, circuit.observedDays());
|
||||
assertEquals(1, circuit.agreeingDays());
|
||||
assertEquals(List.of(new Interval("13:00", "14:00")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMajorityPatternWinsOverOneOddDay() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-12", "11:25", "12:05", 2), cwuDay("2026-10-19", "11:25", "12:05", 2),
|
||||
cwuDay("2026-10-26", "13:00", "14:00", 1));
|
||||
|
||||
Circuit circuit = monday(program("30", samples));
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, circuit.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDayWithTheChangeToWinterTimeKeepsLocalClockTimes() throws IOException {
|
||||
// Sunday 2026-10-18 is CEST (UTC+2), Sunday 2026-10-25 CET (UTC+1): the same local 11:25-12:05.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
cwuDay("2026-10-18", "11:25", "12:05", 2), cwuDay("2026-10-25", "11:25", "12:05", 1));
|
||||
|
||||
Circuit sunday = program("14", samples).weekdays().get(6).cwu();
|
||||
|
||||
assertEquals(Confidence.CONFIRMED, sunday.confidence());
|
||||
assertEquals(List.of(new Interval("11:25", "12:05")), sunday.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIntervalAcrossMidnightIsSplitOverBothDays() throws IOException {
|
||||
// Comfort from 22:55 local Sunday to 01:05 local Monday (CET: UTC+1).
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-11-01T21:50", "2026-11-01T21:50", economy()),
|
||||
polls("2026-11-01T21:55", "2026-11-02T00:00", cwuComfort()),
|
||||
polls("2026-11-02T00:05", "2026-11-02T00:10", economy()));
|
||||
Clock later = Clock.fixed(Instant.parse("2026-11-04T11:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
ProgramView view = new ProgramService((from, to) -> samples, later).program("14");
|
||||
|
||||
assertEquals(List.of(new Interval("22:55", "24:00")), view.weekdays().get(6).cwu().comfort());
|
||||
assertEquals(List.of(new Interval("00:00", "01:05")), view.weekdays().get(0).cwu().comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ourOwnWritesLeaveNoComfortIntervalButTheDayStaysObserved() throws IOException {
|
||||
// Economy raised 8 -> 45 -> 43 (equal to comfort, so the periods are ambiguous) -> 8, as in the
|
||||
// 2026-10-24 tests, all during the economy period.
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T11:00:00Z", program(1, 8.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T11:05:00Z", program(1, 45.0, 18.0, 45, 43)),
|
||||
poll("2026-10-19T11:10:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:15:00Z", program(1, 43.0, 18.0, 43, 43)),
|
||||
poll("2026-10-19T11:20:00Z", program(1, 8.0, 18.0, 8, 43)));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aWriteInsideComfortDropsTheIntervalInsteadOfGuessingItsEnd() throws IOException {
|
||||
// Economy written 8 -> 43 inside the comfort period makes the CWU periods ambiguous for two polls.
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", economy()),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:05", cwuComfort()),
|
||||
polls("2026-10-19T10:10", "2026-10-19T10:15", program(1, 43.0, 18.0, 43, 43)),
|
||||
polls("2026-10-19T10:20", "2026-10-19T10:55", cwuComfort()),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:05", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
assertEquals(1, circuit.observedDays());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anotherProgramBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:05:00Z", program(2, 43.0, 18.0, 8, 43)),
|
||||
poll("2026-10-19T10:10:00Z", economy()));
|
||||
|
||||
Circuit circuit = monday(program("14", samples));
|
||||
|
||||
assertEquals(List.of(), circuit.comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapInTheDataBreaksTheChain() throws IOException {
|
||||
List<HeatPumpSample> samples = List.of(
|
||||
poll("2026-10-19T09:55:00Z", economy()), poll("2026-10-19T10:00:00Z", cwuComfort()),
|
||||
poll("2026-10-19T10:40:00Z", economy()));
|
||||
|
||||
assertEquals(List.of(), monday(program("14", samples)).comfort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roomIsReadFromItsOwnTargets() throws IOException {
|
||||
List<HeatPumpSample> samples = concat(
|
||||
polls("2026-10-19T09:55", "2026-10-19T09:55", weekly(8.0, 18.0)),
|
||||
polls("2026-10-19T10:00", "2026-10-19T10:55", weekly(8.0, 19.0)),
|
||||
polls("2026-10-19T11:00", "2026-10-19T11:00", weekly(8.0, 18.0)));
|
||||
|
||||
ProgramView view = program("14", samples);
|
||||
|
||||
assertEquals(List.of(), monday(view).comfort());
|
||||
assertEquals(List.of(new Interval("12:00", "13:00")), view.weekdays().get(0).room().comfort());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import home.backend.domain.HealthView;
|
||||
import home.backend.domain.HeatPumpView;
|
||||
import home.backend.domain.HeatPumpView.State;
|
||||
import home.backend.domain.HistoryView;
|
||||
import home.backend.domain.Period;
|
||||
import home.backend.domain.PiSensorView;
|
||||
import home.backend.domain.HistoryView.Point;
|
||||
import home.backend.domain.Series;
|
||||
@@ -71,6 +72,39 @@ class ServicesTest {
|
||||
assertEquals(State.HEATING_BOTH, heatPump(Map.of("pump_mode", 3.0)).state());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpHeaterIsOnForTheOutputOrForZbState2() throws IOException {
|
||||
assertTrue(heatPump(Map.of("output_heater", 100.0)).heaterOn());
|
||||
assertTrue(heatPump(Map.of("output_heater", 0.0, "zb_state", 2.0)).heaterOn());
|
||||
assertFalse(heatPump(Map.of("output_heater", 0.0, "zb_state", 0.0)).heaterOn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpPeriodsComeFromTargetsAndSetpoints() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 1.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0, "target_room_temp", 18.0, "setpoint_co_economy", 18.0,
|
||||
"setpoint_co_comfort", 19.0));
|
||||
|
||||
assertEquals(Period.COMFORT, view.periods().cwu());
|
||||
assertEquals(Period.ECONOMY, view.periods().room());
|
||||
assertNull(view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpOtherProgramOverridesBothPeriodsAndIsNamed() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("program", 2.0, "target_cwu_temp", 43.0, "setpoint_cwu_economy", 8.0,
|
||||
"setpoint_cwu_comfort", 43.0));
|
||||
|
||||
assertEquals(Period.OVERRIDE, view.periods().cwu());
|
||||
assertEquals(Period.OVERRIDE, view.periods().room());
|
||||
assertEquals("Party", view.periods().programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpWithoutProgramHasUnknownPeriods() throws IOException {
|
||||
assertEquals(Period.UNKNOWN, heatPump(Map.of("target_cwu_temp", 43.0)).periods().cwu());
|
||||
}
|
||||
|
||||
@Test
|
||||
void heatPumpMapsOutputsAlarmAndTime() throws IOException {
|
||||
HeatPumpView view = heatPump(Map.of("output_compressor", 66.0, "output_heater", 100.0,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package home.backend.application;
|
||||
|
||||
import home.backend.domain.HeatPumpSample;
|
||||
import home.backend.domain.Periods;
|
||||
import home.backend.domain.StatsView;
|
||||
import home.backend.domain.StatsView.Day;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -35,7 +36,7 @@ class StatsServiceTest {
|
||||
}
|
||||
|
||||
private static HeatPumpSample compressor(String time, double stage) {
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null);
|
||||
return new HeatPumpSample(Instant.parse(time), stage, 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN);
|
||||
}
|
||||
|
||||
private static Day day(StatsView view, String date) {
|
||||
@@ -158,9 +159,9 @@ class StatsServiceTest {
|
||||
@Test
|
||||
void sumsEachOutputSeparately() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null));
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 100.0, 0.0, 0.0, 10.0, 40.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 100.0, 0.0, 100.0, 100.0, 12.0, 38.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:10:00Z"), 0.0, 0.0, 0.0, 0.0, null, null, Periods.UNKNOWN));
|
||||
|
||||
Day d = day(view, "2026-10-23");
|
||||
assertEquals(0.17, d.compressorHours());
|
||||
@@ -175,10 +176,10 @@ class StatsServiceTest {
|
||||
@Test
|
||||
void totalsCoverTheWholeRangeWithTheTemperatureOfAllPolls() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0));
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:00:00Z"), 100.0, 0.0, 0.0, 0.0, 10.0, 30.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-22T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 10.0, 31.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), 100.0, 0.0, 0.0, 100.0, 4.0, 45.0, Periods.UNKNOWN),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:05:00Z"), 0.0, 0.0, 0.0, 0.0, 4.0, 44.0, Periods.UNKNOWN));
|
||||
|
||||
assertEquals(0.17, view.totals().compressorHours());
|
||||
assertEquals(0.08, view.totals().heaterHours());
|
||||
@@ -190,7 +191,7 @@ class StatsServiceTest {
|
||||
@Test
|
||||
void aMissingFieldCountsAsOffAndUnknownStateIsNoStart() throws IOException {
|
||||
StatsView view = stats("7d", NOW,
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null),
|
||||
new HeatPumpSample(Instant.parse("2026-10-23T10:00:00Z"), null, null, null, null, null, null, Periods.UNKNOWN),
|
||||
compressor("2026-10-23T10:05:00Z", 100));
|
||||
|
||||
assertEquals(0, day(view, "2026-10-23").compressorStarts());
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package home.backend.domain;
|
||||
|
||||
import home.backend.domain.HeatPumpView.Setpoints;
|
||||
import home.backend.domain.HeatPumpView.Targets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class PeriodsTest {
|
||||
|
||||
/** CWU economy 8 and comfort 43, room economy 18 and comfort 19. */
|
||||
private static Periods of(Integer program, double cwuTarget, double roomTarget) {
|
||||
return Periods.of(program, new Targets(roomTarget, 31.0, cwuTarget), new Setpoints(18, 19, 8, 43));
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetEqualToAComfortSetpointIsComfort() {
|
||||
Periods periods = of(1, 43.0, 19.0);
|
||||
|
||||
assertEquals(Period.COMFORT, periods.cwu());
|
||||
assertEquals(Period.COMFORT, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetEqualToAnEconomySetpointIsEconomy() {
|
||||
Periods periods = of(1, 8.0, 18.0);
|
||||
|
||||
assertEquals(Period.ECONOMY, periods.cwu());
|
||||
assertEquals(Period.ECONOMY, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetBetweenTheSetpointsIsUnknown() {
|
||||
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).cwu());
|
||||
assertEquals(Period.UNKNOWN, of(1, 25.0, 18.5).room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equalSetpointsAreUnknownBecauseTheTargetIsTheSameEitherWay() {
|
||||
Periods periods = Periods.of(1, new Targets(18.0, 31.0, 43.0), new Setpoints(18, 19, 43, 43));
|
||||
|
||||
assertEquals(Period.UNKNOWN, periods.cwu());
|
||||
assertEquals(Period.ECONOMY, periods.room());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProgramOtherThanWeeklyOverridesAndIsNamed() {
|
||||
String[] names = {"Ferie", null, "Party", "Rozruch", "Stop", "Dezynfekcja"};
|
||||
for (int program = 0; program < names.length; program++) {
|
||||
if (program != 1) {
|
||||
Periods periods = of(program, 43.0, 19.0);
|
||||
assertEquals(Period.OVERRIDE, periods.cwu());
|
||||
assertEquals(Period.OVERRIDE, periods.room());
|
||||
assertEquals(names[program], periods.programText());
|
||||
}
|
||||
}
|
||||
assertEquals("Program 9", of(9, 8.0, 18.0).programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void weeklyProgramHasNoText() {
|
||||
assertNull(of(1, 8.0, 18.0).programText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingValuesAreUnknown() {
|
||||
assertEquals(Periods.UNKNOWN, Periods.of(null, new Targets(null, null, null), new Setpoints(null, null, null, null)));
|
||||
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, null), new Setpoints(18, 19, 8, 43)).cwu());
|
||||
assertEquals(Period.UNKNOWN, Periods.of(1, new Targets(18.0, null, 8.0), new Setpoints(null, null, 8, null)).cwu());
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodSerialisesAsLowerCaseId() {
|
||||
assertEquals("comfort", Period.COMFORT.id());
|
||||
assertEquals("override", Period.OVERRIDE.id());
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@
|
||||
"groundIn": null, "groundOut": null, "pumpOutlet": null, "outdoorAvg": null
|
||||
},
|
||||
"targets": { "room": null, "co": null, "cwu": null },
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null }
|
||||
"setpoints": { "coEconomy": null, "coComfort": null, "cwuEconomy": null, "cwuComfort": null },
|
||||
"periods": { "cwu": "unknown", "room": "unknown", "program": null, "programText": null }
|
||||
}
|
||||
@@ -13,5 +13,6 @@
|
||||
"groundIn": 16.0, "groundOut": 14.3, "pumpOutlet": 19.7, "outdoorAvg": 4.8
|
||||
},
|
||||
"targets": { "room": 18.0, "co": 31.0, "cwu": 8.0 },
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 }
|
||||
"setpoints": { "coEconomy": 18, "coComfort": 19, "cwuEconomy": 8, "cwuComfort": 45 },
|
||||
"periods": { "cwu": "economy", "room": "economy", "program": 1, "programText": null }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"days": 7,
|
||||
"weekdays": [
|
||||
{ "day": 1, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 2, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 3, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 4, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 5, "cwu": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1,
|
||||
"comfort": [ { "from": "11:25", "to": "12:05" } ] },
|
||||
"room": { "confidence": "seen_once", "observedDays": 1, "agreeingDays": 1, "comfort": [] } },
|
||||
{ "day": 6, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } },
|
||||
{ "day": 7, "cwu": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] },
|
||||
"room": { "confidence": "no_data", "observedDays": 0, "agreeingDays": 0, "comfort": [] } }
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":0.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
|
||||
[{"name":"bme280","interval":"PT5M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"measurement","measurement":"MEASUREMENT","tags":{"location":"dom-szczepancowa"},"fields":{"pressure":985.8,"humidity":45.82,"temperature":20.17},"time":"2026-10-23T22:26:00Z"}]},{"name":"deye","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"deye","tags":{"location":"dom-szczepancowa"},"fields":{"grid_power_w":645.0,"battery_temperature_c":19.2,"battery_discharge_today_kwh":0.1,"load_today_kwh":24.2,"pv2_power_w":0.0,"pv1_power_w":0.0,"grid_voltage_l1_v":233.9,"grid_bought_today_kwh":16.8,"load_power_w":570.0,"grid_voltage_l2_v":233.4,"battery_soc_pct":99.0,"pv_today_kwh":29.9,"battery_power_w":-24.0,"grid_power_l1_w":390.0,"grid_voltage_l3_v":231.9,"battery_charge_today_kwh":0.5,"grid_power_l2_w":52.0,"load_power_l1_w":364.0,"load_power_l2_w":26.0,"grid_power_l3_w":203.0,"battery_voltage_v":56.49,"grid_sold_today_kwh":22.3,"load_power_l3_w":180.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"fronius","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"fronius","tags":{"location":"dom-szczepancowa"},"fields":{"energy_year_wh":1846276.375,"pv_power_w":0.0,"energy_day_wh":4400.0,"energy_total_wh":2.90713E7},"time":"2026-10-23T22:26:00Z"}]},{"name":"heat-pump","interval":"PT10S","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"Dom_Pompa","measurement":"POMPA","tags":{},"fields":{"Temp co":16.900000000000002,"Temp cwu":13.600000000000001,"Temp out":14.3,"Temp in":16.0,"Temp wew":21.200000000000003,"External temperature":4.3,"Temp zsr":4.800000000000001,"Temp pc out":19.700000000000003},"time":"2026-10-23T22:26:00Z"},{"database":"Dom_Pompa","measurement":"heat_pump","tags":{},"fields":{"pump_outlet_temp":19.700000000000003,"setpoint_cwu_economy":8.0,"pressure_switch":0.0,"setpoint_co_comfort":19.0,"output_co":0.0,"co_buffer_temp":16.900000000000002,"ground_out_temp":14.3,"setpoint_co_economy":18.0,"output_heater":0.0,"pump_mode":0.0,"target_co_temp":31.0,"cwu_temp":13.600000000000001,"alarm":0.0,"reg_4225":0.0,"reg_4223":0.0,"reg_4224":0.0,"reg_4222":0.0,"target_room_temp":18.0,"output_circulation":0.0,"output_compressor":0.0,"output_ground":0.0,"outdoor_avg_temp":4.800000000000001,"program":1.0,"output_cwu":0.0,"target_cwu_temp":8.0,"indoor_temp":21.200000000000003,"output_buffer":0.0,"setpoint_cwu_comfort":45.0,"output_cooling":0.0,"zb_state":0.0,"outdoor_temp":4.3,"ground_in_temp":16.0},"time":"2026-10-23T22:26:00Z"}]},{"name":"house-meter","interval":"PT1M","since":"2026-10-23T20:00:00Z","lastAttempt":"2026-10-23T22:26:00Z","lastSuccess":"2026-10-23T22:26:00Z","lastError":null,"readings":[{"database":"electricity","measurement":"PRAD2","tags":{"location":"dom-szczepancowa"},"fields":{"p3_power_reactive":26.52,"p2_demand_current":0.98,"total_power_factor":1.0,"p23_voltage_thd":0.0,"import_energy_reactive":6433.02,"maximum_export_demand_power_active":0.0,"total_energy_apparent":138275.8,"p1_power_apparent":74.71,"p3_demand_current":0.86,"p2_current_thd":99.27,"total_current":262147.94,"p2_power_factor":0.87,"p1_power_factor":0.75,"p3_current":0.67,"p2_current":0.88,"total_energy_active":134191.39,"maximum_neutral_demand_current":20.28,"maximum_import_demand_power_active":0.0,"voltage_ln_thd":3.65,"p2_power_apparent":207.46,"p1_phase_angle":41.52,"export_demand_power_active":0.0,"maximum_p1_demand_current":21.31,"neutral_demand_current":0.77,"p31_voltage_thd":0.0,"p3_power_apparent":155.65,"neutral_current":0.67,"export_energy_active":66677.11,"total_phase_angle":-4.12,"import_demand_power_active":0.0,"maximum_import_demand_power_apparent":-10986.21,"current_thd":95.36,"maximum_p2_demand_current":20.79,"p23_voltage":405.05,"p3_power_active":153.38,"p2_power_active":179.47,"p3_voltage":232.2,"p2_voltage":235.51,"frequency":50.01,"maximum_p3_demand_current":16.35,"p12_voltage":408.07,"maximum_demand_power_apparent":10989.65,"p1_demand_current":0.33,"p3_current_thd":72.45,"total_power_apparent":437.82,"voltage_ln":234.47,"voltage_ll_thd":0.0,"voltage_ll":406.11,"p2_phase_angle":-30.11,"p3_power_factor":0.99,"p1_current":0.32,"export_energy_reactive":26926.61,"total_power_reactive":-28.04,"p12_voltage_thd":0.0,"import_energy_active":67514.27,"p31_voltage":405.21,"p1_current_thd":114.36,"total_energy_reactive":33359.63,"total_demand_power_apparent":507.11,"p1_power_reactive":49.52,"current_ln":0.85,"total_power_active":388.78,"p1_voltage":235.69,"p2_power_reactive":-104.07,"p3_phase_angle":9.81,"p3n_voltage_thd":3.64,"p2n_voltage_thd":3.82,"p1n_voltage_thd":3.48,Line truncated
|
||||
@@ -1 +1 @@
|
||||
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp"],"values":[[1792771200000,0,0,0,0,8.5,44.2],[1792771500000,100,100,0,0,8.5,43.9],[1792771800000,50,0,100,100,null,null]]}]}]}
|
||||
{"results":[{"statement_id":0,"series":[{"name":"heat_pump","columns":["time","output_compressor","output_cwu","output_co","output_heater","outdoor_temp","cwu_temp","program","target_room_temp","target_cwu_temp","setpoint_co_economy","setpoint_co_comfort","setpoint_cwu_economy","setpoint_cwu_comfort"],"values":[[1792771200000,0,0,0,0,8.5,44.2,1,18.0,8.0,18,19,8,43],[1792771500000,100,100,0,0,8.5,43.9,1,19.0,43.0,18,19,8,43],[1792771800000,50,0,100,100,null,null,null,null,null,null,null,null,null]]}]}]}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Frisko RX910-SE21: what history shows (2026-10)
|
||||
|
||||
Sources, all read-only: `Dom_Pompa.heat_pump` (2026-10-24, 167 samples, 5 min), legacy `POMPA`
|
||||
(8 temperatures, 5 min, Feb-Oct 2026), `electricity.PRAD2` (house meter, 1 min), `setpoint_audit`.
|
||||
Times are Europe/Warsaw; 2026-10-24 was CEST (DST ended 2026-10-25 03:00). Sampling gives +-5 min
|
||||
unless a meter step is quoted (+-1 min). Only one day of `heat_pump` exists so far.
|
||||
|
||||
## Period detection (economy or comfort)
|
||||
|
||||
Verified from history, 2026-10-24, 167 samples. Rule: compare the target with the two setpoints.
|
||||
|
||||
| Circuit | Target / economy / comfort | Result |
|
||||
|---|---|---|
|
||||
| CWU | 4203 vs 4143 / 4144 (8 / 43) | 133 economy, 27 comfort, 0 neither outside our writes |
|
||||
| Room | 4201 vs 4131 / 4132 (18 / 19) | 130 economy, 30 comfort, 0 neither |
|
||||
|
||||
- During our 4143 writes (13:20:19 to 13:53:33: 45, 47, 43, 8) the CWU rule is wrong: target = the
|
||||
economy value we wrote; once economy = 43 = comfort (13:29:47 on) it is ambiguous (5 samples).
|
||||
Exclude samples within a few minutes after a `setpoint_audit` write, or report "unknown".
|
||||
- Ambiguity only occurs when two setpoints are equal; the target is then the same either way.
|
||||
- "Neither" never happened (no Party, Ferie, disinfection: 4226 = 1 on every sample).
|
||||
- 4226 = 1 all day with the weekly program active: the PDF numbering (1 = Tygodn) holds.
|
||||
- Target changes at the period boundary within one poll; the first sample after it already shows it.
|
||||
|
||||
## Weekly program (hypothesis from one observed day plus 7 weeks of cycles)
|
||||
|
||||
2026-10-24 (Saturday), from `heat_pump`: CWU target 43 in 11:25-~12:05 and 17:55-~19:10, else 8.
|
||||
|
||||
| Circuit | Comfort start | Comfort end | Evidence |
|
||||
|---|---|---|---|
|
||||
| CWU | 11:25 (11:23:48..11:28:48) | ~12:05 (12:04:56..12:09:56) | 4203; meter step +1.4 kW 11:24:55 |
|
||||
| CWU | 17:55 (17:53:23..17:58:23) | ~19:10 (19:08:50..19:13:50) | 4203; meter step +1.7 kW 17:55:23 |
|
||||
| Room | ~12:07 (12:04:56..12:09:56) | ~14:55 (14:52:53..14:57:53) | 4201 = 19 vs 18 |
|
||||
|
||||
- Legacy `POMPA`, 2026-09-10..10-24: compressor cycles begin at 11:25 and 17:55 on all seven weekdays
|
||||
(43 morning, 33 evening cycles; the evening one is missing only when the tank is already warm).
|
||||
Strong evidence that CWU comfort is the same every day. Not read from the controller (4169-4176).
|
||||
- Unknown: room intervals on other days (CO never ran on 10-24, `output_co` = 0 all day); a possible
|
||||
third interval; Party/Ferie use. Reading 4167-4177 needs the shared selector.
|
||||
- The 5-minute "11:30" and "18:00" in older months are a sampling-phase artefact (first poll after start).
|
||||
|
||||
## The controller clock does not follow DST (verified from history)
|
||||
|
||||
- Cycles began at 11:30 / 18:00 until 2026-03-28; from 03-29 (DST start) at 12:30 / 19:00 local, i.e.
|
||||
one hour late, until 2026-04-06; from 04-07 back at 11:30 / 18:00. So the clock stayed on CET after
|
||||
the change and was set by hand on about 04-06.
|
||||
- Prediction (hypothesis, testable on 2026-10-26): the controller still runs CEST after 10-25 03:00,
|
||||
so comfort periods start one hour early in local time (about 10:25 and 16:55) until the clock is set.
|
||||
The app should not assume program times are local civil time.
|
||||
|
||||
## Heater (output_heater 4115, zb_state 4228)
|
||||
|
||||
Episodes in `heat_pump` (10-24); meter steps from `electricity.PRAD2` `total_power_active`.
|
||||
|
||||
| Episode | On (sample window) | Off | Preceding CWU run | State at start |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 11:43:48..11:48:48 | 12:04:56..12:09:56 (period end) | compressor since 11:24:55, ~20-24 min | stage 66, cwu 28 / target 43, outlet 39, outdoor 11 |
|
||||
| 2 | 18:14:13..18:19:13 (meter 18:15:13, +937 W) | ~18:20 (meter -3.7 kW) | compressor since 17:55:23, 19.8 min | stage 66, cwu 40.6 / 43, outlet 46 then 51.7 |
|
||||
|
||||
Verified from history:
|
||||
- The heater joins 20.0 min (+-0.1) after the compressor starts a CWU cycle, if the cycle is still on.
|
||||
Meter: nine evenings (10-09, 10, 15, 16, 17, 19, 22, 23, 24) show a step of 665-961 W (mean ~890 W,
|
||||
about 0.3 kW per phase) at +20.0 min. The five evenings whose cycle ended after 12-15 min (10-02, 03,
|
||||
04, 06, 21) show none. Mornings 10-06 and 10-23 also show +640 and +756 W at +20 min (PV noise
|
||||
makes mornings unreliable). Matches 4147 = 20.
|
||||
- It is not triggered by the outlet limit: at heater start the outlet was 39 (morning) and 46 (evening),
|
||||
below TmaxPc 52. Not by CO (`output_co` 0), disinfection (4226 = 1), alarm (0), pressure switch (0)
|
||||
or outdoor temperature (4.6-11.9 C). The cwu temperature differs by 12 C between the two starts.
|
||||
- `zb_state` = 2 in all 6 samples with `output_heater` = 100 and 0 otherwise: it is the CWU heater ("zbCW").
|
||||
- The heater ends with the compressor: at the period end (episode 1) or at the TmaxPc stop (episode 2:
|
||||
outlet 51.7, compressor and heater off, outlet 42.1 five minutes later, cwu 42.2 below target).
|
||||
The compressor restarts ~18:25:30 (stage 33) without the heater (not 20 min) and stops at 18:30 when
|
||||
cwu reaches 43.0. Evening heater runs last 1-10 min (meter), ending at the outlet limit.
|
||||
- Energy at ~0.9 kW: episode 1 about 0.3 kWh; an evening run 0.02-0.15 kWh. Small next to the
|
||||
compressor (+1.4-1.7 kW at stage 33, about +3 kW with the pumps at 66).
|
||||
|
||||
Hypothesis: the heater delay is measured from the compressor start of a CWU request, not from a
|
||||
temperature condition. A cycle that finishes within 20 min never uses it.
|
||||
|
||||
## CWU heating cycles (legacy POMPA, 2026-09-10..10-24, outlet and cwu rise)
|
||||
|
||||
- Start: 11:25 (43 cycles) and 17:55 (33 cycles), meter-confirmed: they begin with the comfort period.
|
||||
- Duration, 5-min resolution: morning median 35 min (15-50), evening median 25 min (15-50).
|
||||
- Start cwu: morning 13-36 C (median 26), evening 18-44 C (median 36). Cold starts run 40-50 min.
|
||||
- End cwu: 43-44.2 C when the target is reached (overshoot <= 1.2 C), observed 25 evening and 21
|
||||
morning times. Other morning cycles end at 38.7-42 C because comfort ends at ~12:05.
|
||||
- Heating continues to the target, not for a fixed time. The sensor keeps rising 0.5-0.7 C for 5-10 min
|
||||
after the compressor stops (stratification): do not read the end of a cycle from cwu alone.
|
||||
- `output_cwu` stays 100 for 5-10 min after the compressor stops; `pump_mode` can read 0 while the
|
||||
compressor runs (10-24 13:36, 18:24, 18:29). Do not use 4227 alone to show "heating CWU".
|
||||
- Start condition is unclear: cycles began at cwu 40.8-42.8 C (10-02, 03, 04, 06) and a test at 41.4 vs
|
||||
target 45 started within 5 min, yet 10-19/20 mornings (38.2, 39.2 C) and 10-20 evening (38.9 C)
|
||||
had none. HistCWU = 5 does not explain both; not resolved.
|
||||
- No restart inside a comfort period was seen (cwu stayed above 42.7 until the period ended).
|
||||
- The 10-24 13:21-13:45 cycle (cwu 41.4 to 43.2) was caused by our test writes, not by the program.
|
||||
@@ -47,3 +47,6 @@ the Frisko app. Party mode (4140 = 2, hours in 4142) is a simpler "heat now" ove
|
||||
- Setting CWU above what the pump reaches risks the electric heater (after the delay) and
|
||||
pressure-switch trips; three trips in one cycle latch a fault until manual reset (MR208-PC+ docs).
|
||||
- Frisko phone app: direct Modbus TCP, LAN only, 200–300 ms between requests, 5 s timeout.
|
||||
- History analysis of 2026-10 ([history-2026-10.md](history-2026-10.md)): the heater joins 20 min into a
|
||||
CWU cycle (4147); `zb_state` 2 = heater; 4227 is unreliable for "heating CWU"; the controller clock
|
||||
does not follow DST; period = target compared with the setpoints, valid only with 4226 = 1.
|
||||
+4
-2
@@ -76,7 +76,8 @@ Functional:
|
||||
- F1 Live state of the heat pump: idle / heating CWU / heating CO, compressor stage, pumps,
|
||||
heater, alarm, current targets and temperatures; the BME280 reading (temperature, humidity,
|
||||
pressure) of the living room. An "Odśwież" button asks for one extra poll of each device, at most once
|
||||
a minute per device (the MK01 takes one client).
|
||||
a minute per device (the MK01 takes one client). It also shows the period (comfort or economy) of CWU and room,
|
||||
and a warning while the electric heater runs.
|
||||
- F2 History for the last 7 days, and per-day heat pump statistics for 7 or 30 days (compressor, CWU, CO and heater hours,
|
||||
starts "at least" because polls are 5 minutes apart).
|
||||
- F3 Change four setpoints: economy and comfort temperature of CWU (4143, 4144) and CO
|
||||
@@ -92,7 +93,8 @@ Functional:
|
||||
through the same checks.
|
||||
- F4 Alarms with notifications: heat pump alarm, controller link lost, grid outage (house on battery).
|
||||
- F5 Health of backend, device links and tunnel.
|
||||
- F6 Later, low priority: show the weekly program (reading needs selector writes).
|
||||
- F6 Show the weekly program as the history shows it (comfort intervals per weekday, from the polls; nothing is
|
||||
asked of the controller, since reading its program needs selector writes). Editing it stays out.
|
||||
- F7 Energy view: PV production, battery state, grid import/export, house consumption; day totals; a details panel per device (Deye, Fronius, house meter); a battery view with SOC, power, the Deye's program schedule and limits.
|
||||
|
||||
- F8 Battery settings (ADR-013, off until enabled): change the Deye's Time of Use switch and the six slots (start, SOC, grid charge) from the Bateria view, and the app's backup reserve (default 50 %, floor 30 %) that every enabled slot SOC must hold. Refused when the settings reading is older than 30 min or changed meanwhile; read back, no restore.
|
||||
|
||||
Reference in new issue
Block a user