first commit
This commit is contained in:
commit
367533c02b
171 files changed
+101175
No files matched your search
Whitespace-only changes.
@@ -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)"))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user