Skip to content

Commit 91478af

Browse files
Luca AzzinnaroLuca Azzinnaro
authored andcommitted
Fix critical bugs in Night-Humidity-Priority and EnvironmentGuard integration
Bug Fixes: 1. Night-Humidity-Priority now ALWAYS active in VPD modes - Previously only active when vpdDeviceDampening=False - Now integrated into process_actions_with_dampening() path - Ensures consistent energy-efficient behavior across all configurations 2. EnvironmentGuard now ALWAYS active in VPD modes - Previously only active in fallback path (vpdDeviceDampening=False) - Critical safety fix: prevents cold/dry air intake without protection - Applied AFTER Night-Humidity-Priority and Device Dampening 3. Physikalisch-aware Air-Exchange suppression - Air-Exchange now suppressed when it works against humidity goal - RH high + Temp low: Dehumidifier only (Air-Exchange would cool) - RH low + Temp high: Cooler only (Air-Exchange would dry) - Extreme temp deviation (≥5°C): Allows all actions Changes: - OGBDampeningActions.process_actions_with_dampening(): Added night humidity priority - OGBActionManager.checkLimitsAndPublicateWithDampening(): Added EnvironmentGuard - ClosedActions: Added air-mixing suppression for night humidity priority - Tests: Added comprehensive coverage for new behavior - Docs: Updated with integration notes and bug fix details Test Results: All 117 tests passing
1 parent 6f9e5ca commit 91478af

7 files changed

Lines changed: 582 additions & 40 deletions

File tree

custom_components/opengrowbox/OGBController/actions/ClosedActions.py

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,9 @@ async def execute_closed_environment_cycle(self, capabilities: Dict[str, Any]):
612612

613613
# Humidity Control (only if NOT in humidity deadband)
614614
hum_status = "stabil"
615+
hum_needs_correction = False
615616
if not hum_in_db:
617+
hum_needs_correction = True
616618
hum_actions, hum_status = await self.control_humidity_closed(capabilities)
617619
all_actions.extend(hum_actions)
618620
else:
@@ -621,18 +623,21 @@ async def execute_closed_environment_cycle(self, capabilities: Dict[str, Any]):
621623

622624
# Temperature Control (only if NOT in temp deadband)
623625
temp_status = "stabil"
626+
temp_needs_correction = False
624627
if not temp_in_db:
625-
if night_humidity_priority and not hum_in_db and abs(hum_dev) > 0:
626-
temp_limit_dev = self.control_logic.calculate_temperature_deviation().get("deviation", 0)
627-
if abs(temp_limit_dev) >= self.night_humidity_temp_guard:
628+
temp_needs_correction = True
629+
temp_dev_data = self.control_logic.calculate_temperature_deviation()
630+
temp_limit_dev = temp_dev_data.get("deviation", 0)
631+
632+
if night_humidity_priority and hum_needs_correction:
633+
should_control_temp = self._should_control_temp_at_night(hum_status, temp_dev_data)
634+
if should_control_temp:
628635
temp_actions, temp_status = await self.control_temperature_closed(capabilities)
629636
all_actions.extend(temp_actions)
630637
else:
631-
temp_status = (
632-
f"nacht-priorisiert (Feuchte zuerst, Temp-Limitabweichung {abs(temp_limit_dev):.1f}°C zu klein)"
633-
)
638+
temp_status = self._get_night_suppression_reason(hum_status, temp_dev_data)
634639
_LOGGER.info(
635-
f"{self.ogb.room}: Closed Environment night humidity priority active - suppressing temp control in favor of humidity"
640+
f"{self.ogb.room}: Night humidity priority - {temp_status}"
636641
)
637642
else:
638643
temp_actions, temp_status = await self.control_temperature_closed(capabilities)
@@ -642,9 +647,18 @@ async def execute_closed_environment_cycle(self, capabilities: Dict[str, Any]):
642647
_LOGGER.debug(f"{self.ogb.room}: Temperature in deadband ({temp_dev:.1f}°C deviation) - skipping temp actions")
643648

644649
# Air Recirculation (only if neither temp nor humidity in deadband)
650+
# But suppress at night if humidity priority active and opposing directions
645651
if not temp_in_db and not hum_in_db:
646-
air_actions = await self.optimize_air_recirculation(capabilities)
647-
all_actions.extend(air_actions)
652+
if night_humidity_priority:
653+
should_run_air_mixing = self._should_run_air_mixing_at_night(hum_status, temp_dev, hum_dev)
654+
if should_run_air_mixing:
655+
air_actions = await self.optimize_air_recirculation(capabilities)
656+
all_actions.extend(air_actions)
657+
else:
658+
_LOGGER.debug(f"{self.ogb.room}: Night humidity priority - skipping air mixing")
659+
else:
660+
air_actions = await self.optimize_air_recirculation(capabilities)
661+
all_actions.extend(air_actions)
648662
else:
649663
_LOGGER.debug(f"{self.ogb.room}: Skipping air recirculation (deadband active)")
650664

@@ -959,6 +973,65 @@ def _is_night_humidity_priority_active(self) -> bool:
959973
and bool(self.ogb.dataStore.getDeep("controlOptions.nightVPDHold", True))
960974
)
961975

976+
def _should_control_temp_at_night(self, hum_status: str, temp_dev_data: Dict[str, Any]) -> bool:
977+
"""Determine if temperature control should be active during night humidity priority.
978+
979+
Physikalisch-aware Logik (Option C):
980+
- Extreme Temp-Abweichung (>= 5°C): Immer erlauben (Pflanzenschutz)
981+
- RH hoch + Temp niedrig: Entfeuchter reicht (wärmt dabei) → Keine Temp-Kontrolle
982+
- RH niedrig + Temp hoch: Kühler reicht (erhöht RH) → Keine Temp-Kontrolle
983+
- Andere Kombinationen: Keine Temp-Kontrolle bei moderate Abweichung
984+
"""
985+
temp_status = temp_dev_data.get("status")
986+
temp_deviation = abs(temp_dev_data.get("deviation", 0))
987+
988+
if temp_status == "no_data" or temp_status == "invalid":
989+
return False
990+
991+
if temp_status == "stabil":
992+
return False
993+
994+
if temp_deviation >= 5.0:
995+
return True
996+
997+
if hum_status == "entfeuchten" and temp_status == "too_low":
998+
return False
999+
1000+
if hum_status == "befeuchten" and temp_status == "too_high":
1001+
return False
1002+
1003+
return False
1004+
1005+
def _get_night_suppression_reason(self, hum_status: str, temp_dev_data: Dict[str, Any]) -> str:
1006+
"""Get human-readable reason for temperature suppression at night."""
1007+
temp_status = temp_dev_data.get("status")
1008+
temp_deviation = abs(temp_dev_data.get("deviation", 0))
1009+
1010+
if hum_status == "entfeuchten" and temp_status == "too_low":
1011+
return f"RH-Priorität: Entfeuchter senkt RH und wärmt (Temp {temp_deviation:.1f}°C zu niedrig ignoriert)"
1012+
if hum_status == "befeuchten" and temp_status == "too_high":
1013+
return f"RH-Priorität: Kühler senkt Temp und erhöht RH (Temp {temp_deviation:.1f}°C zu hoch ignoriert)"
1014+
if temp_deviation < 5.0:
1015+
return f"RH-Priorität: Tempabweichung {temp_deviation:.1f}°C < 5°C Schwellenwert"
1016+
1017+
return "RH-Priorität aktiv"
1018+
1019+
def _should_run_air_mixing_at_night(self, hum_status: str, temp_dev: float, hum_dev: float) -> bool:
1020+
"""Determine if air mixing should run during night humidity priority.
1021+
1022+
Physikalisch-aware Logik (Option C):
1023+
- RH hoch + Temp niedrig: Air-Exchange würde kühlen → unterdrücken
1024+
- RH niedrig + Temp hoch: Air-Exchange würde trocknen → unterdrücken
1025+
- Andere Kombinationen: Erlauben
1026+
"""
1027+
if hum_status == "entfeuchten" and temp_dev < 0:
1028+
return False
1029+
1030+
if hum_status == "befeuchten" and temp_dev > 0:
1031+
return False
1032+
1033+
return True
1034+
9621035
def _determine_co2_status(self, current_co2, min_co2, max_co2, is_light_on: bool) -> str:
9631036
"""Determine CO2 control status."""
9641037
try:

custom_components/opengrowbox/OGBController/actions/OGBDampeningActions.py

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ async def process_actions_with_dampening(self, action_map: List) -> List:
151151
vpd_status, # Add VPD status for context-aware enhancement
152152
)
153153

154+
# Apply night humidity priority if active (physikalisch-aware)
155+
if is_night_vpd_hold:
156+
enhanced_action_map = self._apply_night_humidity_priority(
157+
enhanced_action_map,
158+
real_temp_dev,
159+
real_hum_dev,
160+
)
161+
154162
# Apply dampening filter
155163
dampened_actions, blocked_actions = (
156164
self.action_manager._filterActionsByDampening(
@@ -217,8 +225,8 @@ async def process_actions_with_dampening(self, action_map: List) -> List:
217225
else:
218226
_LOGGER.warning(f"{self.ogb.room}: No actions to execute after conflict resolution")
219227

220-
# Execute actions
221-
await self._execute_actions(final_actions)
228+
# DO NOT execute actions here - let ActionManager apply EnvironmentGuard first
229+
# await self._execute_actions(final_actions)
222230

223231
return final_actions
224232

@@ -346,27 +354,72 @@ def _get_night_humidity_priority_devices(self, vpd_status: str) -> List[str]:
346354
return device_priority.get(vpd_status, ["canVentilate", "canIntake"])
347355

348356
def _apply_night_humidity_priority(self, actions: List, real_temp_dev: float, real_hum_dev: float) -> List:
349-
"""At night, prefer humidity and air-exchange actions to smooth VPD and prevent mold."""
357+
"""At night, apply physikalisch-aware humidity priority to smooth VPD and prevent mold.
358+
359+
Physikalisch-aware Logik (Option C):
360+
- RH hoch (real_hum_dev > 0) + Temp niedrig (real_temp_dev < 0): Entfeuchter reicht (wärmt dabei), Air-Exchange unterdrückt (kühlt)
361+
- RH niedrig (real_hum_dev < 0) + Temp hoch (real_temp_dev > 0): Kühler reicht (erhöht RH), Air-Exchange unterdrückt (trocknet)
362+
- Andere Kombinationen: Erlaube Temp-Kontrolle bei extremer Abweichung (>= 5°C)
363+
"""
350364
if not self._is_night_vpd_hold_active():
351365
return actions
352366

353-
humidity_caps = {"canDehumidify", "canHumidify", "canExhaust", "canVentilate", "canIntake", "canWindow"}
367+
humidity_device_caps = {"canDehumidify", "canHumidify"}
368+
air_exchange_caps = {"canExhaust", "canVentilate", "canIntake", "canWindow"}
354369
temperature_caps = {"canHeat", "canCool", "canClimate"}
355370

356-
# Humidity out of range at night should dominate. Keep temp actions only for strong temp deviations.
371+
# Check if humidity needs correction
357372
if abs(real_hum_dev) > 0:
358-
keep_temperature = abs(real_temp_dev) >= 3.0
373+
# Physikalisch-aware: check opposing directions
374+
rh_high = real_hum_dev > 0
375+
temp_low = real_temp_dev < 0
376+
temp_high = real_temp_dev > 0
377+
rh_low = real_hum_dev < 0
378+
379+
# Determine if temperature actions should be kept
380+
keep_temperature = True
381+
keep_air_exchange = True
382+
suppression_reason = None
383+
air_exchange_reason = None
384+
385+
if rh_high and temp_low:
386+
# RH hoch + Temp niedrig: Entfeuchter wärmt → kein Heizer nötig
387+
# Air-Exchange würde kühlen → unterdrücken
388+
keep_temperature = abs(real_temp_dev) >= 5.0
389+
keep_air_exchange = False
390+
suppression_reason = f"Entfeuchter senkt RH und wärmt (Temp {abs(real_temp_dev):.1f}°C zu niedrig ignoriert)"
391+
air_exchange_reason = f"Air-Exchange würde kühlen (Entfeuchter wärmt bereits)"
392+
elif rh_low and temp_high:
393+
# RH niedrig + Temp hoch: Kühler erhöht RH → kein Kühler nötig
394+
# Air-Exchange würde trocknen → unterdrücken
395+
keep_temperature = abs(real_temp_dev) >= 5.0
396+
keep_air_exchange = False
397+
suppression_reason = f"Kühler senkt Temp und erhöht RH (Temp {abs(real_temp_dev):.1f}°C zu hoch ignoriert)"
398+
air_exchange_reason = f"Air-Exchange würde trocknen (Kühler erhöht bereits RH)"
399+
elif abs(real_temp_dev) < 5.0:
400+
# Moderate Temp-Abweichung ohne physikalischen Konflikt
401+
keep_temperature = False
402+
keep_air_exchange = True
403+
suppression_reason = f"Tempabweichung {abs(real_temp_dev):.1f}°C < 5°C Schwellenwert"
404+
359405
prioritized = []
360406
for action in actions:
361407
cap = getattr(action, "capability", None)
362-
if cap in humidity_caps:
408+
if cap in humidity_device_caps:
363409
prioritized.append(dataclasses.replace(action, priority="high"))
410+
elif cap in air_exchange_caps:
411+
if keep_air_exchange:
412+
prioritized.append(dataclasses.replace(action, priority="high"))
413+
else:
414+
_LOGGER.info(
415+
f"{self.ogb.room}: NightHoldVPD suppressing {cap} - {air_exchange_reason}"
416+
)
364417
elif cap in temperature_caps:
365418
if keep_temperature:
366419
prioritized.append(dataclasses.replace(action, priority="low"))
367420
else:
368-
_LOGGER.debug(
369-
f"{self.ogb.room}: NightHoldVPD suppressing {cap} in favor of humidity-first control"
421+
_LOGGER.info(
422+
f"{self.ogb.room}: NightHoldVPD suppressing {cap} - {suppression_reason}"
370423
)
371424
else:
372425
prioritized.append(action)

custom_components/opengrowbox/OGBController/managers/OGBActionManager.py

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -700,21 +700,60 @@ def _apply_night_humidity_priority_to_actions(self, action_map: List, tent_data:
700700
if abs(real_hum_dev) <= 0:
701701
return action_map
702702

703-
humidity_caps = {"canDehumidify", "canHumidify", "canExhaust", "canVentilate", "canIntake", "canWindow"}
703+
humidity_device_caps = {"canDehumidify", "canHumidify"}
704+
air_exchange_caps = {"canExhaust", "canVentilate", "canIntake", "canWindow"}
704705
temperature_caps = {"canHeat", "canCool", "canClimate"}
705-
keep_temperature = abs(real_temp_dev) >= 3.0
706+
707+
# Physikalisch-aware: check opposing directions
708+
rh_high = real_hum_dev > 0
709+
temp_low = real_temp_dev < 0
710+
temp_high = real_temp_dev > 0
711+
rh_low = real_hum_dev < 0
712+
713+
# Determine if temperature actions should be kept
714+
keep_temperature = True
715+
keep_air_exchange = True
716+
suppression_reason = None
717+
air_exchange_reason = None
718+
719+
if rh_high and temp_low:
720+
# RH hoch + Temp niedrig: Entfeuchter wärmt → kein Heizer nötig
721+
# Air-Exchange würde kühlen → unterdrücken
722+
keep_temperature = abs(real_temp_dev) >= 5.0
723+
keep_air_exchange = False
724+
suppression_reason = f"Entfeuchter senkt RH und wärmt (Temp {abs(real_temp_dev):.1f}°C zu niedrig ignoriert)"
725+
air_exchange_reason = f"Air-Exchange würde kühlen (Entfeuchter wärmt bereits)"
726+
elif rh_low and temp_high:
727+
# RH niedrig + Temp hoch: Kühler erhöht RH → kein Kühler nötig
728+
# Air-Exchange würde trocknen → unterdrücken
729+
keep_temperature = abs(real_temp_dev) >= 5.0
730+
keep_air_exchange = False
731+
suppression_reason = f"Kühler senkt Temp und erhöht RH (Temp {abs(real_temp_dev):.1f}°C zu hoch ignoriert)"
732+
air_exchange_reason = f"Air-Exchange würde trocknen (Kühler erhöht bereits RH)"
733+
elif abs(real_temp_dev) < 5.0:
734+
# Moderate Temp-Abweichung ohne physikalischen Konflikt
735+
keep_temperature = False
736+
keep_air_exchange = True
737+
suppression_reason = f"Tempabweichung {abs(real_temp_dev):.1f}°C < 5°C Schwellenwert"
706738

707739
filtered_actions = []
708740
for action in action_map:
709741
capability = getattr(action, "capability", None)
710-
if capability in humidity_caps:
742+
if capability in humidity_device_caps:
711743
filtered_actions.append(dataclasses.replace(action, priority="high"))
744+
elif capability in air_exchange_caps:
745+
if keep_air_exchange:
746+
filtered_actions.append(dataclasses.replace(action, priority="high"))
747+
else:
748+
_LOGGER.info(
749+
f"{self.room}: NightHoldVPD suppressing {capability} - {air_exchange_reason}"
750+
)
712751
elif capability in temperature_caps:
713752
if keep_temperature:
714753
filtered_actions.append(dataclasses.replace(action, priority="low"))
715754
else:
716-
_LOGGER.debug(
717-
f"{self.room}: NightHoldVPD suppressing {capability} in favor of humidity-first control"
755+
_LOGGER.info(
756+
f"{self.room}: NightHoldVPD suppressing {capability} - {suppression_reason}"
718757
)
719758
else:
720759
filtered_actions.append(action)
@@ -1413,30 +1452,36 @@ async def checkLimitsAndPublicateWithDampening(self, actionMap: List):
14131452

14141453
# Check if device dampening is enabled in control options
14151454
dampening_enabled = self.data_store.getDeep("controlOptions.vpdDeviceDampening", False)
1455+
final_actions = []
1456+
14161457
if self.dampening_actions and dampening_enabled:
1417-
await self.dampening_actions.process_actions_with_dampening(actionMap)
1458+
# Process with full dampening (includes night humidity priority now)
1459+
final_actions = await self.dampening_actions.process_actions_with_dampening(actionMap)
14181460
else:
14191461
# Fallback: execute actions directly without dampening
14201462
_LOGGER.info(f"{self.room}: VPD mode with dampening (dampening disabled) - executing {len(actionMap)} actions directly")
1421-
actionMap = self._apply_night_humidity_priority_to_actions(actionMap)
1463+
final_actions = self._apply_night_humidity_priority_to_actions(actionMap)
14221464

14231465
# Log actions before environment guard
1424-
action_summary = ", ".join([f"{getattr(a, 'capability', 'unknown')}:{getattr(a, 'action', 'unknown')}" for a in actionMap])
1466+
action_summary = ", ".join([f"{getattr(a, 'capability', 'unknown')}:{getattr(a, 'action', 'unknown')}" for a in final_actions])
14251467
await self.event_manager.emit(
14261468
"LogForClient",
14271469
{
14281470
"Name": self.room,
1429-
"message": f"VPD dampening mode: executing {len(actionMap)} actions",
1471+
"message": f"VPD dampening mode: executing {len(final_actions)} actions",
14301472
"actions": action_summary,
1431-
"actionCount": len(actionMap),
1473+
"actionCount": len(final_actions),
14321474
"dampeningEnabled": False,
14331475
},
14341476
haEvent=True,
14351477
debug_type="INFO",
14361478
)
1437-
1438-
final_actions = await self._apply_environment_guard(actionMap)
1439-
await self.publicationActionHandler(final_actions)
1479+
1480+
# Apply EnvironmentGuard (protects against cold/dry air from ambient/outside)
1481+
final_actions = await self._apply_environment_guard(final_actions)
1482+
1483+
# Execute final actions
1484+
await self.publicationActionHandler(final_actions)
14401485

14411486
async def publicationActionHandler(self, actionMap: List):
14421487
"""

0 commit comments

Comments
 (0)