ebuild_rasp2/ebuild/core/systems/burner.py~

679 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
from __future__ import annotations
__title__ = "burner"
__author__ = 'Mehmet Karatay & "Saraswati" (ChatGPT)'
__purpose__ = "Bina ve/veya dış ısıya göre brülör ve sirkülasyon kontrol çekirdeği"
__version__ = "0.4.3"
__date__ = "2025-11-22"
"""
ebuild/core/systems/burner.py
Revision : 2025-11-22
Authors : Mehmet Karatay & "Saraswati" (ChatGPT)
Amaç
-----
- BUILD_BURNER moduna göre (F/B) brülör ve sirkülasyon pompalarını yönetmek
- Bina ortalaması (B mod) veya dış ısı (F mod) üzerinden ısıtma isteği üretmek
- used_out_heat mantığı ile dış ısıya hafta sonu / konfor offset uygulamak
Bağımlılıklar
--------------
- building.Building
- environment.BuildingEnvironment
- season.SeasonController
- io.relay_driver.RelayDriver
- io.dbtext.DBText
- io.legacy_syslog (syslog/console çıktıları için)
- config_statics (cfg_s)
- config_runtime (cfg_v)
Notlar
------
- Brülör, igniter ve pompalar relay_driver içinde isimlendirilmiş kanallarla
temsil edilir.
- Bu dosya, eski sistemle uyum için mümkün olduğunca log formatını korumaya
çalışır.
"""
import datetime
import time as _time
from dataclasses import dataclass
from typing import Optional, Dict, Any, List, Tuple
from ..building import Building
from ..season import SeasonController
from ..environment import BuildingEnvironment
from ...io.relay_driver import RelayDriver
from ...io.dbtext import DBText
from ...io import legacy_syslog as lsys
from ... import config_statics as cfg_s
from ... import config_runtime as cfg_v
# -------------------------------------------------------------
# Yardımcı: DS18B20 okuma (hat sensörleri için)
# -------------------------------------------------------------
@dataclass
class BurnerState:
burner_on: bool
pumps_on: Tuple[str, ...]
fire_setpoint_c: float
last_change_ts: datetime.datetime
reason: str
last_building_avg: Optional[float]
last_outside_c: Optional[float]
last_used_out_c: Optional[float]
last_mode: str
# ----------------------------- Isı eğrisi --------------------
# Dış ısı → kazan çıkış setpoint haritası
# Örnek bir eğri; config_runtime ile override edilebilir.
BURNER_FIRE_SETPOINT_MAP: Dict[float, Dict[str, float]] = getattr(
cfg_v,
"BURNER_FIRE_SETPOINT_MAP",
{
-10.0: {"fire": 50.0},
-5.0: {"fire": 48.0},
0.0: {"fire": 46.0},
5.0: {"fire": 44.0},
10.0: {"fire": 40.0},
15.0: {"fire": 35.0},
20.0: {"fire": 30.0},
25.0: {"fire": 26.0},
},
)
class BurnerConfig:
"""
Brülör çalışma parametreleri (runtime config'ten override edilebilir).
"""
min_run_sec: int = 60 # brülör en az bu kadar saniye çalışsın
min_stop_sec: int = 60 # brülör en az bu kadar saniye duruşta kalsın
hysteresis_c: float = 0.5 # bina ortalaması için histerezis
# ---------------------------------------------------------
# Yardımcı fonksiyon: bina istatistikleri
# ---------------------------------------------------------
def _safe_float(value: Any, default: Optional[float] = None) -> Optional[float]:
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _merge_stats(old: Optional[Dict[str, Any]], new: Dict[str, Any]) -> Dict[str, Any]:
"""
Bina istatistiği için min/avg/max birleştirme.
"""
if old is None:
return dict(new)
def _pick(key: str, func):
a = old.get(key)
b = new.get(key)
if a is None:
return b
if b is None:
return a
return func(a, b)
return {
"min": _pick("min", min),
"avg": new.get("avg"),
"max": _pick("max", max),
}
# ---------------------------------------------------------
# used_out_heat hesabı
# ---------------------------------------------------------
def _apply_weekend_and_comfort(
used_out: Optional[float],
now: datetime.datetime,
weekend_boost_c: float,
comfort_offset_c: float,
) -> Optional[float]:
"""
Haftasonu ve konfor offset'ini used_out üzerine uygular.
"""
if used_out is None:
return None
result = float(used_out)
# Haftasonu boost: Cumartesi / Pazar
if now.weekday() >= 5 and weekend_boost_c != 0.0:
result -= weekend_boost_c
# Konfor offset'i
if comfort_offset_c != 0.0:
result -= comfort_offset_c
return result
def pick_fire_setpoint(outside_c: Optional[float]) -> float:
"""
Dış ısı (used_out_heat) için en yakın fire setpoint'i döndürür.
Eğer outside_c None ise, MAX_OUTLET_C kullanılır.
"""
if outside_c is None:
return float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
keys = sorted(BURNER_FIRE_SETPOINT_MAP.keys())
nearest_key = min(keys, key=lambda k: abs(k - outside_c))
mapping = BURNER_FIRE_SETPOINT_MAP.get(nearest_key, {})
return float(mapping.get("fire", getattr(cfg_v, "MAX_OUTLET_C", 45.0)))
# ---------------------------------------------------------
# Ana sınıf: BurnerController
# ---------------------------------------------------------
class BurnerController:
"""
F/B moduna göre brülör kontrolü yapan sınıf.
BUILD_BURNER = "B"
→ bina ortalama sıcaklığına göre kontrol
BUILD_BURNER = "F"
→ dış ısıya göre (OUTSIDE_LIMIT_HEAT_C) karar veren mod
(burada dış ısı olarak *used_out_heat* kullanılır).
"""
def __init__(
self,
building: Building,
relay_driver: RelayDriver,
logger: Optional[DBText] = None,
config: Optional[BurnerConfig] = None,
burner_id: Optional[int] = None,
environment: Optional[BuildingEnvironment] = None,
) -> None:
self.building = building
self.relays = relay_driver
# Runtime konfig: varsayılan BurnerConfig + config_runtime override
self.cfg = config or BurnerConfig()
try:
self.cfg.min_run_sec = int(getattr(cfg_v, "BURNER_MIN_RUN_SEC", self.cfg.min_run_sec))
self.cfg.min_stop_sec = int(getattr(cfg_v, "BURNER_MIN_STOP_SEC", self.cfg.min_stop_sec))
self.cfg.hysteresis_c = float(getattr(cfg_v, "BURNER_HYSTERESIS_C", self.cfg.hysteresis_c))
except Exception as e:
print("BurnerConfig override error:", e)
# Hangi brülör? → config_statics.BURNER_DEFAULT_ID veya parametre
default_id = int(getattr(cfg_s, "BURNER_DEFAULT_ID", 0))
self.burner_id = int(burner_id) if burner_id is not None else default_id
# DBText logger
log_file = getattr(cfg_s, "BURNER_LOG_FILE", "ebuild_burner_log.sql")
log_table = getattr(cfg_s, "BURNER_LOG_TABLE", "eburner_log")
self.logger = logger or DBText(
filename=log_file,
table=log_table,
app="EBURNER",
)
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
# Röle kanal isimleri (eski yapı ile uyum için fallback)
self.igniter_ch: str = getattr(cfg_s, "BURNER_IGNITER_CH", "igniter")
self.pump_channels: List[str] = list(
getattr(cfg_s, "BURNER_PUMPS", ["circulation_a", "circulation_b"])
)
self.default_pumps: List[str] = list(
getattr(cfg_s, "BURNER_DEFAULT_PUMPS", ["circulation_a"])
)
# Bina okuma periyodu (BUILDING_READ_PERIOD_S)
self._building_last_read_ts: Optional[datetime.datetime] = None
self._building_read_period: float = float(
getattr(cfg_v, "BUILDING_READ_PERIOD_S", 60.0)
)
self._building_last_stats: Optional[Dict[str, Any]] = None
# used_out_heat için parametreler
self.used_out_c: Optional[float] = None
self._last_used_update_ts: Optional[datetime.datetime] = None
self.outside_smooth_sec: float = float(
getattr(cfg_v, "OUTSIDE_SMOOTH_SECONDS", 900.0)
)
self.weekend_boost_c: float = float(
getattr(cfg_v, "WEEKEND_HEAT_BOOST_C", 0.0)
)
self.comfort_offset_c: float = float(
getattr(cfg_v, "BURNER_COMFORT_OFFSET_C", 0.0)
)
# Ortam nesnesi (opsiyonel)
self.environment = environment
# Ortamdan başlangıç dış ısı alınabiliyorsa used_out'u hemen doldur
if self.environment is not None:
try:
first_out = self.environment.get_outside_temp_cached()
except Exception:
first_out = None
if first_out is not None:
self.used_out_c = first_out
self._last_used_update_ts = datetime.datetime.now()
# Çalışma modu
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
initial_mode = cfg_mode if cfg_mode in ("F", "B") else "F"
# Başlangıç state
self.state = BurnerState(
burner_on=False,
pumps_on=tuple(),
fire_setpoint_c=max_out,
last_change_ts=datetime.datetime.now(),
reason="init",
last_building_avg=None,
last_outside_c=None,
last_used_out_c=None,
last_mode=initial_mode,
)
# Mevsim / güneş bilgisi (syslog üst block için)
try:
self.season = SeasonController.from_now()
except Exception as e:
print("SeasonController.from_now() hata:", e)
self.season = None
# ---------------------------------------------------------
# Bina istatistikleri
# ---------------------------------------------------------
def _get_building_stats(self, now: datetime.datetime) -> Optional[Dict[str, Any]]:
"""
Bina ortalaması / min / max gibi istatistikleri periyodik olarak okur.
BUILDING_READ_PERIOD_S içinde cache kullanır.
"""
if self._building_last_read_ts is None:
need_read = True
else:
delta = (now - self._building_last_read_ts).total_seconds()
need_read = delta >= self._building_read_period
if not need_read:
return self._building_last_stats
try:
stats = self.building.get_stats()
except Exception as e:
print("Building.get_stats() hata:", e)
return self._building_last_stats
self._building_last_read_ts = now
self._building_last_stats = stats
return stats
# ---------------------------------------------------------
# used_out_heat güncelleme
# ---------------------------------------------------------
def _update_used_out(self, now: datetime.datetime, outside_c: Optional[float]) -> Optional[float]:
"""
Dış ısı okumasına göre used_out_heat günceller.
- OUTSIDE_SMOOTH_SECONDS süresince eksponansiyel smoothing
- Haftasonu ve konfor offset'i eklenir.
"""
raw = outside_c
if raw is None:
return self.used_out_c
# Smooth
if self.used_out_c is None or self._last_used_update_ts is None:
smoothed = raw
else:
dt = (now - self._last_used_update_ts).total_seconds()
if dt <= 0:
smoothed = self.used_out_c
else:
tau = max(1.0, self.outside_smooth_sec)
alpha = min(1.0, dt / tau)
smoothed = (1.0 - alpha) * self.used_out_c + alpha * raw
self.used_out_c = smoothed
self._last_used_update_ts = now
# Haftasonu / konfor offset'i uygula
final_used = _apply_weekend_and_comfort(
smoothed,
now,
self.weekend_boost_c,
self.comfort_offset_c,
)
return final_used
# ---------------------------------------------------------
# Isı ihtiyacı kararları
# ---------------------------------------------------------
def _should_heat_by_outside(self, used_out: Optional[float]) -> bool:
"""
F modunda (dış ısıya göre) ısıtma isteği.
"""
limit = float(getattr(cfg_v, "OUTSIDE_HEAT_LIMIT_C", 17.0))
if used_out is None:
return False
want = used_out < limit
print(f"should_heat_by_outside: used={used_out:.3f}C limit={limit:.1f}C")
return want
def _should_heat_by_building(self, building_avg: Optional[float], now: datetime.datetime) -> bool:
"""
B modunda bina ortalaması + konfor setpoint'e göre ısıtma isteği.
"""
comfort = float(getattr(cfg_v, "COMFORT_SETPOINT_C", 23.0))
h = self.cfg.hysteresis_c
if building_avg is None:
return False
if building_avg < (comfort - h):
return True
if building_avg > (comfort + h):
return False
# Histerezis bandında önceki state'i koru
return self.state.burner_on
# ---------------------------------------------------------
# Min çalışma / durma süreleri
# ---------------------------------------------------------
def _respect_min_times(self, now: datetime.datetime, want_on: bool) -> bool:
"""
min_run_sec / min_stop_sec kurallarını uygular.
- İlk açılışta (state.reason == 'init') kısıtlama uygulanmaz.
"""
# İlk tick: min_run/min_stop uygulama
try:
if getattr(self.state, "reason", "") == "init":
return want_on
except Exception:
pass
elapsed = (now - self.state.last_change_ts).total_seconds()
if self.state.burner_on:
# Çalışırken min_run dolmadan kapatma
if not want_on and elapsed < self.cfg.min_run_sec:
return True
else:
# Kapalıyken min_stop dolmadan açma
if want_on and elapsed < self.cfg.min_stop_sec:
return False
return want_on
# ---------------------------------------------------------
# Çıkışları rölelere uygulama
# ---------------------------------------------------------
def _apply_outputs(
self,
now: datetime.datetime,
mode: str,
burner_on: bool,
pumps_on: Tuple[str, ...],
fire_setpoint_c: float,
reason: str,
) -> None:
"""
Röleleri sürer, state'i günceller, log ve syslog üretir.
"""
# 1) Röle sürücüsü (igniter + pompalar)
try:
# Yeni API: RelayDriver brülör-aware ise
if hasattr(self.relays, "set_igniter"):
# Brülör ateşleme
self.relays.set_igniter(self.burner_id, burner_on)
# Pompalar: her zaman kanal isimleri üzerinden sür
if hasattr(self.relays, "all_pumps"):
all_pumps = list(self.relays.all_pumps(self.burner_id)) # ['circulation_a', ...]
for ch in all_pumps:
self.relays.set_channel(ch, (ch in pumps_on))
else:
# all_pumps yoksa, config_statics'ten gelen pump_channels ile sür
for ch in self.pump_channels:
self.relays.set_channel(ch, (ch in pumps_on))
else:
# Eski/çok basit API: doğrudan kanal adları
self.relays.set_channel(self.igniter_ch, burner_on)
for ch in self.pump_channels:
self.relays.set_channel(ch, (ch in pumps_on))
except Exception as exc:
# legacy_syslog.log_error YOK, bu yüzden ya loga yaz ya da print et
try:
msg = f"[relay_error] igniter_ch={self.igniter_ch} burner_on={burner_on} pumps_on={pumps_on} exc={exc}"
lsys.send_legacy_syslog(lsys.format_line(98, msg))
except Exception:
print("Relay error in _apply_outputs:", exc)
# 2) State güncelle
if burner_on != self.state.burner_on or tuple(pumps_on) != self.state.pumps_on:
self.state.last_change_ts = now
self.state.burner_on = burner_on
self.state.pumps_on = tuple(pumps_on)
self.state.fire_setpoint_c = fire_setpoint_c
self.state.reason = reason
self.state.last_mode = mode
# 3) DBText logger'a yaz
try:
self.logger.insert(
{
"ts": now,
"mode": mode,
"burner_on": int(burner_on),
"pumps": ",".join(pumps_on),
"fire_sp": fire_setpoint_c,
"reason": reason,
"bavg": _safe_float(self.state.last_building_avg),
"out": _safe_float(self.state.last_outside_c),
"used": _safe_float(self.state.last_used_out_c),
}
)
except Exception:
pass
# 4) Syslog / console üst blok
try:
lsys.log_burner_header(
now=now,
mode=mode,
season=self.season,
building_avg=self.state.last_building_avg,
outside_c=self.state.last_outside_c,
used_out_c=self.state.last_used_out_c,
fire_sp=fire_setpoint_c,
burner_on=burner_on,
pumps_on=pumps_on,
)
except Exception as exc:
# Burayı tamamen sessize almayalım, hatayı konsola basalım
print("BRULOR lsys.log_burner_header error:", exc, "burner.py _apply_outputs()")
# ---------------------------------------------------------
# Ana tick fonksiyonu
# ---------------------------------------------------------
def tick(self, outside_c: Optional[float] = None) -> BurnerState:
"""
Tek bir kontrol adımı.
- Bina istatistiği BUILDING_READ_PERIOD_S periyodunda bir kez okunur,
aradaki tick'lerde cache kullanılır.
- F modunda kararlar *used_out_heat* üzerinden verilir.
"""
now = datetime.datetime.now()
cfg_mode = str(getattr(cfg_s, "BUILD_BURNER", "F")).upper()
mode = cfg_mode
print("tick outside_c:", outside_c)
# 0) dış ısı: parametre yoksa ortamdan al
if outside_c is None and getattr(self, "environment", None) is not None:
try:
outside_c = self.environment.get_outside_temp_cached()
except Exception:
outside_c = None
print("env:", getattr(self, "environment", None))
print("tick outside_c 2:", outside_c)
# 1) bina istatistiği (periyodik)
stats = self._get_building_stats(now)
building_avg = stats.get("avg") if stats else None
# 2) used_out_heat güncelle
used_out = self._update_used_out(now, outside_c)
self.state.last_building_avg = building_avg
self.state.last_outside_c = outside_c
self.state.last_used_out_c = used_out
# 3) ısıtma ihtiyacı
if mode == "F":
want_heat = self._should_heat_by_outside(used_out)
else:
mode = "B" # saçma değer gelirse B moduna zorla
want_heat = self._should_heat_by_building(building_avg, now)
want_heat = self._respect_min_times(now, want_heat)
# 4) fire setpoint F modunda da used_out üzerinden okunur
fire_sp = pick_fire_setpoint(used_out)
max_out = float(getattr(cfg_v, "MAX_OUTLET_C", 45.0))
fire_sp = min(fire_sp, max_out)
# 5) pompalar
if want_heat:
if hasattr(self.relays, "enabled_pumps"):
try:
pumps_list = list(self.relays.enabled_pumps(self.burner_id))
pumps = tuple(pumps_list)
except Exception:
pumps = tuple(self.default_pumps)
else:
pumps = tuple(self.default_pumps)
else:
pumps = tuple()
reason = (
f"avg={building_avg}C "
f"outside_raw={outside_c}C "
f"used={used_out}C "
f"want_heat={want_heat}"
)
print("tick reason", reason)
# 7) Rölelere uygula
self._apply_outputs(
now=now,
mode=mode,
burner_on=bool(want_heat),
pumps_on=pumps,
fire_setpoint_c=fire_sp,
reason=reason,
)
print("state", self.state)
return self.state
# -------------------------------------------------------------
# CLI / demo
# -------------------------------------------------------------
def _demo() -> None:
"""
Basit demo: Building + RelayDriver + BuildingEnvironment ile
BurnerController'ı ayağa kaldır, tick() döngüsü yap.
"""
# 1) Bina
try:
building = Building()
print("✅ Building: statics yüklendi\n")
print(building.pretty_summary())
except Exception as e:
print("❌ Building oluşturulamadı:", e)
raise SystemExit(1)
# 2) Ortam (dış ısı, ADC vs.)
try:
env = BuildingEnvironment()
except Exception as e:
print("⚠️ BuildingEnvironment oluşturulamadı:", e)
env = None
# 3) Röle sürücüsü
rel = RelayDriver(onoff=False)
# 4) Denetleyici
ctrl = BurnerController(building, rel, environment=env)
print("🔥 BurnerController başlatıldı")
print(f" Burner ID : {ctrl.burner_id}")
print(f" Çalışma modu (BUILD_BURNER): {getattr(cfg_s, 'BUILD_BURNER', 'F')} (F=dış ısı, B=bina ort)")
print(f" Igniter kanalı : {ctrl.igniter_ch}")
print(f" Pompa kanalları : {ctrl.pump_channels}")
print(f" Varsayılan pompalar : {ctrl.default_pumps}")
print(f" Konfor setpoint (°C) : {getattr(cfg_v, 'COMFORT_SETPOINT_C', 23.0)}")
print(f" Histerezis (°C) : {ctrl.cfg.hysteresis_c}")
print(f" Dış ısı limiti (°C) : {getattr(cfg_v, 'OUTSIDE_HEAT_LIMIT_C', 17.0)}")
print(f" Max kazan çıkış (°C) : {getattr(cfg_v, 'MAX_OUTLET_C', 45.0)}")
print(f" Bina okuma periyodu (s) : {ctrl._building_read_period}")
print(f" OUTSIDE_SMOOTH_SECONDS : {ctrl.outside_smooth_sec}")
print(f" WEEKEND_HEAT_BOOST_C : {ctrl.weekend_boost_c}")
print(f" BURNER_COMFORT_OFFSET_C : {ctrl.comfort_offset_c}")
print("----------------------------------------------------")
print("BurnerController demo (Ctrl+C ile çık)…")
try:
while True:
ctrl.tick()
_time.sleep(5)
except KeyboardInterrupt:
print("\nCtrl+C alındı, çıkış hazırlanıyor…")
finally:
try:
rel.all_off()
print("🔌 Tüm röleler kapatıldı.")
except Exception as e:
print(f"⚠️ Röleleri kapatırken hata: {e}")
finally:
try:
rel.cleanup()
except Exception:
pass
if __name__ == "__main__":
_demo()