fix: various fixes
This commit is contained in:
@@ -6,7 +6,7 @@ export interface LoopDecisionInput {
|
||||
nowMs: number;
|
||||
/** Only restore once projected power is at least this far below target, to avoid flapping. */
|
||||
restoreMarginW: number;
|
||||
/** Whether HA's sun.sun entity currently reports above_horizon. */
|
||||
/** Whether the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */
|
||||
sunUp: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { QueryApi } from "@influxdata/influxdb-client";
|
||||
import type { Env } from "../env.js";
|
||||
import { fetchPowerSamplesSince } from "../influx/power.js";
|
||||
import {
|
||||
fetchLatestPowerSample,
|
||||
fetchLatestSolarProductionSample,
|
||||
fetchPowerSamplesSince,
|
||||
} from "../influx/power.js";
|
||||
import { HomeAssistantClient } from "../homeassistant/client.js";
|
||||
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
|
||||
import type { Store } from "../db/store.js";
|
||||
import type { DeviceStatus, QuarterHourStatus } from "../types.js";
|
||||
import type { CurrentReadings, DeviceStatus, QuarterHourStatus } from "../types.js";
|
||||
import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
|
||||
import { decideControlAction } from "./loop.js";
|
||||
|
||||
@@ -19,17 +23,24 @@ export class ControlLoopRunner {
|
||||
private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
|
||||
) {}
|
||||
|
||||
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> {
|
||||
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> {
|
||||
const nowMs = Date.now();
|
||||
const blockStart = blockStartFor(nowMs);
|
||||
const targetW = this.store.getTargetW();
|
||||
|
||||
const samples = await fetchPowerSamplesSince(this.queryApi, this.env, blockStart);
|
||||
const [samples, latestPower, latestSolar, devices] = await Promise.all([
|
||||
fetchPowerSamplesSince(this.queryApi, this.env, blockStart),
|
||||
fetchLatestPowerSample(this.queryApi, this.env),
|
||||
fetchLatestSolarProductionSample(this.queryApi, this.env),
|
||||
this.loadDeviceStatuses(),
|
||||
]);
|
||||
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
|
||||
const current: CurrentReadings = {
|
||||
powerW: latestPower?.watts ?? null,
|
||||
solarW: latestSolar?.watts ?? null,
|
||||
};
|
||||
|
||||
const devices = await this.loadDeviceStatuses();
|
||||
|
||||
return { quarterHour, devices };
|
||||
return { quarterHour, devices, current };
|
||||
}
|
||||
|
||||
private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
|
||||
@@ -42,6 +53,26 @@ export class ControlLoopRunner {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the solar inverter is currently producing power, used to gate
|
||||
* `onlyWhenSun` devices. Reads the configured InfluxDB solar production
|
||||
* series and compares its latest sample against SOLAR_PRODUCTION_THRESHOLD_W.
|
||||
* Falls back to HA's sun.sun (astronomical above/below horizon) when no
|
||||
* solar production series is configured, or if either read fails.
|
||||
*/
|
||||
private async isSolarProducing(): Promise<boolean> {
|
||||
try {
|
||||
const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env);
|
||||
if (sample) return sample.watts >= this.env.SOLAR_PRODUCTION_THRESHOLD_W;
|
||||
} catch (err) {
|
||||
this.log.error({ err }, "failed to read solar production series; falling back to sun.sun");
|
||||
}
|
||||
return this.ha.isSunUp().catch((err) => {
|
||||
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async tick(): Promise<void> {
|
||||
const nowMs = Date.now();
|
||||
const { quarterHour, devices } = await this.getStatus();
|
||||
@@ -50,10 +81,7 @@ export class ControlLoopRunner {
|
||||
// as that block's final average, with no separate "finalize" step needed.
|
||||
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
|
||||
|
||||
const sunUp = await this.ha.isSunUp().catch((err) => {
|
||||
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
|
||||
return true;
|
||||
});
|
||||
const sunUp = await this.isSolarProducing();
|
||||
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
|
||||
if (!decision) return;
|
||||
|
||||
|
||||
@@ -20,6 +20,15 @@ const envSchema = z.object({
|
||||
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
|
||||
HA_URL: z.string().url(),
|
||||
HA_TOKEN: z.string().min(1),
|
||||
// Solar inverter production, read from InfluxDB (same shape as the house
|
||||
// power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to
|
||||
// sun.sun (astronomical day/night).
|
||||
SOLAR_INFLUX_MEASUREMENT: z.string().default("power"),
|
||||
SOLAR_INFLUX_DOMAIN: z.string().optional(),
|
||||
SOLAR_INFLUX_ENTITY_ID: z.string().optional(),
|
||||
SOLAR_INFLUX_FIELD: z.string().default("value"),
|
||||
SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
|
||||
SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50),
|
||||
TARGET_KW: z.coerce.number().positive(),
|
||||
DB_PATH: z.string().default("./data/energy.db"),
|
||||
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),
|
||||
|
||||
@@ -2,27 +2,40 @@ import type { QueryApi } from "@influxdata/influxdb-client";
|
||||
import type { Env } from "../env.js";
|
||||
import type { PowerSample } from "../types.js";
|
||||
|
||||
interface SeriesConfig {
|
||||
bucket: string;
|
||||
measurement: string;
|
||||
field: string;
|
||||
fieldUnit: "W" | "kW";
|
||||
domain?: string;
|
||||
entityId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw power samples between `sinceMs` and now.
|
||||
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
|
||||
* Fetches raw samples for one series between `sinceMs` and now, converting
|
||||
* the configured field to watts based on `fieldUnit`.
|
||||
*/
|
||||
export async function fetchPowerSamplesSince(
|
||||
async function fetchSeriesSince(
|
||||
queryApi: QueryApi,
|
||||
env: Env,
|
||||
config: SeriesConfig,
|
||||
sinceMs: number,
|
||||
): Promise<PowerSample[]> {
|
||||
const sinceIso = new Date(sinceMs).toISOString();
|
||||
const domainFilter = config.domain
|
||||
? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n `
|
||||
: "";
|
||||
const entityFilter = config.entityId
|
||||
? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n `
|
||||
: "";
|
||||
const flux = `
|
||||
from(bucket: "${env.INFLUX_BUCKET}")
|
||||
from(bucket: "${config.bucket}")
|
||||
|> range(start: ${sinceIso})
|
||||
|> filter(fn: (r) => r["_measurement"] == "${env.INFLUX_FIELD_UNIT}")
|
||||
|> filter(fn: (r) => r["_field"] == "${env.INFLUX_FIELD}")
|
||||
|> filter(fn: (r) => r["domain"] == "${env.INFLUX_DOMAIN}")
|
||||
|> filter(fn: (r) => r["entity_id"] == "${env.INFLUX_ENTITY_ID}")
|
||||
|> sort(columns: ["_time"])
|
||||
|> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|
||||
|> filter(fn: (r) => r["_field"] == "${config.field}")
|
||||
${domainFilter}${entityFilter}|> sort(columns: ["_time"])
|
||||
`;
|
||||
|
||||
const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1;
|
||||
const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1;
|
||||
const samples: PowerSample[] = [];
|
||||
|
||||
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
|
||||
@@ -36,6 +49,29 @@ export async function fetchPowerSamplesSince(
|
||||
return samples;
|
||||
}
|
||||
|
||||
function housePowerConfig(env: Env): SeriesConfig {
|
||||
return {
|
||||
bucket: env.INFLUX_BUCKET,
|
||||
measurement: env.INFLUX_MEASUREMENT,
|
||||
field: env.INFLUX_FIELD,
|
||||
fieldUnit: env.INFLUX_FIELD_UNIT,
|
||||
domain: env.INFLUX_DOMAIN,
|
||||
entityId: env.INFLUX_ENTITY_ID,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches raw power samples between `sinceMs` and now.
|
||||
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
|
||||
*/
|
||||
export async function fetchPowerSamplesSince(
|
||||
queryApi: QueryApi,
|
||||
env: Env,
|
||||
sinceMs: number,
|
||||
): Promise<PowerSample[]> {
|
||||
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
|
||||
}
|
||||
|
||||
/** Fetches the single most recent power sample, if any exists within the lookback window. */
|
||||
export async function fetchLatestPowerSample(
|
||||
queryApi: QueryApi,
|
||||
@@ -49,3 +85,26 @@ export async function fetchLatestPowerSample(
|
||||
);
|
||||
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the most recent solar production sample from InfluxDB, if any
|
||||
* exists within the lookback window. Returns null when no solar production
|
||||
* series is configured (SOLAR_INFLUX_ENTITY_ID unset).
|
||||
*/
|
||||
export async function fetchLatestSolarProductionSample(
|
||||
queryApi: QueryApi,
|
||||
env: Env,
|
||||
lookbackMs = 60_000,
|
||||
): Promise<PowerSample | null> {
|
||||
if (!env.SOLAR_INFLUX_ENTITY_ID) return null;
|
||||
const config: SeriesConfig = {
|
||||
bucket: env.INFLUX_BUCKET,
|
||||
measurement: env.SOLAR_INFLUX_MEASUREMENT,
|
||||
field: env.SOLAR_INFLUX_FIELD,
|
||||
fieldUnit: env.SOLAR_INFLUX_FIELD_UNIT,
|
||||
domain: env.SOLAR_INFLUX_DOMAIN,
|
||||
entityId: env.SOLAR_INFLUX_ENTITY_ID,
|
||||
};
|
||||
const samples = await fetchSeriesSince(queryApi, config, Date.now() - lookbackMs);
|
||||
return samples.length > 0 ? samples[samples.length - 1] : null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface DeviceConfig {
|
||||
maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1)
|
||||
dwellSeconds: number; // minimum time between state changes for this device
|
||||
manualOverride: boolean; // if true, control loop leaves this device alone
|
||||
onlyWhenSun: boolean; // if true, device may only be enabled while sun.sun reports above_horizon
|
||||
onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power
|
||||
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
|
||||
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
|
||||
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
|
||||
@@ -39,6 +39,11 @@ export interface QuarterHourStatus {
|
||||
overTarget: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentReadings {
|
||||
powerW: number | null; // most recent house power sample, null if none within lookback
|
||||
solarW: number | null; // most recent solar production sample, null if unconfigured or none within lookback
|
||||
}
|
||||
|
||||
export interface ControlAction {
|
||||
timestampMs: number;
|
||||
entityId: string;
|
||||
|
||||
Reference in New Issue
Block a user