"""Seed 10-minute IoT panel readings (144/day) for every flock. Kandang 2 Lantai 1 is seeded from real sensor snapshots pulled from ``dashboard.cpsp.id/api/iot/flocks/`` (cached in ``scripts/iot_panel_10min_extract.json``); every other flock/floor is generated from that data with a deterministic per-house jitter so each cycle day holds a full 00:00..23:50 run of 144 readings. Run ``scripts/extract_iot_10min.py`` to refresh the cache against the live API. """ from __future__ import annotations import json import random from datetime import date as date_cls from datetime import datetime, time, timedelta from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils import timezone from apps.farms.models import Cycle, Flock SLOTS_PER_DAY = 144 # 24h * 6 CHILL_FACTOR_POINTS = ( (0, 8.0), (1, 8.0), (7, 7.0), (14, 6.0), (21, 4.5), (28, 3.5), (35, 3.5), (42, 3.0), ) def chicken_chill_factor(age: float) -> float: if age <= CHILL_FACTOR_POINTS[0][0]: return CHILL_FACTOR_POINTS[0][1] for idx in range(1, len(CHILL_FACTOR_POINTS)): prev_age, prev_factor = CHILL_FACTOR_POINTS[idx - 1] next_age, next_factor = CHILL_FACTOR_POINTS[idx] if age > next_age: continue span = next_age - prev_age if span <= 0: return next_factor progress = (age - prev_age) / span return prev_factor + (next_factor - prev_factor) * progress return CHILL_FACTOR_POINTS[-1][1] def experience_temperature( *, average_temperature: float, wind_speed: float, humidity_pct: float, age_days: float ) -> float: chill_factor = chicken_chill_factor(age_days) rh_adjustment = (humidity_pct - 70.0) / 5.0 chill_effect = wind_speed * chill_factor - rh_adjustment return average_temperature - chill_effect def lerp(a: float, b: float, frac: float) -> float: return a + (b - a) * frac def slot_timestamp(day: date_cls, slot: int) -> datetime: naive = datetime.combine(day, time(0, 0)) + timedelta(minutes=10 * slot) return timezone.make_aware(naive, timezone.get_current_timezone()) def gap_fill_day(day_rows: dict[int, dict]) -> dict[int, dict]: """Return 144 slots for a day, interpolating any missing 10-minute windows.""" present = sorted(day_rows.items()) filled: dict[int, dict] = {} for slot in range(SLOTS_PER_DAY): if slot in day_rows: filled[slot] = dict(day_rows[slot]) continue prev = next = None for s, row in present: if s < slot: prev = (s, row) elif s > slot and next is None: next = (s, row) break if prev is None and next is not None: filled[slot] = dict(next[1]) elif next is None and prev is not None: filled[slot] = dict(prev[1]) elif prev is not None and next is not None: ps, prow = prev ns, nrow = next frac = (slot - ps) / (ns - ps) filled[slot] = { "wind_speed": lerp(prow["wind_speed"], nrow["wind_speed"], frac), "humidity": lerp(prow["humidity"], nrow["humidity"], frac), "water_total": lerp(prow["water_total"], nrow["water_total"], frac), "average_temperature": lerp( prow["average_temperature"], nrow["average_temperature"], frac ), } return filled class Command(BaseCommand): help = "Seed 10-minute IoT panel readings (144/day) for all flocks" def add_arguments(self, parser): parser.add_argument("--iot-json", default="") parser.add_argument("--cycle-start", default="2026-05-22") parser.add_argument("--no-reset", action="store_true") parser.add_argument("--jitter-seed", type=int, default=7) def load_real_rows(self, path: Path) -> dict[str, dict[int, dict]]: if not path.is_file(): raise CommandError(f"IoT 10-min extract not found: {path}") data = json.loads(path.read_text(encoding="utf-8")) by_date: dict[str, dict[int, dict]] = {} for row in data["rows"]: ts = datetime.fromisoformat(row["timestamp"]) slot = (ts.hour * 60 + ts.minute) // 10 by_date.setdefault(row["date"], {})[slot] = row return by_date @transaction.atomic def handle(self, *args, **options): iot_json = options["iot_json"] or str( Path(settings.BASE_DIR).resolve().parent / "scripts" / "iot_panel_10min_extract.json" ) cycle_start = date_cls.fromisoformat(options["cycle_start"]) real_by_date = self.load_real_rows(Path(iot_json)) cycles = Cycle.objects.filter( start_date=cycle_start, kandang__flocks__isnull=False ).distinct() if not cycles: raise CommandError(f"No cycles starting {cycle_start} with flocks") seeded = 0 for cycle in cycles: kandang_name = cycle.kandang.kandang_name for flock in cycle.kandang.flocks.order_by("flock_id"): profile = self.profile_for(kandang_name, flock.flock_name) rng = random.Random(options["jitter_seed"] + flock.pk) rows = self.build_flock_rows(flock, cycle, real_by_date, profile, rng) if not options["no_reset"]: flock.iot_panels.all().delete() for chunk in range(0, len(rows), 500): flock.iot_panels.bulk_create(rows[chunk : chunk + 500]) seeded += len(rows) self.stdout.write( f" {kandang_name} / {flock.flock_name}: {len(rows)} readings" ) self.stdout.write(self.style.SUCCESS(f"Seeded {seeded} IoT panel readings")) def profile_for(self, kandang_name: str, flock_name: str) -> dict: """Per-house transform applied to the real Kandang 2 Lantai 1 readings.""" is_lantai1 = flock_name == "Lantai 1" if kandang_name == "Kandang 2": if is_lantai1: return {"real": True} return { "wind_delta": -0.05, "humidity_delta": 1.2, "temperature_delta": -0.3, "water_factor": 0.96, } # Kandang 1: deterministic jitter (same approach as seed_sukawarna_kandang4). return { "temperature_jitter": (-0.4, 0.4), "humidity_jitter": (-3.0, 3.0), "wind_jitter": (-0.1, 0.1), "water_factor": 0.9, "water_jitter_abs": (-120.0, 120.0), "humidity_delta": 1.2 if not is_lantai1 else 0.0, "temperature_delta": -0.3 if not is_lantai1 else 0.0, } def build_flock_rows(self, flock: Flock, cycle: Cycle, real: dict, profile: dict, rng: random.Random) -> list: from apps.operations.models import IotPanel day = cycle.start_date rows: list[IotPanel] = [] while day <= cycle.end_date: iso = day.isoformat() age_days = (day - cycle.start_date).days + 1 if profile.get("real"): source = gap_fill_day(real.get(iso, {})) else: source = gap_fill_day(real.get(iso, {})) if not source: day += timedelta(days=1) continue real_total = source.get(SLOTS_PER_DAY - 1, {}).get("water_total", 0.0) or 0.0 target_total = real_total * profile.get("water_factor", 1.0) if profile.get("water_jitter_abs"): target_total += rng.uniform(*profile["water_jitter_abs"]) target_total = max(0.0, target_total) water_scale = target_total / real_total if real_total else 0.0 for slot in range(SLOTS_PER_DAY): base = source[slot] avg_temp = base.get("average_temperature", 0.0) + profile.get("temperature_delta", 0.0) if profile.get("temperature_jitter"): avg_temp += rng.uniform(*profile["temperature_jitter"]) humidity = base.get("humidity", 0.0) + profile.get("humidity_delta", 0.0) if profile.get("humidity_jitter"): humidity += rng.uniform(*profile["humidity_jitter"]) humidity = min(100.0, max(0.0, humidity)) wind = base.get("wind_speed", 0.0) + profile.get("wind_delta", 0.0) if profile.get("wind_jitter"): wind += rng.uniform(*profile["wind_jitter"]) wind = max(0.0, wind) water = base.get("water_total", 0.0) * water_scale exp_temp = experience_temperature( average_temperature=avg_temp, wind_speed=wind, humidity_pct=humidity, age_days=age_days, ) rows.append( IotPanel( flock=flock, date=day, timestamp=slot_timestamp(day, slot), wind_speed=round(wind, 2), humidity=round(humidity, 1), water_total=round(water, 1), average_temperature=round(avg_temp, 2), experience_temperature=round(exp_temp, 2), ) ) day += timedelta(days=1) return rows