239 lines
9.4 KiB
Python
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 |