feat(shelly): optimize dew point ventilation setpoints, duty cycle, and night-window gating

- Raise target humidity setpoints from 40% to 55% with a 2% hysteresis shut-off
- Eliminate 9999-min unlimited runtime bypass and strictly cap bursts at 35 min
- Rebalance duty cycle to 1:1 ratio (35 min run, 35 min cooldown rest)
- Implement Shelly internal RTC time-of-day check to restrict routine drying to 02:00-07:00
- Add delta-T guard requiring spread >= 3.5°C and max 20 min bursts when outside is >8°C colder
- Update AGENTS.md documentation to reflect optimized gates and thresholds
This commit is contained in:
Moritz Graf
2026-10-20 15:46:30 +02:00
parent 28eedea372
commit 9903194316
3 changed files with 65 additions and 34 deletions
+11 -11
View File
@@ -25,20 +25,20 @@ In warm months, blowing outdoor air into a cool cellar can heat up the cellar ma
* **Ceiling Protection**: If $T_{in} \ge 20^\circ\text{C}$, the fan is **only** permitted to run if outdoor air is cooler than inside ($T_{out} \le T_{in}$). * **Ceiling Protection**: If $T_{in} \ge 20^\circ\text{C}$, the fan is **only** permitted to run if outdoor air is cooler than inside ($T_{out} \le T_{in}$).
* **Warming Allowance**: If $T_{in} < 20^\circ\text{C}$, gentle warming is permitted, but outdoor temperature cannot exceed $20^\circ\text{C}$ ($T_{out} \le 20^\circ\text{C}$). * **Warming Allowance**: If $T_{in} < 20^\circ\text{C}$, gentle warming is permitted, but outdoor temperature cannot exceed $20^\circ\text{C}$ ($T_{out} \le 20^\circ\text{C}$).
### 3. Seasonal Target Humidities ### 3. Target Humidities
* **Cold / Winter Mode** ($T_{in} < 14^\circ\text{C}$ or $T_{out} < 10^\circ\text{C}$): Targets **50% RH**. Once 50% RH is reached, the fan stops to preserve cellar warmth. * **Target Humidity**: Set to **55% RH** (with a 2% hysteresis shut-off down to 53% when ventilating). Once 55% RH is reached, the fan rests to preserve cellar warmth and prevent excessive wall moisture desorption.
* **Warm / Summer Mode**: Targets **40% RH** for maximum drying.
* **Hard Cold Floor**: The fan is blocked if $T_{in} \le \text{mindesttemperatur}$ (default 10°C). * **Hard Cold Floor**: The fan is blocked if $T_{in} \le \text{mindesttemperatur}$ (default 10°C).
### 4. 2-Tier Scheduling & Optimal Night Window ### 4. 2-Tier Scheduling & Optimal Night Window
In Central Europe / Germany, the diurnal temperature and absolute humidity minimum occurs in early morning (~03:00–07:00, peak around 05:00): In Central Europe / Germany, the diurnal temperature and absolute humidity minimum occurs in early morning (~02:00–07:00, peak drying yield around 03:00–05:00 at ~7.6 g/m³ outdoor vapor content):
* **Urgent Drying ($RH_{in} > 60\%$)**: Runs 24/7 whenever psychrometric and temperature guards allow. * **Urgent Drying ($RH_{in} > 60\%$ or Mold Critical)**: Evaluated and run 24/7 whenever psychrometric and temperature guards allow.
* **Routine Drying ($40\% \le RH_{in} \le 60\%$)**: Restricted to the **optimal night window (03:00–07:00)** to exploit maximum dew point spread. * **Routine Drying ($53\% \le RH_{in} \le 60\%$)**: Strictly restricted to the **optimal night window (02:00–07:00)** to exploit maximum dew point spread and prevent wasteful daytime runs.
* **Thermal Delta Guard**: If $T_{in} - T_{out} > 8.0^\circ\text{C}$ (very cold outside draft), spread requirement increases by +1.5°C (spread $\ge 3.5^\circ\text{C}$) and burst duration is throttled to 20 min max.
### 5. Duty Cycle & Thermal Equilibrium ### 5. Duty Cycle & Thermal Equilibrium
To allow cellar air and thermal wall mass to equilibrate and prevent non-stop continuous blowing: To allow cellar air and thermal wall mass to equilibrate and prevent non-stop continuous blowing:
* **Max Continuous Runtime**: 90 minutes. * **Max Continuous Runtime**: Strictly capped at **35 minutes** (scaled dynamically between 15 and 35 min based on dew point spread; no unlimited 9999 bypass).
* **Cooldown Rest**: 45 minutes pause after hitting max continuous runtime. * **Cooldown Rest**: **35 minutes pause** (1:1 duty cycle) after hitting max runtime to allow concrete and masonry moisture desorption.
--- ---
@@ -53,11 +53,11 @@ To allow cellar air and thermal wall mass to equilibrate and prevent non-stop co
│ YES │ YES
[ Overheating Safe (Tout vs Tin)? ] [ Overheating Safe (Tout vs Tin)? ]
│ YES │ YES
[ Cellar RH > Target (40% / 50%)? ] [ Cellar RH > Target Setpoint (55%)? ]
│ YES │ YES
[ Dew Point Spread > Threshold (2°C)? ] [ Urgent (>60%) OR Night Window (02-07)? ]
│ YES │ YES
[ Urgent (>60%) OR Night Window (03-07)? ] [ Dew Point Spread > Required Threshold? ]
│ YES │ YES
┌────────┴────────┐ ┌────────┴────────┐
▼ ▼ ▼ ▼
@@ -13,6 +13,10 @@ var MAX_HEAT_CEIL = ${max_indoor_temp_heating};
var WINTER_IN_TH = ${winter_temp_threshold_in}; var WINTER_IN_TH = ${winter_temp_threshold_in};
var TARGET_SUMMER = ${target_humi_summer}; var TARGET_SUMMER = ${target_humi_summer};
var TARGET_WINTER = ${target_humi_winter}; var TARGET_WINTER = ${target_humi_winter};
var URGENT_HUMI_TH = ${urgent_humi_threshold};
var NIGHT_START_H = ${night_window_start_hour};
var NIGHT_END_H = ${night_window_end_hour};
var MAX_RUN_MIN = ${max_runtime_minutes};
var MIN_PAUSE_MIN = ${min_pause_minutes}; var MIN_PAUSE_MIN = ${min_pause_minutes};
var LIM_BASE = ${critical_humi_base}; var LIM_BASE = ${critical_humi_base};
@@ -28,6 +32,16 @@ var st_run = 0, st_target = TARGET_SUMMER, st_mold_th = 70.0, st_mold_crit = 0,
var st_quality = 0, st_max_run_min = 0, st_gov_state = 0; var st_quality = 0, st_max_run_min = 0, st_gov_state = 0;
var pid_out = -1, pid_in = -1; var pid_out = -1, pid_in = -1;
function getCurrentHour() {
var s = Shelly.getComponentStatus("sys");
if (!s || typeof s.time !== "string" || s.time.length < 2) return undefined;
var h0 = s.time.charCodeAt(0) - 48;
var h1 = s.time.charCodeAt(1) - 48;
if (h0 < 0 || h0 > 9 || h1 < 0 || h1 > 9) return undefined;
var h = h0 * 10 + h1;
return (h >= 0 && h <= 23) ? h : undefined;
}
function calcDewPoint(T, RH) { function calcDewPoint(T, RH) {
if (typeof T !== "number" || typeof RH !== "number" || isNaN(T) || isNaN(RH) || RH <= 0) return undefined; if (typeof T !== "number" || typeof RH !== "number" || isNaN(T) || isNaN(RH) || RH <= 0) return undefined;
var a = (T >= 0) ? 17.27 : 21.875, b = (T >= 0) ? 237.7 : 265.5; var a = (T >= 0) ? 17.27 : 21.875, b = (T >= 0) ? 237.7 : 265.5;
@@ -60,6 +74,7 @@ function evaluateDecision() {
var reqSpread = DP_SPREAD_BASE; var reqSpread = DP_SPREAD_BASE;
var targetHumi = TARGET_SUMMER; var targetHumi = TARGET_SUMMER;
var govState = 2; // Comfort default var govState = 2; // Comfort default
var deltaT = ti - to; // Positive when outside is colder than cellar
if (ti > MAX_HEAT_CEIL) { if (ti > MAX_HEAT_CEIL) {
// Warm Zone (> 20°C): Maximize cooling. Prevent heating if To > Ti. // Warm Zone (> 20°C): Maximize cooling. Prevent heating if To > Ti.
@@ -71,8 +86,10 @@ function evaluateDecision() {
// Comfort Zone (14°C - 20°C) // Comfort Zone (14°C - 20°C)
govState = 2; govState = 2;
targetHumi = TARGET_SUMMER; targetHumi = TARGET_SUMMER;
if (to < (ti - 5.0)) { if (deltaT > 8.0) {
reqSpread = DP_SPREAD_BASE + 1.0; // Strong cooling requires prime spread >= 3.0°C reqSpread = DP_SPREAD_BASE + 1.5; // Very cold outside air requires premium spread >= 3.5°C
} else if (deltaT > 5.0) {
reqSpread = DP_SPREAD_BASE + 1.0; // Moderate cooling requires spread >= 3.0°C
} }
} else { } else {
// Cold Zone (< 14°C) // Cold Zone (< 14°C)
@@ -85,30 +102,44 @@ function evaluateDecision() {
} }
} }
// 3. Target Humidity Gate // 3. Target Humidity Gate with Hysteresis (2% lower to stop when already ventilating)
if (hi <= targetHumi) { var effTarget = relay_on ? (targetHumi - 2.0) : targetHumi;
if (hi <= effTarget) {
return { run: false, reason: "TARGET_REACHED", maxRun: 0, qual: 0, gov: govState }; return { run: false, reason: "TARGET_REACHED", maxRun: 0, qual: 0, gov: govState };
} }
// 4. Dew Point Spread Gate // 4. 2-Tier Scheduling: Urgent (>60% or mold crit) runs 24/7; Routine (<=60%) restricted to night window
var isUrgent = (hi > URGENT_HUMI_TH || isMoldCrit);
if (!isUrgent) {
var curHour = getCurrentHour();
if (typeof curHour === "number") {
var inWindow = (NIGHT_START_H <= NIGHT_END_H)
? (curHour >= NIGHT_START_H && curHour < NIGHT_END_H)
: (curHour >= NIGHT_START_H || curHour < NIGHT_END_H);
if (!inWindow) {
return { run: false, reason: "OUTSIDE_NIGHT_WINDOW", maxRun: 0, qual: 0, gov: govState };
}
}
}
// 5. Dew Point Spread Gate
var spread = di - do_; var spread = di - do_;
if (spread < reqSpread) { if (spread < reqSpread) {
return { run: false, reason: "INSUFFICIENT_SPREAD", maxRun: 0, qual: Math.round(Math.max(0, spread * 20)), gov: govState }; return { run: false, reason: "INSUFFICIENT_SPREAD", maxRun: 0, qual: Math.round(Math.max(0, spread * 20)), gov: govState };
} }
// 5. Dynamic Quality Runtime Formula // 6. Dynamic Quality Runtime Formula with Strict Upper Cap (MAX_RUN_MIN)
// Runtime [min] = (spread - DP_SPREAD_BASE) * 45 min var maxRunMin = Math.round((spread - DP_SPREAD_BASE) * 20);
var maxRunMin = 0;
if (spread >= 5.0) {
maxRunMin = 9999; // Unlimited continuous run
} else {
maxRunMin = Math.round((spread - DP_SPREAD_BASE) * 45);
if (maxRunMin < 15) maxRunMin = 15; if (maxRunMin < 15) maxRunMin = 15;
} if (maxRunMin > MAX_RUN_MIN) maxRunMin = MAX_RUN_MIN;
// Cold Zone burst throttle (max 25 min bursts when Ti < 14°C) // Cold Zone burst throttle (max 20 min bursts when Ti < 14°C)
if (ti < WINTER_IN_TH && maxRunMin > 25) { if (ti < WINTER_IN_TH && maxRunMin > 20) {
maxRunMin = 25; maxRunMin = 20;
}
// Large Delta T throttle (max 20 min bursts when outside is >8°C colder)
if (deltaT > 8.0 && maxRunMin > 20) {
maxRunMin = 20;
} }
var qualityScore = Math.min(100, Math.round(spread * 20)); var qualityScore = Math.min(100, Math.round(spread * 20));
@@ -139,7 +170,7 @@ function applyDecision(d) {
if (d.run) { if (d.run) {
fan_run_sec += SCHALTZEIT_SEC; fan_run_sec += SCHALTZEIT_SEC;
if (d.maxRun < 9999 && fan_run_sec >= d.maxRun * 60) { if (d.maxRun > 0 && fan_run_sec >= d.maxRun * 60) {
d.run = false; d.run = false;
last_reason = "BURST_LIMIT_REACHED"; last_reason = "BURST_LIMIT_REACHED";
fan_pause_sec = MIN_PAUSE_MIN * 60; fan_pause_sec = MIN_PAUSE_MIN * 60;
+5 -5
View File
@@ -47,13 +47,13 @@ variable "winter_temp_threshold_out" {
variable "target_humi_summer" { variable "target_humi_summer" {
type = number type = number
description = "Target indoor relative humidity [%] during warm / summer mode" description = "Target indoor relative humidity [%] during warm / summer mode"
default = 40.0 default = 55.0
} }
variable "target_humi_winter" { variable "target_humi_winter" {
type = number type = number
description = "Target indoor relative humidity [%] during cold / winter mode (avoids over-cooling)" description = "Target indoor relative humidity [%] during cold / winter mode (avoids over-cooling)"
default = 50.0 default = 55.0
} }
variable "urgent_humi_threshold" { variable "urgent_humi_threshold" {
@@ -65,7 +65,7 @@ variable "urgent_humi_threshold" {
variable "night_window_start_hour" { variable "night_window_start_hour" {
type = number type = number
description = "Start hour (0-23) for the optimal dew point drying window" description = "Start hour (0-23) for the optimal dew point drying window"
default = 3 default = 2
} }
variable "night_window_end_hour" { variable "night_window_end_hour" {
@@ -77,13 +77,13 @@ variable "night_window_end_hour" {
variable "max_runtime_minutes" { variable "max_runtime_minutes" {
type = number type = number
description = "Maximum continuous fan runtime in minutes before enforcing a cooldown pause" description = "Maximum continuous fan runtime in minutes before enforcing a cooldown pause"
default = 90 default = 35
} }
variable "min_pause_minutes" { variable "min_pause_minutes" {
type = number type = number
description = "Minimum rest period in minutes after a ventilation burst to allow thermal/humidity equilibration" description = "Minimum rest period in minutes after a ventilation burst to allow thermal/humidity equilibration"
default = 20 default = 35
} }
variable "schaltzeit" { variable "schaltzeit" {