"""One-zone grow-room heat balance; Python 3.10+, standard library only.""" import json import math import sys from pathlib import Path REQUIRED = { "duration_hours": (0.1, 168.0), "time_step_seconds": (1.0, 3600.0), "initial_indoor_temp_c": (-20.0, 60.0), "thermal_capacitance_j_per_k": (1.0, 1.0e12), "envelope_ua_w_per_k": (0.0, 1.0e8), "air_density_kg_per_m3": (0.1, 10.0), "air_heat_capacity_j_per_kg_k": (100.0, 10000.0), "ventilation_m3_per_s": (0.0, 1.0e5), "outside_mean_temp_c": (-50.0, 70.0), "outside_amplitude_c": (0.0, 50.0), "outside_peak_hour": (0.0, 24.0), "led_power_w": (0.0, 1.0e8), "led_heat_fraction": (0.0, 1.0), "led_on_hour": (0.0, 24.0), "led_off_hour": (0.0, 24.0), "other_internal_heat_w": (0.0, 1.0e8), "cooling_capacity_w": (0.0, 1.0e8), "cooling_setpoint_c": (-20.0, 60.0), "cooling_hysteresis_c": (0.0, 20.0), } def finite_number(value, name, low, high): if isinstance(value, bool): raise ValueError(f"{name}: boolean is not a number") try: number = float(value) except (TypeError, ValueError): raise ValueError(f"{name}: missing or nonnumeric") from None if not math.isfinite(number) or not low <= number <= high: raise ValueError(f"{name}: expected [{low}, {high}]") return number def validate(raw): unknown = sorted(set(raw) - set(REQUIRED)) missing = sorted(set(REQUIRED) - set(raw)) if unknown: raise ValueError("unknown keys: " + ", ".join(unknown)) if missing: raise ValueError("missing keys: " + ", ".join(missing)) cfg = {k: finite_number(raw[k], k, *limits) for k, limits in REQUIRED.items()} steps = cfg["duration_hours"] * 3600.0 / cfg["time_step_seconds"] if abs(steps - round(steps)) > 1e-9: raise ValueError("duration must be an integer number of time steps") if cfg["led_off_hour"] <= cfg["led_on_hour"]: raise ValueError("this teaching model requires led_off_hour > led_on_hour") return cfg def outside_temperature(cfg, hour): phase = 2.0 * math.pi * (hour - cfg["outside_peak_hour"]) / 24.0 return cfg["outside_mean_temp_c"] + cfg["outside_amplitude_c"] * math.cos(phase) def led_is_on(cfg, hour): local_hour = hour % 24.0 return cfg["led_on_hour"] <= local_hour < cfg["led_off_hour"] def simulate(raw): cfg = validate(raw) dt = cfg["time_step_seconds"] count = round(cfg["duration_hours"] * 3600.0 / dt) conductance = cfg["envelope_ua_w_per_k"] + ( cfg["air_density_kg_per_m3"] * cfg["air_heat_capacity_j_per_kg_k"] * cfg["ventilation_m3_per_s"] ) indoor = cfg["initial_indoor_temp_c"] cooling_on = False cooling_energy_j = 0.0 led_energy_j = 0.0 balance_residual_j = 0.0 minimum = indoor maximum = indoor records = [] for step in range(count): hour = step * dt / 3600.0 outside = outside_temperature(cfg, hour) if cooling_on and indoor <= cfg["cooling_setpoint_c"] - cfg["cooling_hysteresis_c"]: cooling_on = False elif (not cooling_on) and indoor >= cfg["cooling_setpoint_c"] + cfg["cooling_hysteresis_c"]: cooling_on = True led_heat = cfg["led_power_w"] * cfg["led_heat_fraction"] if led_is_on(cfg, hour) else 0.0 cooling = cfg["cooling_capacity_w"] if cooling_on else 0.0 envelope_and_vent = conductance * (indoor - outside) net_heat = led_heat + cfg["other_internal_heat_w"] - envelope_and_vent - cooling stored_before = cfg["thermal_capacitance_j_per_k"] * indoor indoor_next = indoor + net_heat * dt / cfg["thermal_capacitance_j_per_k"] stored_after = cfg["thermal_capacitance_j_per_k"] * indoor_next balance_residual_j += abs((stored_after - stored_before) - net_heat * dt) cooling_energy_j += cooling * dt led_energy_j += led_heat * dt indoor = indoor_next minimum = min(minimum, indoor) maximum = max(maximum, indoor) if (step + 1) % round(3600.0 / dt) == 0: records.append({ "hour": round((step + 1) * dt / 3600.0, 6), "indoor_temp_c": round(indoor, 6), "outside_temp_c": round(outside, 6), "cooling_on": cooling_on, }) return { "model": "one well-mixed thermal zone; explicit Euler; synthetic parameters", "conductance_envelope_plus_vent_w_per_k": conductance, "minimum_indoor_temp_c": minimum, "maximum_indoor_temp_c": maximum, "final_indoor_temp_c": indoor, "led_heat_kwh": led_energy_j / 3.6e6, "cooling_thermal_energy_kwh": cooling_energy_j / 3.6e6, "absolute_energy_balance_residual_j": balance_residual_j, "hourly": records, } def main(): path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("config.json") try: with path.open(encoding="utf-8") as handle: result = simulate(json.load(handle)) print(json.dumps(result, indent=2, ensure_ascii=False, allow_nan=False)) except (OSError, json.JSONDecodeError, ValueError, KeyError) as error: raise SystemExit("Invalid input: " + str(error)) from None if __name__ == "__main__": main()