"""模拟数据生成器 - 为天普园区设备生成真实感的模拟数据 Uses physics-based solar position, Beijing weather models, cloud transients, temperature derating, and realistic building load patterns to produce data that is convincing to industrial park asset owners. """ import asyncio import random import math import logging from datetime import datetime, timezone, timedelta from sqlalchemy import select from app.core.database import async_session from app.models.device import Device from app.models.energy import EnergyData from app.models.alarm import AlarmEvent from app.services.alarm_checker import check_alarms from app.services.weather_model import ( pv_power, pv_electrical, get_pv_orientation, heat_pump_data, building_load, indoor_sensor, heat_meter_data, get_hvac_mode, outdoor_temperature, should_skip_reading, should_go_offline, ) logger = logging.getLogger("simulator") class DataSimulator: def __init__(self): self._task = None self._running = False self._cycle_count = 0 # Track daily energy accumulators per device self._daily_energy: dict[int, float] = {} self._total_energy: dict[int, float] = {} self._last_day: int = -1 # Track offline status per device self._offline_until: dict[int, datetime] = {} # Cache heat pump totals for heat meter correlation self._last_hp_power: float = 0.0 self._last_hp_cop: float = 3.0 async def start(self): self._running = True self._task = asyncio.create_task(self._run_loop()) async def stop(self): self._running = False if self._task: self._task.cancel() async def _run_loop(self): while self._running: try: await self._generate_data() except Exception as e: logger.error(f"Simulator error: {e}", exc_info=True) await asyncio.sleep(15) # 每15秒生成一次 async def _generate_data(self): now = datetime.now(timezone.utc) beijing_dt = now + timedelta(hours=8) self._cycle_count += 1 # Reset daily energy accumulators at midnight Beijing time current_day = beijing_dt.timetuple().tm_yday if current_day != self._last_day: self._daily_energy.clear() self._last_day = current_day async with async_session() as session: result = await session.execute(select(Device).where(Device.is_active == True)) devices = result.scalars().all() data_points = [] hp_total_power = 0.0 hp_cop_sum = 0.0 hp_count = 0 # First pass: generate heat pump data (needed for heat meter correlation) hp_results: dict[int, dict] = {} for device in devices: if device.device_type == "heat_pump": hp_data = self._gen_heatpump_data(device, now) hp_results[device.id] = hp_data if hp_data: hp_total_power += hp_data.get("_power", 0) cop = hp_data.get("_cop", 0) if cop > 0: hp_cop_sum += cop hp_count += 1 self._last_hp_power = hp_total_power self._last_hp_cop = hp_cop_sum / hp_count if hp_count > 0 else 3.0 for device in devices: # Simulate communication glitch: skip a reading ~1% of cycles if should_skip_reading(self._cycle_count): continue # Simulate brief device offline events if device.id in self._offline_until: if now < self._offline_until[device.id]: device.status = "offline" continue else: del self._offline_until[device.id] if should_go_offline(): self._offline_until[device.id] = now + timedelta(seconds=random.randint(15, 30)) device.status = "offline" continue points = self._generate_device_data(device, now, hp_results) data_points.extend(points) device.status = "online" device.last_data_time = now if data_points: session.add_all(data_points) await session.flush() # Run alarm checker after data generation try: await check_alarms(session) except Exception as e: logger.error(f"Alarm checker error: {e}", exc_info=True) await session.commit() def _should_trigger_anomaly(self, anomaly_type: str) -> bool: """Determine if we should inject an anomalous value for demo purposes. Preserves the existing alarm demo trigger pattern: - PV low power: every ~10 min (40 cycles), lasts ~2 min (8 cycles) - Heat pump low COP: every ~20 min (80 cycles), lasts ~2 min - Sensor out of range: every ~30 min (120 cycles), lasts ~2 min """ c = self._cycle_count if anomaly_type == "pv_low_power": return (c % 40) < 8 elif anomaly_type == "hp_low_cop": return (c % 80) < 8 elif anomaly_type == "sensor_out_of_range": return (c % 120) < 8 return False def _generate_device_data(self, device: Device, now: datetime, hp_results: dict) -> list[EnergyData]: points = [] if device.device_type == "pv_inverter": points = self._gen_pv_data(device, now) elif device.device_type == "heat_pump": hp_data = hp_results.get(device.id) if hp_data: points = hp_data.get("_points", []) elif device.device_type == "meter": points = self._gen_meter_data(device, now) elif device.device_type == "sensor": points = self._gen_sensor_data(device, now) elif device.device_type == "heat_meter": points = self._gen_heat_meter_data(device, now) return points def _gen_pv_data(self, device: Device, now: datetime) -> list[EnergyData]: """光伏逆变器数据 - 基于太阳位置、云层、温度降额模型""" rated = device.rated_power or 110.0 orientation = get_pv_orientation(device.code) power = pv_power(now, rated_power=rated, orientation=orientation, device_code=device.code) # Demo anomaly: cloud cover drops INV-01 power very low for alarm testing if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01": power = random.uniform(1.0, 3.0) elec = pv_electrical(power, rated_power=rated) # Demo anomaly: over-temperature for alarm testing if self._should_trigger_anomaly("pv_low_power") and device.code == "INV-01": elec["temperature"] = round(random.uniform(67, 72), 1) # Accumulate daily energy (power * 15s interval) interval_hours = 15.0 / 3600.0 energy_increment = power * interval_hours self._daily_energy[device.id] = self._daily_energy.get(device.id, 0) + energy_increment # Total energy: start from a reasonable base if device.id not in self._total_energy: self._total_energy[device.id] = 170000 + random.uniform(0, 5000) self._total_energy[device.id] += energy_increment return [ EnergyData(device_id=device.id, timestamp=now, data_type="power", value=round(power, 2), unit="kW"), EnergyData(device_id=device.id, timestamp=now, data_type="daily_energy", value=round(self._daily_energy[device.id], 2), unit="kWh"), EnergyData(device_id=device.id, timestamp=now, data_type="total_energy", value=round(self._total_energy[device.id], 1), unit="kWh"), EnergyData(device_id=device.id, timestamp=now, data_type="dc_voltage", value=elec["dc_voltage"], unit="V"), EnergyData(device_id=device.id, timestamp=now, data_type="ac_voltage", value=elec["ac_voltage"], unit="V"), EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=elec["temperature"], unit="℃"), ] def _gen_heatpump_data(self, device: Device, now: datetime) -> dict: """热泵机组数据 - 基于室外温度和COP模型""" rated = device.rated_power or 35.0 data = heat_pump_data(now, rated_power=rated, device_code=device.code) cop = data["cop"] power = data["power"] # Demo anomaly: low COP for HP-01 if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-01": cop = random.uniform(1.2, 1.8) # Demo anomaly: overload for HP-02 if self._should_trigger_anomaly("hp_low_cop") and device.code == "HP-02": power = random.uniform(39, 42) points = [ EnergyData(device_id=device.id, timestamp=now, data_type="power", value=round(power, 2), unit="kW"), EnergyData(device_id=device.id, timestamp=now, data_type="cop", value=round(cop, 2), unit=""), EnergyData(device_id=device.id, timestamp=now, data_type="inlet_temp", value=data["inlet_temp"], unit="℃"), EnergyData(device_id=device.id, timestamp=now, data_type="outlet_temp", value=data["outlet_temp"], unit="℃"), EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", value=data["flow_rate"], unit="m³/h"), EnergyData(device_id=device.id, timestamp=now, data_type="outdoor_temp", value=data["outdoor_temp"], unit="℃"), ] return { "_points": points, "_power": power, "_cop": cop, } def _gen_meter_data(self, device: Device, now: datetime) -> list[EnergyData]: """电表数据 - 基于建筑负荷模型(工作日/周末、午餐低谷、HVAC季节贡献)""" data = building_load(now, base_power=50.0, meter_code=device.code) return [ EnergyData(device_id=device.id, timestamp=now, data_type="power", value=data["power"], unit="kW"), EnergyData(device_id=device.id, timestamp=now, data_type="voltage", value=data["voltage"], unit="V"), EnergyData(device_id=device.id, timestamp=now, data_type="current", value=data["current"], unit="A"), EnergyData(device_id=device.id, timestamp=now, data_type="power_factor", value=data["power_factor"], unit=""), ] def _gen_sensor_data(self, device: Device, now: datetime) -> list[EnergyData]: """温湿度传感器数据 - 室内HVAC控制 / 室外天气模型""" is_outdoor = False if device.metadata_: is_outdoor = device.metadata_.get("type") == "outdoor" data = indoor_sensor(now, is_outdoor=is_outdoor, device_code=device.code) temp = data["temperature"] # Demo anomaly: sensor out of range for alarm testing if self._should_trigger_anomaly("sensor_out_of_range") and device.code == "TH-01": temp = random.uniform(31, 34) return [ EnergyData(device_id=device.id, timestamp=now, data_type="temperature", value=round(temp, 1), unit="℃"), EnergyData(device_id=device.id, timestamp=now, data_type="humidity", value=data["humidity"], unit="%"), ] def _gen_heat_meter_data(self, device: Device, now: datetime) -> list[EnergyData]: """热量表数据 - 与热泵运行功率和COP相关联""" data = heat_meter_data(now, hp_power=self._last_hp_power, hp_cop=self._last_hp_cop) return [ EnergyData(device_id=device.id, timestamp=now, data_type="heat_power", value=data["heat_power"], unit="kW"), EnergyData(device_id=device.id, timestamp=now, data_type="flow_rate", value=data["flow_rate"], unit="m³/h"), EnergyData(device_id=device.id, timestamp=now, data_type="supply_temp", value=data["supply_temp"], unit="℃"), EnergyData(device_id=device.id, timestamp=now, data_type="return_temp", value=data["return_temp"], unit="℃"), ]