Files
dashboard-cpsp/backend/apps/jobs/management/commands/seed_iot_10min.py
T

239 lines
9.4 KiB
Python

"""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