# -*- 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()