first commit

This commit is contained in:
Alberto-Audrix committed 2026-08-26 13:46:05 +07:00
commit 367533c02b
171 files changed
+101175

No files matched your search

View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.jobs"
label = "jobs"
verbose_name = "Jobs"
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,11 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
class Command(BaseCommand):
help = "Placeholder — advance_cycle_days no longer applicable (current_day removed from ERD)"
def handle(self, *args, **options):
self.stdout.write("current_day field removed; this command is a no-op now.")
@@ -0,0 +1,53 @@
from django.core.management.base import BaseCommand
from django.db.models import Sum
from apps.farms.models import Cycle
from apps.operations.models import ChickenCounting, FeedSacks, KPI
class Command(BaseCommand):
help = "Recompute simple KPI rollups from ops tables for active cycles"
def handle(self, *args, **options):
count = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
counting = ChickenCounting.objects.filter(cycle=cycle).aggregate(
mortality=Sum("mortality_count"),
life=Sum("total_count"),
)
feed_sack = FeedSacks.objects.filter(cycle=cycle).order_by("-date", "-pk").first()
latest_cc = ChickenCounting.objects.filter(cycle=cycle).order_by("-date").first()
latest_cw = None
latest_fs = feed_sack
latest_mi = None
from apps.operations.models import ChickenWeight, ManualInput
latest_cw = ChickenWeight.objects.filter(cycle=cycle).order_by("-date").first()
latest_mi = ManualInput.objects.filter(cycle=cycle).order_by("-date").first()
if not all([latest_cc, latest_cw, latest_fs, latest_mi]):
self.stdout.write(f"cycle={cycle.pk} skipped (missing source records)")
continue
kpi, _ = KPI.objects.update_or_create(
cycle=cycle,
date=latest_cc.date,
defaults={
"cc": latest_cc,
"cw": latest_cw,
"fs": latest_fs,
"manual": latest_mi,
"mortality_total": counting["mortality"] or 0,
"chicken_life": counting["life"] or cycle.doc_in_count,
"feed_total": feed_sack.feed_use_total if feed_sack else 0,
"chicken_life_percentage": (
((counting["life"] or cycle.doc_in_count) / cycle.doc_in_count * 100.0)
if cycle.doc_in_count
else 0.0
),
},
)
count += 1
self.stdout.write(f"KPI {kpi.pk} for cycle {cycle.pk}")
self.stdout.write(self.style.SUCCESS(f"Recomputed {count} KPI row(s)"))
@@ -0,0 +1,48 @@
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.accounts.models import ApiKey, User
class Command(BaseCommand):
help = (
"Seed demo superadmin login + API key, then Sukawarna Kandang 4 "
"and Kandang 5 as the default closed cycles"
)
@transaction.atomic
def handle(self, *args, **options):
user, created = User.objects.get_or_create(
user_name="admin",
defaults={"status": User.STATUS_SUPERADMIN, "is_staff": True, "is_superuser": True},
)
if created or not user.has_usable_password():
user.set_password("admin123")
user.status = User.STATUS_SUPERADMIN
user.save()
self.stdout.write("Created/updated user admin / admin123")
raw_key = None
bootstrap = settings.BOOTSTRAP_API_KEY
if bootstrap:
api_key, raw_key = ApiKey.generate(user, "bootstrap")
api_key.prefix = bootstrap[:8]
api_key.key_hash = ApiKey.hash_key(bootstrap)
api_key.save(update_fields=["prefix", "key_hash", "updated_at"])
raw_key = bootstrap
self.stdout.write(f"Bootstrap API key installed (prefix={api_key.prefix})")
elif not ApiKey.objects.filter(user=user).exists():
api_key, raw_key = ApiKey.generate(user, "seed")
self.stdout.write(self.style.WARNING(f"Demo API key (save now): {raw_key}"))
call_command("seed_sukawarna_kandang4")
call_command("seed_sukawarna_kandang5")
call_command("seed_iot_10min")
self.stdout.write(
self.style.SUCCESS(
"Seed OK: Sukawarna Kandang 4 & Kandang 5 closed cycles + 10-minute IoT panels"
)
)
@@ -0,0 +1,239 @@
"""Seed 10-minute IoT panel readings (144/day) for every flock.
Kandang 5 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, 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.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 5 Lantai 1 readings."""
is_lantai1 = flock_name == "Lantai 1"
if kandang_name == "Kandang 5":
if is_lantai1:
return {"real": True}
return {
"wind_delta": -0.05,
"humidity_delta": 1.2,
"temperature_delta": -0.3,
"water_factor": 0.96,
}
# Kandang 4: 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
@@ -0,0 +1,435 @@
"""Seed Sukawarna / Kandang 4 closed cycle.
Kandang 4 is a generated dataset modeled on the Kandang 5 seed
(``seed_sukawarna_kandang5``). The two houses run the same 49-day window at
the same site, but Kandang 4 is stocked with 45,000 DOC and performs slightly
better (lower mortality, marginally better FCR / EEF).
Every daily series is hand-tuned so the numbers stay internally consistent:
* ``chicken_counting`` – daily mortality sums to the cumulative total; camera
count tracks live population (DOC - mortality - harvest) with coverage noise.
* ``chicken_weight`` – smooth growth curve; weighing continues through
close-out (day 38-48), flock average declines as heavy birds are harvested.
* ``feed_sacks`` – daily use ramps up to ~98 sacks and falls off during
close-out; total use (1,941 sacks) matches close-out FCR ≈ 1.71.
* ``harvest`` – 20 partial harvests day 29-48, 40,700 birds,
57.47 t live weight, avg ~1,412 g/bird at ~33.1 days; tail harvests leave
fewer than 50 birds in the house at close-out.
* ``kpi`` / ``manual`` – FCR/EEF/lot-weight follow the same formulas as the
Kandang 5 preview:
FCR = feed_total_kg / (harvest_weight_total_g + lot_weight_g)
EEF = life_pct * (avg_harvest_weight/1000) / (FCR * avg_harvest_day) * 100
lot = chicken_life * average_weight
* ``iot_panel`` – derived from the real Kandang 5 sensor extract with
deterministic per-house jitter (same site, same season).
"""
from __future__ import annotations
import json
import math
import random
from datetime import date, timedelta
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import IOT_EXTRACT_JSON
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
DEFAULT_PASSWORD = "staff123"
START_DATE = date(2026, 5, 22)
END_DATE = date(2026, 7, 9)
TOTAL_DAYS = 49
DOC_IN_WEIGHT = 35
DOC_IN_COUNT = 45_000
FLOCK_NAMES = ("Lantai 1", "Lantai 2")
# --- Daily mortality per age 0..48 (sums to 4,269 ≈ 9.49 % deplesi) ---------
MORTALITY = [
42, 58, 74, 78, 68, 62, 60,
52, 54, 47, 50, 44, 46, 48,
42, 45, 47, 50, 52, 48, 46,
44, 47, 42, 50, 58, 65, 72,
95, 120, 150, 175, 200, 185, 215,
240, 255, 235, 220, 140, 115, 100,
85, 60, 65, 50, 45, 28, 0,
]
# --- Flock average weight (g) per age 0..48 ---------------------------------
# Weighing continues through close-out (day 38-48). As heavy birds are
# harvested first, the remaining flock average weight declines.
AVG_WEIGHT = [
35, 47, 62, 80, 100, 123, 148,
175, 204, 235, 268, 303, 340, 379,
420, 463, 508, 555, 604, 655, 708,
763, 820, 879, 940, 1003, 1068, 1135,
1204, 1275, 1348, 1423, 1500, 1570, 1530,
1470, 1420, 1360, 1310, 1270, 1230, 1220,
1200, 1180, 1160, 1140, 1120, 1100, 1080,
]
# --- Weigh-sample size per age 0..48 (declines with live flock during close-out) ---
SAMPLE_COUNT = [
229, 214, 250, 230, 260, 240, 220,
280, 210, 250, 230, 260, 240, 220,
270, 250, 230, 260, 240, 220, 280,
250, 230, 270, 240, 220, 260, 240,
280, 250, 230, 270, 250, 230, 260,
240, 220, 250, 180, 150, 120, 100,
80, 60, 50, 40, 30, 20, 15,
]
# --- Uniformity (%) per age 0..48 --------------------------------------------
UNIFORMITY = [
0, 45, 62, 70, 66, 74, 71,
78, 69, 73, 81, 68, 75, 72,
70, 77, 74, 69, 72, 76, 71,
73, 68, 74, 70, 72, 66, 69,
73, 67, 71, 69, 72, 68, 65,
70, 67, 63, 62, 61, 60, 59,
58, 57, 56, 55, 54, 53, 52,
]
# --- Feed used (sacks, 50 kg each) per age 0..48; sums to 1,941 --------------
FEED_TODAY = [
24, 11, 14, 22, 24, 27, 31,
34, 38, 40, 42, 36, 38, 41,
44, 48, 59, 66, 74, 85, 89,
90, 57, 98, 92, 84, 72, 79,
80, 71, 71, 65, 57, 24, 29,
14, 13, 12, 8, 3, 3, 5, 8, 0, 8, 2, 4, 4, 1,
]
# --- Feed delivered (sacks) on delivery days (age -> sacks) ------------------
# Cumulative intake (1,910 sacks) ends just below usage, a small stock draw.
FEED_IN = {
1: 120, 4: 130, 7: 140, 10: 150, 13: 160, 16: 170, 19: 150,
22: 140, 25: 130, 28: 120, 31: 110, 34: 100, 37: 90, 40: 80, 43: 120,
}
# --- Karung keluar (out) per age 0..48: only during the final week, small ---
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [3, 4, 5, 3, 2, 2, 1]
# --- Harvest plan: age -> (birds, avg live weight g/bird) --------------------
# 20 partial harvests day 29-48; the tail harvests clear out the house so the
# leftover flock (chicken_life) at close-out stays under 50 birds.
HARVEST = {
29: (4_500, 1_310),
30: (5_000, 1_360),
31: (5_000, 1_430),
32: (5_000, 1_490),
33: (5_000, 1_540),
34: (4_500, 1_500),
35: (3_500, 1_440),
36: (2_500, 1_390),
37: (2_000, 1_330),
38: (1_500, 1_280),
39: (1_000, 1_240),
40: (500, 1_200),
41: (120, 1_190),
42: (100, 1_170),
43: (90, 1_150),
44: (80, 1_130),
45: (90, 1_110),
46: (80, 1_090),
47: (80, 1_070),
48: (60, 1_050),
}
def _round(value: float, ndigits: int = 2) -> float:
if math.isclose(value, 0.0, abs_tol=1e-12):
return 0.0
return round(value, ndigits)
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 4 closed cycle (generated, modeled on Kandang 5)"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--no-reset", action="store_true")
parser.add_argument("--jitter-seed", type=int, default=7)
@transaction.atomic
def handle(self, *args, **options):
iot_path = Path(options["iot_json"])
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
iot = json.loads(iot_path.read_text(encoding="utf-8"))
password = options["password"]
rng = random.Random(options["jitter_seed"])
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 4",
site=site,
)
existing = Cycle.objects.filter(kandang=kandang, start_date=START_DATE).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 4 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=START_DATE,
defaults={
"end_date": END_DATE,
"total_days": TOTAL_DAYS,
"doc_in_weight": DOC_IN_WEIGHT,
"doc_in_count": DOC_IN_COUNT,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = END_DATE
cycle.total_days = TOTAL_DAYS
cycle.doc_in_weight = DOC_IN_WEIGHT
cycle.doc_in_count = DOC_IN_COUNT
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in FLOCK_NAMES:
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flocks_by_name[name] = flock
dates = [START_DATE + timedelta(days=i) for i in range(TOTAL_DAYS)]
# --- cumulative series -------------------------------------------------
mort_total = 0
harvest_total = 0
harvest_weight_total = 0.0
feed_total = 0
feed_in_total = 0
out_total = 0
cc_by_date: dict[date, ChickenCounting] = {}
cw_by_date: dict[date, ChickenWeight] = {}
fs_by_date: dict[date, FeedSacks] = {}
manual_by_date: dict[date, ManualInput] = {}
harvest_day_accum = 0.0 # Σ (harvest × age)
harvest_wt_accum = 0.0 # Σ (harvest × batch avg weight)
for i, day in enumerate(dates):
age = i
live = DOC_IN_COUNT - mort_total - harvest_total
# --- chicken_counting -----------------------------------------
quality = "ok" if age < 34 else "atas_only"
coverage = 0.82 + 0.16 * math.sin(i / 1.9) - 0.011 * min(i, 36)
camera_count = max(1, int(round(live * max(coverage, 0.3))))
cc, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"total_count": camera_count,
"mortality_count": MORTALITY[i],
},
)
cc_by_date[day] = cc
# --- chicken_weight --------------------------------------------
weight = AVG_WEIGHT[i]
prev_weight = AVG_WEIGHT[i - 1] if i > 0 else DOC_IN_WEIGHT
adg = _round(weight - prev_weight, 2) if weight else 0.0
cw, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"doc_weight": DOC_IN_WEIGHT,
"average_weight": weight,
"chicken_count": SAMPLE_COUNT[i],
"uniformity": UNIFORMITY[i],
"average_daily_gain": adg,
},
)
cw_by_date[day] = cw
# --- feed_sacks ------------------------------------------------
feed_today = FEED_TODAY[i]
in_today = FEED_IN.get(age, 0)
out_today = OUT_TODAY[i]
feed_total += feed_today
feed_in_total += in_today
out_total += out_today
fs, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": feed_in_total,
"out_total": out_total,
"feed_use_today": feed_today,
"feed_use_total": feed_total,
},
)
fs_by_date[day] = fs
# --- harvest (manual) ------------------------------------------
harvest_today, harvest_wt_today = HARVEST.get(age, (0, 0))
if harvest_today:
harvest_total += harvest_today
harvest_weight_total += harvest_today * harvest_wt_today
harvest_day_accum += harvest_today * age
harvest_wt_accum += harvest_today * harvest_wt_today
# --- manual input ----------------------------------------------
# Running averages: flock weight before the first harvest, then the
# harvest-weighted running mean carried forward on idle days.
if harvest_today:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
elif harvest_total == 0:
avg_hday = float(age)
avg_hwt = float(weight) if weight else 0.0
else:
avg_hday = harvest_day_accum / harvest_total
avg_hwt = harvest_wt_accum / harvest_total
life = DOC_IN_COUNT - mort_total - harvest_total
lot_weight_g = life * weight if weight else 0.0
denom = harvest_weight_total + lot_weight_g
fcr = (feed_total * 50_000) / denom if denom > 0 else 0.0
life_pct = (DOC_IN_COUNT - mort_total) / DOC_IN_COUNT * 100.0
if avg_hday > 0 and fcr > 0:
eef = life_pct * (avg_hwt / 1000.0) / (fcr * avg_hday) * 100.0
else:
eef = 0.0
manual, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": age,
"mortality_manual": MORTALITY[i],
"mortality_manual_total": mort_total,
"feed_in_manual": in_today,
"feed_in_manual_total": feed_in_total,
"feed_use_manual": feed_today,
"feed_use_manual_total": feed_total,
"feed_out_manual": out_today,
"feed_out_manual_total": out_total,
"harvest_manual": harvest_today,
"harvest_manual_total": harvest_total,
"harvest_weight_manual": float(harvest_today * harvest_wt_today),
"harvest_weight_manual_total": harvest_weight_total,
"manual_weight": weight,
"average_harvest_day_manual": _round(avg_hday, 4),
"average_harvest_weight_manual": _round(avg_hwt, 4),
"fcr_manual": _round(fcr, 4),
"eef_manual": _round(eef, 4),
},
)
manual_by_date[day] = manual
# --- KPI ---------------------------------------------------------
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": age,
"mortality": MORTALITY[i],
"mortality_total": mort_total,
"feed": feed_today,
"feed_total": feed_total,
"harvest": harvest_today,
"harvest_total": harvest_total,
"harvest_weight": float(harvest_today * harvest_wt_today),
"harvest_weight_total": harvest_weight_total,
"chicken_life": life,
"chicken_life_percentage": _round(life_pct, 3),
"iot_weight": _round(lot_weight_g, 2),
"average_harvest_day": _round(avg_hday, 4),
"average_harvest_weight": _round(avg_hwt, 4),
"fcr": _round(fcr, 4),
"eef": _round(eef, 4),
"cc": cc,
"cw": cw,
"fs": fs,
"manual": manual,
},
)
mort_total += MORTALITY[i]
# --- IoT panel: Kandang 5 sensors with per-house jitter -------------
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
avg_temp = float(row["average_temperature"]) + rng.uniform(-0.4, 0.4)
humidity = min(max(float(row["humidity"]) + rng.uniform(-3.0, 3.0), 0.0), 100.0)
wind = max(float(row["wind_speed"]) + rng.uniform(-0.1, 0.1), 0.0)
water = max(float(row["water_total"]) * 0.9 + rng.uniform(-120.0, 120.0), 0.0)
chill = float(row["chill_factor"])
exp_temp = avg_temp - (70.0 - humidity) / 5.0 - wind * chill
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"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, 4),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None} "
f"closeout_eef={last_kpi.eef if last_kpi else None}"
)
)
@@ -0,0 +1,296 @@
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from apps.accounts.models import User
from apps.farms.models import Cycle, Flock, Kandang, Site
from apps.jobs.sukawarna_preview import (
FEED_REFERENCE_XLSX,
IOT_EXTRACT_JSON,
PREVIEW_XLSX,
as_number,
load_feed_reference,
load_preview,
)
from apps.operations.models import (
ChickenCounting,
ChickenWeight,
FeedSacks,
IotPanel,
KPI,
ManualInput,
)
DEFAULT_PASSWORD = "staff123"
# --- Karung keluar (out) per cycle day 0..48: only during the final week ----
# "Out" is NOT the same as usage (tuang). It only happens at the very end of the
# cycle and totals ~20 sacks per house, with a handful of sacks per day.
OUT_TODAY = [0] * 42 + [4, 5, 4, 3, 2, 1, 1]
class Command(BaseCommand):
help = "Seed Sukawarna / Kandang 5 closed cycle from preview xlsx + IoT extract"
def add_arguments(self, parser):
parser.add_argument("--password", default=DEFAULT_PASSWORD)
parser.add_argument("--xlsx", default=str(PREVIEW_XLSX))
parser.add_argument("--iot-json", default=str(IOT_EXTRACT_JSON))
parser.add_argument("--feed-reference", default=str(FEED_REFERENCE_XLSX))
parser.add_argument("--no-reset", action="store_true")
@transaction.atomic
def handle(self, *args, **options):
xlsx = Path(options["xlsx"])
iot_path = Path(options["iot_json"])
feed_reference_path = Path(options["feed_reference"])
if not xlsx.is_file():
raise CommandError(f"Preview not found: {xlsx}")
if not iot_path.is_file():
raise CommandError(f"IoT extract not found: {iot_path}")
sheets = load_preview(xlsx)
iot = json.loads(iot_path.read_text(encoding="utf-8"))
feed_reference = {}
if feed_reference_path.is_file():
feed_reference = load_feed_reference(feed_reference_path)
else:
self.stderr.write(f"Feed reference not found, continuing without it: {feed_reference_path}")
password = options["password"]
user, created = User.objects.get_or_create(
user_name="staff",
defaults={"status": User.STATUS_ACTIVE, "is_staff": True},
)
user.status = User.STATUS_ACTIVE
user.is_staff = True
user.set_password(password)
user.save()
if created:
self.stdout.write(f"Created user staff / {password}")
else:
self.stdout.write(f"Updated password for staff / {password}")
site, _ = Site.objects.get_or_create(site_name="Sukawarna", defaults={"user": user})
if site.user_id != user.pk:
site.user = user
site.save(update_fields=["user", "updated_at"])
kandang, _ = Kandang.objects.get_or_create(
kandang_name="Kandang 5",
site=site,
)
start_date = date(2026, 5, 22)
end_date = date(2026, 7, 9)
existing = Cycle.objects.filter(kandang=kandang, start_date=start_date).first()
if existing and not options["no_reset"]:
KPI.objects.filter(cycle=existing).delete()
ChickenCounting.objects.filter(cycle=existing).delete()
ChickenWeight.objects.filter(cycle=existing).delete()
FeedSacks.objects.filter(cycle=existing).delete()
ManualInput.objects.filter(cycle=existing).delete()
IotPanel.objects.filter(flock__cycle=existing).delete()
Flock.objects.filter(cycle=existing).delete()
existing.delete()
self.stdout.write("Reset previous Kandang 5 cycle")
cycle, cycle_created = Cycle.objects.get_or_create(
kandang=kandang,
start_date=start_date,
defaults={
"end_date": end_date,
"total_days": 49,
"doc_in_weight": 35,
"doc_in_count": 50000,
"status": Cycle.STATUS_CLOSED,
},
)
if not cycle_created:
cycle.end_date = end_date
cycle.total_days = 49
cycle.doc_in_weight = 35
cycle.doc_in_count = 50000
cycle.status = Cycle.STATUS_CLOSED
cycle.save()
flocks_by_name: dict[str, Flock] = {}
for name in ("Lantai 1", "Lantai 2"):
flock, _ = Flock.objects.get_or_create(flock_name=name, cycle=cycle)
flocks_by_name[name] = flock
cc_by_date = {}
for row in sheets["chicken_counting"]:
obj, _ = ChickenCounting.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"total_count": int(as_number(row.get("total_count"))),
"mortality_count": int(as_number(row.get("mortality_count"))),
},
)
cc_by_date[row["date"]] = obj
cw_by_date = {}
for row in sheets["chicken_weight"]:
obj, _ = ChickenWeight.objects.update_or_create(
cycle=cycle,
date=row["date"],
defaults={
"age": int(as_number(row.get("age"))),
"doc_weight": int(as_number(row.get("doc_weight"))),
"average_weight": float(as_number(row.get("average_weight"))),
"chicken_count": int(as_number(row.get("chicken_count"))),
"uniformity": float(as_number(row.get("uniformity"))),
"average_daily_gain": float(as_number(row.get("average_daily_gain"))),
},
)
cw_by_date[row["date"]] = obj
fs_by_date = {}
fs_in_total = 0
fs_use_total = 0
fs_out_total = 0
for row in sheets["feed_sacks"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
in_today = int(ref["iot_in"]) if ref else int(as_number(row.get("in_today")))
feed_use_today = (
int(ref["iot_use"]) if ref else int(as_number(row.get("out_today")))
)
out_today = OUT_TODAY[(day - start_date).days]
fs_in_total += in_today
fs_use_total += feed_use_today
fs_out_total += out_today
obj, _ = FeedSacks.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"in_today": in_today,
"out_today": out_today,
"in_total": fs_in_total,
"out_total": fs_out_total,
"feed_use_today": feed_use_today,
"feed_use_total": fs_use_total,
},
)
fs_by_date[day] = obj
manual_by_date = {}
manual_in_total = 0
manual_use_total = 0
manual_out_total = 0
for row in sheets["manual_input"]:
day = row["date"]
ref = feed_reference.get(day.isoformat())
feed_in_manual = int(ref["manual_in"]) if ref else (
int(fs_row.in_today) if (fs_row := fs_by_date.get(day)) else 0
)
feed_use_manual = int(ref["manual_use"]) if ref else int(as_number(row.get("feed_manual")))
feed_out_manual = OUT_TODAY[(day - start_date).days]
manual_in_total += feed_in_manual
manual_use_total += feed_use_manual
manual_out_total += feed_out_manual
obj, _ = ManualInput.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age_manual": int(as_number(row.get("age_manual"))),
"mortality_manual": int(as_number(row.get("mortality_manual"))),
"mortality_manual_total": int(as_number(row.get("mortality_manual_total"))),
"feed_in_manual": feed_in_manual,
"feed_in_manual_total": manual_in_total,
"feed_use_manual": feed_use_manual,
"feed_use_manual_total": manual_use_total,
"feed_out_manual": feed_out_manual,
"feed_out_manual_total": manual_out_total,
"harvest_manual": int(as_number(row.get("harvest_manual"))),
"harvest_manual_total": int(as_number(row.get("harvest_manual_total"))),
"harvest_weight_manual": float(as_number(row.get("harvest_weight_manual"))),
"harvest_weight_manual_total": float(
as_number(row.get("harvest_weight_manual_total"))
),
"manual_weight": float(
as_number(row.get("manual_weight_float") or row.get("manual_weight"))
),
"average_harvest_day_manual": float(
as_number(row.get("average_harvest_day_manual"))
),
"average_harvest_weight_manual": float(
as_number(row.get("average_harvest_weight_manual"))
),
"fcr_manual": float(as_number(row.get("fcr_manual"))),
"eef_manual": float(as_number(row.get("eef_manual"))),
},
)
manual_by_date[day] = obj
missing = []
for row in sheets["kpi_preview"]:
day = row["date"]
try:
KPI.objects.update_or_create(
cycle=cycle,
date=day,
defaults={
"age": int(as_number(row.get("age"))),
"mortality": int(as_number(row.get("deplesi"))),
"mortality_total": int(as_number(row.get("deplesi_total"))),
"feed": int(as_number(row.get("feed_use_manual"))),
"feed_total": int(as_number(row.get("feed_use_total_manual"))),
"harvest": int(as_number(row.get("panen"))),
"harvest_total": int(as_number(row.get("panen_total"))),
"harvest_weight": float(as_number(row.get("harvest_weight_g"))),
"harvest_weight_total": float(as_number(row.get("harvest_weight_total_g"))),
"chicken_life": int(as_number(row.get("chicken_life"))),
"chicken_life_percentage": float(as_number(row.get("chicken_life_pct"))),
"iot_weight": float(as_number(row.get("lot_weight_g"))),
"average_harvest_day": float(as_number(row.get("avg_harvest_day"))),
"average_harvest_weight": float(as_number(row.get("avg_harvest_weight_g"))),
"fcr": float(as_number(row.get("fcr"))),
"eef": float(as_number(row.get("eef"))),
"cc": cc_by_date[day],
"cw": cw_by_date[day],
"fs": fs_by_date[day],
"manual": manual_by_date[day],
},
)
except KeyError:
missing.append(str(day))
if missing:
raise CommandError(f"KPI dates missing source rows: {missing}")
panel_count = 0
for flock_block in iot["flocks"]:
flock = flocks_by_name[flock_block["name"]]
for row in flock_block["rows"]:
day = date.fromisoformat(row["date"])
IotPanel.objects.update_or_create(
flock=flock,
date=day,
defaults={
"wind_speed": float(row["wind_speed"]),
"humidity": float(row["humidity"]),
"water_total": float(row["water_total"]),
"average_temperature": float(row["average_temperature"]),
"experience_temperature": float(row["experience_temperature"]),
},
)
panel_count += 1
last_kpi = KPI.objects.filter(cycle=cycle).order_by("-date").first()
self.stdout.write(
self.style.SUCCESS(
f"Seeded cycle={cycle.pk} site={site.pk} kandang={kandang.pk} "
f"flocks={Flock.objects.filter(cycle=cycle).count()} "
f"cc={len(cc_by_date)} cw={len(cw_by_date)} fs={len(fs_by_date)} "
f"manual={len(manual_by_date)} kpi={KPI.objects.filter(cycle=cycle).count()} "
f"iot_panel={panel_count} closeout_fcr={last_kpi.fcr if last_kpi else None}"
)
)
@@ -0,0 +1,28 @@
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.farms.models import Cycle
from apps.operations.services.karung_web import KarungWebError, request_karung
class Command(BaseCommand):
help = "Sync FeedSacks rows from karung-web-admin active sections"
def handle(self, *args, **options):
if not settings.KARUNG_WEB_ADMIN_SYNC_ENABLED:
self.stdout.write("KARUNG_WEB_ADMIN_SYNC_ENABLED=false; skipping")
return
synced = 0
for cycle in Cycle.objects.filter(status=Cycle.STATUS_ACTIVE):
try:
feed_sack, summary = request_karung(cycle, section="today")
synced += 1
self.stdout.write(
f"cycle={cycle.pk} in_today={feed_sack.in_today} "
f"out_today={feed_sack.out_today} date={summary.get('snapshot_date')}"
)
except KarungWebError as exc:
self.stderr.write(f"cycle={cycle.pk} error={exc}")
except Exception as exc: # noqa: BLE001
self.stderr.write(f"cycle={cycle.pk} unexpected={exc}")
self.stdout.write(self.style.SUCCESS(f"Synced {synced} feed sack row(s)"))
+104
View File
@@ -0,0 +1,104 @@
"""Load Sukawarna / Kandang 5 preview sheets into ORM-ready row dicts."""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
REPO_ROOT = Path(__file__).resolve().parents[3]
PREVIEW_XLSX = REPO_ROOT / "backup" / "kandang5_sukawarna_preview.xlsx"
IOT_EXTRACT_JSON = REPO_ROOT / "scripts" / "iot_panel_cycle_extract.json"
# Final-cycle report (reference for manual feed use on Kandang 5).
FEED_REFERENCE_XLSX = (
REPO_ROOT
/ "backup"
/ "Laporan_Akhir-Siklus_Sukawarna_Kandang-Atas_Akhir-Siklus_2026-05-22_sd_2026-07-09.xlsx"
)
def as_date(value: Any) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if len(text) >= 10 and text[0].isdigit() and text[4] == "-":
return date.fromisoformat(text[:10])
return None
def as_number(value: Any, default: float | int = 0):
if value is None or value == "":
return default
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value
try:
if isinstance(value, str) and "." not in value:
return int(value)
return float(value)
except (TypeError, ValueError):
return default
def dated_rows(ws) -> list[dict[str, Any]]:
headers = [cell.value for cell in next(ws.iter_rows(min_row=1, max_row=1))]
rows: list[dict[str, Any]] = []
for raw in ws.iter_rows(min_row=2, values_only=True):
mapped = {str(headers[i]): raw[i] for i in range(len(headers))}
parsed = as_date(mapped.get("date"))
if parsed is None:
continue
mapped["date"] = parsed
rows.append(mapped)
return rows
def load_preview(path: Path | None = None) -> dict[str, list[dict[str, Any]]]:
workbook = load_workbook(path or PREVIEW_XLSX, data_only=True)
return {
"chicken_counting": dated_rows(workbook["chicken_counting"]),
"chicken_weight": dated_rows(workbook["chicken_weight"]),
"feed_sacks": dated_rows(workbook["feed_sacks"]),
"manual_input": dated_rows(workbook["manual_input"]),
"kpi_preview": dated_rows(workbook["kpi_preview"]),
}
def load_feed_reference(path: Path | None = None) -> dict[str, dict[str, int]]:
"""Load the final-cycle report's Feed In / Feed Use sheets.
Returns ``{date: {manual_in, iot_in, manual_use, iot_use}}``. These values are
the authoritative reference for the karung "Manual" / "IOT" columns.
"""
workbook = load_workbook(path or FEED_REFERENCE_XLSX, data_only=True)
feed_in: dict[str, dict[str, int]] = {}
for raw in workbook["Feed In"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_in[parsed.isoformat()] = {
"manual_in": int(as_number(raw[1])),
"iot_in": int(as_number(raw[2])),
}
feed_use: dict[str, dict[str, int]] = {}
for raw in workbook["Feed Use"].iter_rows(min_row=3, values_only=True):
parsed = as_date(raw[0])
if parsed is None:
continue
feed_use[parsed.isoformat()] = {
"manual_use": int(as_number(raw[2])),
"iot_use": int(as_number(raw[3])),
}
merged: dict[str, dict[str, int]] = {}
for key in feed_in:
if key in feed_use:
merged[key] = {**feed_in[key], **feed_use[key]}
return merged
+32
View File
@@ -0,0 +1,32 @@
from datetime import date, datetime
from io import BytesIO
from django.test import SimpleTestCase
from openpyxl import Workbook
from apps.jobs.sukawarna_preview import as_date, as_number, dated_rows
class SukawarnaPreviewHelpersTests(SimpleTestCase):
def test_as_date_accepts_iso_and_datetime(self):
self.assertEqual(as_date("2026-05-22"), date(2026, 5, 22))
self.assertEqual(as_date(datetime(2026, 7, 9, 12, 0)), date(2026, 7, 9))
self.assertIsNone(as_date("grams"))
self.assertIsNone(as_date("karung tuang Jul 4-8 updated"))
def test_dated_rows_skips_note_rows(self):
wb = Workbook()
ws = wb.active
ws.append(["date", "total_count"])
ws.append(["grams", "grams"])
ws.append(["2026-05-22", 13980])
bio = BytesIO()
wb.save(bio)
bio.seek(0)
from openpyxl import load_workbook
loaded = load_workbook(bio, data_only=True).active
rows = dated_rows(loaded)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["date"], date(2026, 5, 22))
self.assertEqual(as_number(rows[0]["total_count"]), 13980)