804 lines
34 KiB
Python
804 lines
34 KiB
Python
"""
|
||
CP 707 Knowledge Base
|
||
|
||
Sumber: Buku "Manajemen Broiler CP 707" oleh PT Charoen Pokphand Indonesia, Tbk.
|
||
Edisi Juli 2023
|
||
|
||
Seluruh standar teknis dari buku panduan CP 707 untuk referensi on-premise AI Insight.
|
||
TIDAK ada data dari sumber luar — hanya dari buku ini.
|
||
|
||
Port murni Python 3.11+ dari cp707Knowledge.js (tanpa dependensi Django).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
# ─── Standar Performa Mingguan CP 707 ───────────────────────────────────────
|
||
PERFORMANCE_STANDARD_WEEKLY: list[dict[str, Any]] = [
|
||
{"week": 1, "targetBW_g": 195, "adg_g": 34, "cumFeedConsumption_g": 164.5, "fcr": 0.844},
|
||
{"week": 2, "targetBW_g": 499, "adg_g": 50, "cumFeedConsumption_g": 530.5, "fcr": 1.063},
|
||
{"week": 3, "targetBW_g": 954, "adg_g": 80, "cumFeedConsumption_g": 1181.5, "fcr": 1.238},
|
||
{"week": 4, "targetBW_g": 1543, "adg_g": 88, "cumFeedConsumption_g": 2198.5, "fcr": 1.425},
|
||
{"week": 5, "targetBW_g": 2191, "adg_g": 94, "cumFeedConsumption_g": 3461, "fcr": 1.580},
|
||
]
|
||
|
||
# ─── Standar Performa Harian CP 707 (Lampiran 2) ────────────────────────────
|
||
PERFORMANCE_STANDARD_DAILY: list[dict[str, Any]] = [
|
||
{"day": 1, "bw_g": 57, "adg_g": 15, "mortalityCum_pct": 0.40, "feedDaily_g": 13, "feedCum_g": 13, "fcr": 0.228, "ip": None},
|
||
{"day": 2, "bw_g": 73, "adg_g": 16, "mortalityCum_pct": 0.50, "feedDaily_g": 17, "feedCum_g": 30, "fcr": 0.411, "ip": None},
|
||
{"day": 3, "bw_g": 90, "adg_g": 17, "mortalityCum_pct": 0.60, "feedDaily_g": 20.5, "feedCum_g": 50.5, "fcr": 0.561, "ip": None},
|
||
{"day": 4, "bw_g": 110, "adg_g": 20, "mortalityCum_pct": 0.70, "feedDaily_g": 23, "feedCum_g": 73.5, "fcr": 0.668, "ip": None},
|
||
{"day": 5, "bw_g": 134, "adg_g": 24, "mortalityCum_pct": 0.80, "feedDaily_g": 26, "feedCum_g": 99.5, "fcr": 0.743, "ip": None},
|
||
{"day": 6, "bw_g": 161, "adg_g": 27, "mortalityCum_pct": 0.90, "feedDaily_g": 31, "feedCum_g": 130.5, "fcr": 0.811, "ip": None},
|
||
{"day": 7, "bw_g": 195, "adg_g": 34, "mortalityCum_pct": 1.00, "feedDaily_g": 34, "feedCum_g": 164.5, "fcr": 0.844, "ip": 327},
|
||
{"day": 8, "bw_g": 231, "adg_g": 36, "mortalityCum_pct": 1.10, "feedDaily_g": 35, "feedCum_g": 199.5, "fcr": 0.864, "ip": 331},
|
||
{"day": 9, "bw_g": 269, "adg_g": 38, "mortalityCum_pct": 1.20, "feedDaily_g": 41, "feedCum_g": 240.5, "fcr": 0.894, "ip": 330},
|
||
{"day": 10, "bw_g": 311, "adg_g": 42, "mortalityCum_pct": 1.30, "feedDaily_g": 46, "feedCum_g": 286.5, "fcr": 0.921, "ip": 333},
|
||
{"day": 11, "bw_g": 355, "adg_g": 44, "mortalityCum_pct": 1.40, "feedDaily_g": 52, "feedCum_g": 338.5, "fcr": 0.954, "ip": 334},
|
||
{"day": 12, "bw_g": 401, "adg_g": 46, "mortalityCum_pct": 1.50, "feedDaily_g": 58, "feedCum_g": 396.5, "fcr": 0.989, "ip": 333},
|
||
{"day": 13, "bw_g": 449, "adg_g": 48, "mortalityCum_pct": 1.60, "feedDaily_g": 64, "feedCum_g": 460.5, "fcr": 1.026, "ip": 331},
|
||
{"day": 14, "bw_g": 499, "adg_g": 50, "mortalityCum_pct": 1.70, "feedDaily_g": 70, "feedCum_g": 530.5, "fcr": 1.063, "ip": 330},
|
||
{"day": 15, "bw_g": 552, "adg_g": 53, "mortalityCum_pct": 1.80, "feedDaily_g": 73, "feedCum_g": 603.5, "fcr": 1.093, "ip": 331},
|
||
{"day": 16, "bw_g": 609, "adg_g": 57, "mortalityCum_pct": 1.90, "feedDaily_g": 80, "feedCum_g": 683.5, "fcr": 1.122, "ip": 333},
|
||
{"day": 17, "bw_g": 669, "adg_g": 60, "mortalityCum_pct": 2.00, "feedDaily_g": 86, "feedCum_g": 769.5, "fcr": 1.150, "ip": 335},
|
||
{"day": 18, "bw_g": 734, "adg_g": 65, "mortalityCum_pct": 2.10, "feedDaily_g": 92, "feedCum_g": 861.5, "fcr": 1.174, "ip": 340},
|
||
{"day": 19, "bw_g": 802, "adg_g": 68, "mortalityCum_pct": 2.20, "feedDaily_g": 100, "feedCum_g": 961.5, "fcr": 1.199, "ip": 344},
|
||
{"day": 20, "bw_g": 874, "adg_g": 72, "mortalityCum_pct": 2.30, "feedDaily_g": 107, "feedCum_g": 1068.5, "fcr": 1.223, "ip": 349},
|
||
{"day": 21, "bw_g": 954, "adg_g": 80, "mortalityCum_pct": 2.40, "feedDaily_g": 113, "feedCum_g": 1181.5, "fcr": 1.238, "ip": 358},
|
||
{"day": 22, "bw_g": 1035, "adg_g": 81, "mortalityCum_pct": 2.52, "feedDaily_g": 128, "feedCum_g": 1309.5, "fcr": 1.265, "ip": 362},
|
||
{"day": 23, "bw_g": 1117, "adg_g": 82, "mortalityCum_pct": 2.64, "feedDaily_g": 133, "feedCum_g": 1442.5, "fcr": 1.291, "ip": 366},
|
||
{"day": 24, "bw_g": 1200, "adg_g": 83, "mortalityCum_pct": 2.76, "feedDaily_g": 139, "feedCum_g": 1581.5, "fcr": 1.318, "ip": 369},
|
||
{"day": 25, "bw_g": 1284, "adg_g": 84, "mortalityCum_pct": 2.88, "feedDaily_g": 145, "feedCum_g": 1726.5, "fcr": 1.345, "ip": 371},
|
||
{"day": 26, "bw_g": 1369, "adg_g": 85, "mortalityCum_pct": 3.00, "feedDaily_g": 151, "feedCum_g": 1877.5, "fcr": 1.371, "ip": 372},
|
||
{"day": 27, "bw_g": 1455, "adg_g": 86, "mortalityCum_pct": 3.12, "feedDaily_g": 157, "feedCum_g": 2034.5, "fcr": 1.398, "ip": 373},
|
||
{"day": 28, "bw_g": 1543, "adg_g": 88, "mortalityCum_pct": 3.25, "feedDaily_g": 164, "feedCum_g": 2198.5, "fcr": 1.425, "ip": 374},
|
||
{"day": 29, "bw_g": 1633, "adg_g": 90, "mortalityCum_pct": 3.38, "feedDaily_g": 173, "feedCum_g": 2371.5, "fcr": 1.452, "ip": 375},
|
||
{"day": 30, "bw_g": 1725, "adg_g": 92, "mortalityCum_pct": 3.52, "feedDaily_g": 176.5, "feedCum_g": 2548, "fcr": 1.477, "ip": 376},
|
||
{"day": 31, "bw_g": 1817, "adg_g": 92, "mortalityCum_pct": 3.66, "feedDaily_g": 178, "feedCum_g": 2726, "fcr": 1.500, "ip": 376},
|
||
{"day": 32, "bw_g": 1910, "adg_g": 93, "mortalityCum_pct": 3.80, "feedDaily_g": 180, "feedCum_g": 2906, "fcr": 1.521, "ip": 377},
|
||
{"day": 33, "bw_g": 2003, "adg_g": 93, "mortalityCum_pct": 3.95, "feedDaily_g": 183, "feedCum_g": 3089, "fcr": 1.542, "ip": 378},
|
||
{"day": 34, "bw_g": 2097, "adg_g": 94, "mortalityCum_pct": 4.10, "feedDaily_g": 185, "feedCum_g": 3274, "fcr": 1.561, "ip": 379},
|
||
{"day": 35, "bw_g": 2191, "adg_g": 94, "mortalityCum_pct": 4.25, "feedDaily_g": 187, "feedCum_g": 3461, "fcr": 1.580, "ip": 379},
|
||
{"day": 36, "bw_g": 2285, "adg_g": 94, "mortalityCum_pct": 4.45, "feedDaily_g": 190, "feedCum_g": 3651, "fcr": 1.598, "ip": 380},
|
||
{"day": 37, "bw_g": 2380, "adg_g": 95, "mortalityCum_pct": 4.65, "feedDaily_g": 193, "feedCum_g": 3844, "fcr": 1.615, "ip": 380},
|
||
]
|
||
|
||
# ─── Target Suhu Pemeliharaan per Umur (Buku CP 707) ────────────────────────
|
||
TEMPERATURE_STANDARD: list[dict[str, Any]] = [
|
||
{"ageDay_from": 1, "ageDay_to": 2, "temp_C": 32, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 3, "ageDay_to": 4, "temp_C": 31, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 5, "ageDay_to": 7, "temp_C": 30, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 8, "ageDay_to": 14, "temp_C": 29, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 15, "ageDay_to": 21, "temp_C": 28, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 22, "ageDay_to": 28, "temp_C": 26, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 29, "ageDay_to": 35, "temp_C": 23, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
{"ageDay_from": 36, "ageDay_to": 99, "temp_C": 22, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||
]
|
||
|
||
# ─── Target Efektif Temperatur (TET) per Umur ───────────────────────────────
|
||
TARGET_EFFECTIVE_TEMPERATURE: list[dict[str, Any]] = [
|
||
{"ageDay_from": 1, "ageDay_to": 2, "tet_C": 32},
|
||
{"ageDay_from": 3, "ageDay_to": 4, "tet_C": 31},
|
||
{"ageDay_from": 5, "ageDay_to": 7, "tet_C": 30},
|
||
{"ageDay_from": 8, "ageDay_to": 14, "tet_C": 29},
|
||
{"ageDay_from": 15, "ageDay_to": 21, "tet_C": 27},
|
||
{"ageDay_from": 22, "ageDay_to": 28, "tet_C": 25},
|
||
{"ageDay_from": 29, "ageDay_to": 35, "tet_C": 22},
|
||
{"ageDay_from": 36, "ageDay_to": 99, "tet_C": 21},
|
||
]
|
||
|
||
# ─── Standar Kualitas Udara (Lampiran 3) ────────────────────────────────────
|
||
AIR_QUALITY_STANDARD: dict[str, Any] = {
|
||
"ammonia": {
|
||
"ideal_pct": "<10 ppm",
|
||
"warning": 10, # >10 ppm: merusak permukaan paru-paru
|
||
"critical": 25, # >25 ppm: pertumbuhan menurun
|
||
"severe": 50, # >50 ppm: pertumbuhan menurun signifikan
|
||
"note": ">20 ppm lebih rentan terhadap penyakit pernapasan",
|
||
},
|
||
"co2": {
|
||
"ideal": "<3000 ppm",
|
||
"critical": 3500, # >3500 ppm: ascites dan kematian tinggi
|
||
},
|
||
"co": {
|
||
"ideal": "10 ppm",
|
||
"warning": 50, # >50 ppm mempengaruhi kesehatan
|
||
"critical": 100, # 100 ppm: meningkatkan angka kematian
|
||
},
|
||
"humidity": {
|
||
"ideal_after_brooding": "50-60%",
|
||
"warning_high": 70, # >70% pada suhu >29°C berpengaruh pada pertumbuhan
|
||
"warning_low": 50, # RH <50% selama brooding berpengaruh pada pertumbuhan
|
||
},
|
||
}
|
||
|
||
# ─── Standar Mortalitas CP 707 ───────────────────────────────────────────────
|
||
MORTALITY_THRESHOLDS: dict[str, Any] = {
|
||
"normal_pct": 5, # <5% mortalitas dianggap normal
|
||
"warning_pct": 7, # 5-7% perlu perhatian
|
||
"critical_pct": 7, # >7% kritis
|
||
# Standar kumulatif per umur (dari tabel harian lampiran 2)
|
||
"byDayStandard": [
|
||
{"day": d["day"], "mortalityCumStd_pct": d["mortalityCum_pct"]}
|
||
for d in PERFORMANCE_STANDARD_DAILY
|
||
],
|
||
}
|
||
|
||
# ─── Kepadatan Kandang (Buku CP 707) ─────────────────────────────────────────
|
||
DENSITY_STANDARD: dict[str, Any] = {
|
||
"openHouse": {
|
||
"minKgPerM2": 12,
|
||
"maxKgPerM2": 13,
|
||
"description": "Kandang terbuka dengan ventilasi alami",
|
||
},
|
||
"closedHouse": {
|
||
"minKgPerM2": 24,
|
||
"maxKgPerM2": 30,
|
||
"description": "Kandang tertutup dapat mencapai 24-30 kg/m2",
|
||
},
|
||
"byHarvestWeight": [
|
||
{"minBW_kg": 0.80, "maxBW_kg": 0.99, "density_ekorPerM2_min": 11.0, "density_ekorPerM2_max": 11.1},
|
||
{"minBW_kg": 1.00, "maxBW_kg": 1.19, "density_ekorPerM2_min": 10.0, "density_ekorPerM2_max": 10.5},
|
||
{"minBW_kg": 1.20, "maxBW_kg": 1.39, "density_ekorPerM2_min": 9.0, "density_ekorPerM2_max": 9.5},
|
||
{"minBW_kg": 1.40, "maxBW_kg": 1.59, "density_ekorPerM2_min": 8.0, "density_ekorPerM2_max": 8.5},
|
||
{"minBW_kg": 1.60, "maxBW_kg": 1.89, "density_ekorPerM2_min": 7.5, "density_ekorPerM2_max": 8.0},
|
||
{"minBW_kg": 1.90, "maxBW_kg": 99, "density_ekorPerM2_min": 7.0, "density_ekorPerM2_max": 7.5},
|
||
],
|
||
}
|
||
|
||
# ─── Konsumsi Air per 1000 ekor per hari (suhu 21°C) ─────────────────────────
|
||
WATER_CONSUMPTION_STANDARD: list[dict[str, Any]] = [
|
||
{"week": 1, "minLiter": 58, "maxLiter": 65},
|
||
{"week": 2, "minLiter": 102, "maxLiter": 115},
|
||
{"week": 3, "minLiter": 149, "maxLiter": 167},
|
||
{"week": 4, "minLiter": 192, "maxLiter": 216},
|
||
{"week": 5, "minLiter": 232, "maxLiter": 261},
|
||
{"week": 6, "minLiter": 274, "maxLiter": 308},
|
||
{"week": 7, "minLiter": 309, "maxLiter": 347},
|
||
{"week": 8, "minLiter": 342, "maxLiter": 385},
|
||
]
|
||
# Catatan: Di atas 21°C, kebutuhan air meningkat rata-rata 6.5% per kenaikan 1°C
|
||
WATER_INCREASE_PER_DEGREE_ABOVE_21C_PCT = 6.5
|
||
|
||
# ─── Program Pencahayaan CP 707 ───────────────────────────────────────────────
|
||
LIGHTING_PROGRAM: list[dict[str, Any]] = [
|
||
{"ageDay_from": 2, "ageDay_to": 7, "onHours": 23, "darkFrom": "20:00", "darkTo": "21:00"},
|
||
{"ageDay_from": 8, "ageDay_to": 14, "onHours": 22, "darkFrom": "20:00", "darkTo": "22:00"},
|
||
{"ageDay_from": 15, "ageDay_to": 20, "onHours": 20, "darkFrom": "20:00", "darkTo": "23:00"},
|
||
{"ageDay_from": 21, "ageDay_to": 28, "onHours": 20, "darkFrom": "20:00", "darkTo": "24:00"},
|
||
{"ageDay_from": 29, "ageDay_to": 99, "onHours": 23, "darkFrom": "20:00", "darkTo": "22:00"},
|
||
]
|
||
LIGHTING_MIN_INTENSITY_LUX = 25
|
||
LIGHTING_BROODING_OPTIMAL_LUX = "40-60"
|
||
LIGHTING_AFTER_15DAYS_LUX = 5
|
||
|
||
|
||
def _daily_row(day_age: Any) -> dict[str, Any] | None:
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
for row in PERFORMANCE_STANDARD_DAILY:
|
||
if row["day"] == day:
|
||
return row
|
||
return None
|
||
|
||
|
||
def _as_float(value: Any) -> float | None:
|
||
if value is None or isinstance(value, bool):
|
||
return None
|
||
try:
|
||
n = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if n != n: # NaN
|
||
return None
|
||
return n
|
||
|
||
|
||
def _direction_from_delta(delta_pct: float, *, band: float = 5.0) -> str:
|
||
if delta_pct > band:
|
||
return "di_atas_standar"
|
||
if delta_pct < -band:
|
||
return "di_bawah_standar"
|
||
return "sesuai_standar"
|
||
|
||
|
||
def get_fcr_standard_by_day(day_age: Any) -> float | None:
|
||
std = _daily_row(day_age)
|
||
if std:
|
||
return float(std["fcr"])
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if day > 37:
|
||
return 1.65
|
||
return None
|
||
|
||
|
||
def get_bw_standard_by_day(day_age: Any) -> float | None:
|
||
std = _daily_row(day_age)
|
||
if std:
|
||
return float(std["bw_g"])
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if day > 37:
|
||
return 2500.0
|
||
return None
|
||
|
||
|
||
def get_temp_standard_by_day(day_age: Any) -> dict[str, Any]:
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return dict(TEMPERATURE_STANDARD[-1])
|
||
for row in TEMPERATURE_STANDARD:
|
||
if row["ageDay_from"] <= day <= row["ageDay_to"]:
|
||
return dict(row)
|
||
return dict(TEMPERATURE_STANDARD[-1])
|
||
|
||
|
||
def get_tet_by_day(day_age: Any) -> float:
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return 21.0
|
||
for row in TARGET_EFFECTIVE_TEMPERATURE:
|
||
if row["ageDay_from"] <= day <= row["ageDay_to"]:
|
||
return float(row["tet_C"])
|
||
return 21.0
|
||
|
||
|
||
def get_ip_standard_by_day(day_age: Any) -> int | None:
|
||
"""
|
||
Standar IP (Indeks Performans / EEF) menurut Lampiran 2 buku CP 707.
|
||
|
||
Kolom IP di tabel buku hanya terisi mulai hari ke-7 dan berhenti di hari
|
||
ke-37 (nilai 380). Di luar rentang itu buku TIDAK menyatakan standar, jadi
|
||
fungsi ini mengembalikan None — jangan menggantinya dengan tebakan.
|
||
"""
|
||
std = _daily_row(day_age)
|
||
if std is None:
|
||
return None
|
||
ip = std.get("ip")
|
||
return int(ip) if isinstance(ip, (int, float)) else None
|
||
|
||
|
||
def get_ip_standard_range() -> dict[str, int] | None:
|
||
"""Rentang IP yang tercantum di buku, untuk konteks saat hari di luar tabel."""
|
||
values = [int(d["ip"]) for d in PERFORMANCE_STANDARD_DAILY if isinstance(d.get("ip"), (int, float))]
|
||
if not values:
|
||
return None
|
||
return {"min": min(values), "max": max(values), "lastDay": 37}
|
||
|
||
|
||
def get_mortality_cum_std_by_day(day_age: Any) -> float | None:
|
||
std = _daily_row(day_age)
|
||
if std:
|
||
return float(std["mortalityCum_pct"])
|
||
try:
|
||
day = int(day_age)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if day > 37:
|
||
return 4.65 + ((day - 37) * 0.1)
|
||
return None
|
||
|
||
|
||
def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]:
|
||
actual = _as_float(actual_fcr)
|
||
std_fcr = get_fcr_standard_by_day(day_age)
|
||
if actual is None or std_fcr is None:
|
||
return {
|
||
"actual": actual,
|
||
"standard": std_fcr,
|
||
"delta_pct": None,
|
||
"direction": "unknown",
|
||
"status": "unknown",
|
||
"message": "Umur di luar standar tabel CP 707." if std_fcr is None else "FCR aktual tidak tersedia.",
|
||
}
|
||
|
||
delta_pct = round(((actual - std_fcr) / std_fcr) * 100, 1)
|
||
direction = _direction_from_delta(delta_pct)
|
||
|
||
if delta_pct > 10:
|
||
status = "critical"
|
||
message = (
|
||
f"FCR {actual:.2f} melebihi standar CP 707 ({std_fcr:.3f}) sebesar {delta_pct:.1f}% "
|
||
f"pada umur hari ke-{day_age}. Periksa potensi pakan tercecer dan kualitas pakan."
|
||
)
|
||
elif delta_pct > 5:
|
||
status = "warning"
|
||
message = (
|
||
f"FCR {actual:.2f} sedikit di atas standar CP 707 ({std_fcr:.3f}) "
|
||
f"pada umur hari ke-{day_age}. Atur ketinggian piringan pakan dan evaluasi fines dalam pakan."
|
||
)
|
||
elif delta_pct < -5:
|
||
status = "ok"
|
||
message = (
|
||
f"FCR {actual:.2f} lebih baik dari standar CP 707 ({std_fcr:.3f}) "
|
||
f"pada umur hari ke-{day_age}. Pertahankan manajemen pakan."
|
||
)
|
||
else:
|
||
status = "ok"
|
||
message = f"FCR {actual:.2f} sesuai standar CP 707 ({std_fcr:.3f}) pada umur hari ke-{day_age}."
|
||
|
||
return {
|
||
"actual": actual,
|
||
"standard": std_fcr,
|
||
"delta_pct": float(delta_pct),
|
||
"direction": direction,
|
||
"status": status,
|
||
"message": message,
|
||
}
|
||
|
||
|
||
def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]:
|
||
actual = _as_float(actual_bw_g)
|
||
std_bw = get_bw_standard_by_day(day_age)
|
||
if actual is None or std_bw is None:
|
||
return {
|
||
"actual": actual,
|
||
"standard": std_bw,
|
||
"delta_pct": None,
|
||
"direction": "unknown",
|
||
"status": "unknown",
|
||
"message": "Umur di luar standar tabel CP 707." if std_bw is None else "Bobot aktual tidak tersedia.",
|
||
}
|
||
|
||
delta_pct = round(((actual - std_bw) / std_bw) * 100, 1)
|
||
direction = _direction_from_delta(delta_pct)
|
||
|
||
if delta_pct < -15:
|
||
status = "critical"
|
||
message = (
|
||
f"Bobot badan aktual {actual:g}g jauh di bawah standar CP 707 ({std_bw:g}g) "
|
||
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Lakukan grading segera."
|
||
)
|
||
elif delta_pct < -5:
|
||
status = "warning"
|
||
message = (
|
||
f"Bobot badan aktual {actual:g}g sedikit di bawah standar CP 707 ({std_bw:g}g) "
|
||
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan."
|
||
)
|
||
elif delta_pct > 10:
|
||
status = "ok"
|
||
message = (
|
||
f"Bobot badan aktual {actual:g}g melampaui standar CP 707 ({std_bw:g}g) "
|
||
f"pada hari ke-{day_age}. Pertumbuhan sangat baik."
|
||
)
|
||
else:
|
||
status = "ok"
|
||
message = f"Bobot badan aktual {actual:g}g sesuai standar CP 707 ({std_bw:g}g) pada hari ke-{day_age}."
|
||
|
||
return {
|
||
"actual": actual,
|
||
"standard": std_bw,
|
||
"delta_pct": float(delta_pct),
|
||
"direction": direction,
|
||
"status": status,
|
||
"message": message,
|
||
}
|
||
|
||
|
||
def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
|
||
rate = _as_float(mortality_rate_pct)
|
||
std_mortality = get_mortality_cum_std_by_day(day_age)
|
||
|
||
if rate is None:
|
||
return {
|
||
"status": "unknown",
|
||
"direction": "unknown",
|
||
"severity_label": None,
|
||
"actual_pct": None,
|
||
"dilarang_kata": [],
|
||
"stdMortality": std_mortality,
|
||
"message": "Data mortalitas tidak tersedia.",
|
||
}
|
||
|
||
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
|
||
status = "critical"
|
||
direction = "di_atas_standar"
|
||
severity_label = "SANGAT TINGGI"
|
||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||
message = (
|
||
f"Mortalitas kumulatif {rate:.2f}% adalah SANGAT TINGGI — melebihi batas kritis "
|
||
f"CP 707 (>7%). Lakukan nekropsi darurat dan perketat biosekuriti."
|
||
)
|
||
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
||
status = "warning"
|
||
direction = "di_atas_standar"
|
||
severity_label = "TINGGI"
|
||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||
message = (
|
||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI (warning). "
|
||
"Standar CP 707 <5%."
|
||
)
|
||
elif rate >= MORTALITY_THRESHOLDS["normal_pct"]:
|
||
# 5% inclusive through just under warning threshold — still not "rendah"
|
||
status = "warning"
|
||
direction = "di_atas_standar"
|
||
severity_label = "TINGGI"
|
||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali", "sedikit"]
|
||
message = (
|
||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI. "
|
||
"Standar CP 707 <5%."
|
||
)
|
||
elif std_mortality is not None and rate > std_mortality * 1.5:
|
||
status = "warning"
|
||
direction = "di_atas_standar"
|
||
severity_label = "TINGGI"
|
||
dilarang = ["rendah", "normal", "baik", "aman", "terkendali"]
|
||
message = (
|
||
f"Mortalitas kumulatif {rate:.2f}% adalah TINGGI relatif standar harian CP 707 "
|
||
f"({std_mortality:.2f}%) pada hari ke-{day_age}."
|
||
)
|
||
else:
|
||
status = "ok"
|
||
direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar"
|
||
severity_label = "normal/rendah"
|
||
dilarang = ["SANGAT TINGGI", "kritis"]
|
||
message = (
|
||
f"Mortalitas kumulatif {rate:.2f}% normal/rendah — dalam batas CP 707 (<5%)."
|
||
)
|
||
|
||
return {
|
||
"status": status,
|
||
"direction": direction,
|
||
"severity_label": severity_label,
|
||
"actual_pct": rate,
|
||
"dilarang_kata": dilarang,
|
||
"stdMortality": std_mortality,
|
||
"message": message,
|
||
}
|
||
|
||
|
||
def analyze_environment(
|
||
temp_c: Any,
|
||
humidity_pct: Any,
|
||
ammonia_ppm: Any,
|
||
day_age: Any,
|
||
) -> dict[str, Any]:
|
||
temp = _as_float(temp_c)
|
||
humidity = _as_float(humidity_pct)
|
||
ammonia = _as_float(ammonia_ppm)
|
||
|
||
if temp is None:
|
||
return {
|
||
"status": "unknown",
|
||
"direction": "unknown",
|
||
"issues": [],
|
||
"tempStd": get_temp_standard_by_day(day_age),
|
||
"tet": get_tet_by_day(day_age),
|
||
"message": "Data suhu tidak tersedia.",
|
||
}
|
||
|
||
if humidity is None:
|
||
humidity = 65.0
|
||
if ammonia is None:
|
||
ammonia = 0.0
|
||
|
||
temp_std = get_temp_standard_by_day(day_age)
|
||
tet = get_tet_by_day(day_age)
|
||
issues: list[str] = []
|
||
overall_status = "ok"
|
||
|
||
if temp > temp_std["temp_C"] + 4:
|
||
issues.append(
|
||
f"Suhu {temp:.1f}°C jauh di atas target CP 707 ({temp_std['temp_C']}°C) "
|
||
f"untuk umur hari ke-{day_age}. Nyalakan cooling pad/exhaust fan segera."
|
||
)
|
||
overall_status = "critical"
|
||
elif temp > temp_std["temp_C"] + 2:
|
||
issues.append(
|
||
f"Suhu {temp:.1f}°C di atas target CP 707 ({temp_std['temp_C']}°C) "
|
||
f"untuk umur hari ke-{day_age}. Tingkatkan ventilasi."
|
||
)
|
||
if overall_status != "critical":
|
||
overall_status = "warning"
|
||
elif temp < temp_std["temp_C"] - 3:
|
||
issues.append(
|
||
f"Suhu {temp:.1f}°C di bawah target CP 707 ({temp_std['temp_C']}°C). Nyalakan heater."
|
||
)
|
||
if overall_status != "critical":
|
||
overall_status = "warning"
|
||
|
||
if humidity < temp_std["humidity_pct_min"]:
|
||
issues.append(
|
||
f"Kelembapan {humidity:.1f}% di bawah standar CP 707 "
|
||
f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%)."
|
||
)
|
||
if overall_status != "critical":
|
||
overall_status = "warning"
|
||
elif humidity > temp_std["humidity_pct_max"]:
|
||
issues.append(
|
||
f"Kelembapan {humidity:.1f}% di atas standar CP 707 "
|
||
f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%). "
|
||
"Periksa kebocoran nipple dan sekam basah."
|
||
)
|
||
if overall_status != "critical":
|
||
overall_status = "warning"
|
||
|
||
if ammonia > AIR_QUALITY_STANDARD["ammonia"]["critical"]:
|
||
issues.append(
|
||
f"Amonia {ammonia:.1f} ppm di atas batas kritis CP 707 (>25 ppm). "
|
||
"Pertumbuhan ayam menurun. Tingkatkan ventilasi segera dan gemburkan sekam basah."
|
||
)
|
||
overall_status = "critical"
|
||
elif ammonia > AIR_QUALITY_STANDARD["ammonia"]["warning"]:
|
||
issues.append(
|
||
f"Amonia {ammonia:.1f} ppm melebihi batas aman CP 707 (<10 ppm). Tingkatkan sirkulasi udara."
|
||
)
|
||
if overall_status != "critical":
|
||
overall_status = "warning"
|
||
|
||
return {
|
||
"status": overall_status,
|
||
"direction": "sesuai_standar" if overall_status == "ok" else "di_atas_standar",
|
||
"issues": issues,
|
||
"tempStd": temp_std,
|
||
"tet": tet,
|
||
"message": (
|
||
" ".join(issues)
|
||
if issues
|
||
else f"Lingkungan kandang sesuai standar CP 707 untuk umur hari ke-{day_age}."
|
||
),
|
||
}
|
||
|
||
|
||
def build_book_reference_context(day_age: Any) -> str:
|
||
std_bw = get_bw_standard_by_day(day_age)
|
||
std_fcr = get_fcr_standard_by_day(day_age)
|
||
std_mortality = get_mortality_cum_std_by_day(day_age)
|
||
temp_std = get_temp_standard_by_day(day_age)
|
||
tet = get_tet_by_day(day_age)
|
||
|
||
return f"""
|
||
=== REFERENSI STANDAR BUKU CP 707 (SUMBER TUNGGAL - PT CHAROEN POKPHAND INDONESIA) ===
|
||
Umur ayam hari ke-{day_age}:
|
||
- Target bobot badan: {std_bw if std_bw is not None else 'N/A'} gram
|
||
- Target FCR: {std_fcr if std_fcr is not None else 'N/A'}
|
||
- Target mortalitas kumulatif normal: <5% (standar CP 707)
|
||
- Target mortalitas kumulatif standar hari ke-{day_age}: {str(std_mortality) + '%' if std_mortality is not None else 'N/A'}
|
||
- Target suhu kandang: {temp_std['temp_C']}°C
|
||
- Target kelembapan: {temp_std['humidity_pct_min']}%-{temp_std['humidity_pct_max']}%
|
||
- Target Efektif Temperatur (TET): {tet}°C
|
||
- Batas amonia aman: <10 ppm (ideal), >25 ppm = pertumbuhan menurun (dari Lampiran 3 CP 707)
|
||
- Batas CO2 aman: <3000 ppm (>3500 ppm = ascites & kematian tinggi)
|
||
- Konsumsi air normal: 2-2.5x konsumsi pakan
|
||
|
||
CATATAN PENTING:
|
||
- Analisis HANYA berdasarkan standar buku CP 707
|
||
- Jika ada data yang tidak ada di buku CP 707, nyatakan "data tidak tersedia di buku CP 707"
|
||
- Risiko yang disebutkan HARUS berdasarkan data aktual vs standar CP 707
|
||
=== END REFERENSI ===
|
||
"""
|
||
|
||
|
||
def build_cp707_standard_block(context_data: dict[str, Any] | None) -> str:
|
||
"""
|
||
Blok standar CP 707 berlabel satuan untuk hari yang sedang dilihat
|
||
(setara buildCp707StandardBlock di aiInsights.js lama).
|
||
"""
|
||
if not isinstance(context_data, dict):
|
||
return ""
|
||
|
||
raw = None
|
||
for key in ("hari_ke", "hari_terakhir", "currentDay", "dayAge"):
|
||
if context_data.get(key) is not None:
|
||
raw = context_data.get(key)
|
||
break
|
||
day_f = _as_float(raw)
|
||
if day_f is None or day_f <= 0:
|
||
return ""
|
||
day = int(day_f)
|
||
|
||
ip = get_ip_standard_by_day(day)
|
||
ip_range = get_ip_standard_range()
|
||
bw = get_bw_standard_by_day(day)
|
||
fcr = get_fcr_standard_by_day(day)
|
||
mort = get_mortality_cum_std_by_day(day)
|
||
|
||
lines = [
|
||
f"- Bobot badan standar hari ke-{day}: {str(bw) + ' gram' if bw is not None else 'tidak tercantum di buku'}",
|
||
f"- FCR standar hari ke-{day}: {str(fcr) + ' (rasio, tanpa satuan)' if fcr is not None else 'tidak tercantum di buku'}",
|
||
f"- Mortalitas kumulatif standar hari ke-{day}: {str(mort) + ' %' if mort is not None else 'tidak tercantum di buku'}",
|
||
]
|
||
if mort is not None:
|
||
hidup = round((100 - mort) * 100) / 100
|
||
lines.append(
|
||
f"- Persen hidup standar hari ke-{day}: {hidup} % "
|
||
"(turunan langsung dari mortalitas standar di atas — pakai angka ini, jangan menghitung sendiri)"
|
||
)
|
||
else:
|
||
lines.append(f"- Persen hidup standar hari ke-{day}: tidak tersedia")
|
||
|
||
if ip is not None:
|
||
lines.append(f"- IP/EEF standar hari ke-{day}: {ip} (indeks, TANPA satuan)")
|
||
else:
|
||
last_day = ip_range["lastDay"] if ip_range else 37
|
||
rng = f"{ip_range['min']}-{ip_range['max']}" if ip_range else "327-380"
|
||
lines.append(
|
||
f"- IP/EEF standar hari ke-{day}: TIDAK tercantum di buku. "
|
||
f"Kolom IP pada Lampiran 2 hanya terisi hari ke-7 s/d ke-{last_day} dengan rentang {rng}. "
|
||
"Jangan mengarang standar untuk hari ini."
|
||
)
|
||
|
||
joined = "\n".join(lines)
|
||
return f"""
|
||
═══════════════════════════════════════════════
|
||
STANDAR CP 707 UNTUK HARI INI (angka resmi dari Lampiran 2 buku)
|
||
{joined}
|
||
|
||
ATURAN WAJIB saat membandingkan dengan standar:
|
||
- Pakai HANYA angka di blok ini sebagai standar. DILARANG mengambil angka
|
||
standar dari tabel mentah di kutipan buku di bawah — kolomnya tidak berjudul,
|
||
dan angka pakan (gram) sering tertukar menjadi standar IP/EEF.
|
||
- Sebutkan satuan dengan benar: gram untuk bobot dan pakan, persen untuk
|
||
mortalitas, dan IP/EEF adalah indeks TANPA satuan.
|
||
- Jika standar untuk suatu metrik tidak tercantum, tulis "standar tidak
|
||
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
|
||
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
|
||
menghitung sendiri atau mengarang angka yang tidak ada di sana.
|
||
- Angka di blok ini adalah STANDAR saja (bukan nilai aktual kandang).
|
||
Jika bobot/mortalitas aktual tidak ada di Data Halaman / GRADED FACTS,
|
||
tulis "data tidak tersedia" — JANGAN menyalin angka standar sebagai aktual.
|
||
═══════════════════════════════════════════════"""
|
||
|
||
|
||
def _nested_get(obj: Any, *path: str) -> Any:
|
||
cur = obj
|
||
for key in path:
|
||
if not isinstance(cur, dict):
|
||
return None
|
||
cur = cur.get(key)
|
||
return cur
|
||
|
||
|
||
def _first_number(*candidates: Any) -> float | None:
|
||
for value in candidates:
|
||
n = _as_float(value)
|
||
if n is not None:
|
||
return n
|
||
return None
|
||
|
||
|
||
def _resolve_day_age(context_pack: dict[str, Any]) -> int | None:
|
||
day = _first_number(
|
||
context_pack.get("hari_ke"),
|
||
_nested_get(context_pack, "metadata", "currentDay"),
|
||
_nested_get(context_pack, "cycle", "currentAgeDay"),
|
||
context_pack.get("ageDay"),
|
||
context_pack.get("umurHari"),
|
||
context_pack.get("currentDay"),
|
||
context_pack.get("dayAge"),
|
||
)
|
||
if day is None or day <= 0:
|
||
return None
|
||
return int(day)
|
||
|
||
|
||
def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
|
||
grams = _first_number(
|
||
context_pack.get("bobot_avg_gram"),
|
||
context_pack.get("averageWeight"),
|
||
context_pack.get("bobot_rata_rata_gram"),
|
||
context_pack.get("bobot_iot_gram_terakhir"),
|
||
context_pack.get("bobot_iot_gram"),
|
||
_nested_get(context_pack, "weightStats", "averageWeight", "value"),
|
||
_nested_get(context_pack, "weight", "current", "averageWeight"),
|
||
context_pack.get("berat_rata_rata"),
|
||
)
|
||
if grams is not None:
|
||
return grams
|
||
|
||
kg = _first_number(
|
||
context_pack.get("bobot_iot_kg_terakhir"),
|
||
context_pack.get("bobot_iot_kg"),
|
||
)
|
||
if kg is not None:
|
||
return kg * 1000.0
|
||
return None
|
||
|
||
|
||
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
|
||
"""Cumulative mortality % from explicit fields, % hidup, or kematian/DOC."""
|
||
direct = _first_number(
|
||
context_pack.get("mortalitas_kumulatif_pct"),
|
||
context_pack.get("mortalitas_kumulatif_persen"),
|
||
context_pack.get("mortalitas_persen"),
|
||
context_pack.get("mortalityRate"),
|
||
context_pack.get("mortality_rate_pct"),
|
||
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
|
||
)
|
||
if direct is not None:
|
||
return direct
|
||
|
||
persen_hidup = _first_number(context_pack.get("persen_hidup"))
|
||
if persen_hidup is not None:
|
||
return round((100.0 - persen_hidup) * 100) / 100
|
||
|
||
death = _first_number(
|
||
context_pack.get("kematian_kumulatif_ekor"),
|
||
context_pack.get("mortalitas_kumulatif_ekor"),
|
||
)
|
||
doc = _first_number(
|
||
context_pack.get("doc_in_ekor"),
|
||
context_pack.get("doc_in"),
|
||
context_pack.get("initialPopulation"),
|
||
)
|
||
if death is not None and doc is not None and doc > 0:
|
||
return round((death / doc) * 10000) / 100
|
||
return None
|
||
|
||
|
||
def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]:
|
||
for key in ("iotPanel", "panel_iot", "telemetry", "iotData", "iot"):
|
||
raw = context_pack.get(key)
|
||
if isinstance(raw, dict):
|
||
display = raw.get("display")
|
||
if isinstance(display, dict):
|
||
return display
|
||
return raw
|
||
# Flat InsightContext fields (rebuild)
|
||
return context_pack
|
||
|
||
|
||
def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[str, Any]:
|
||
"""Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama."""
|
||
pack = context_pack if isinstance(context_pack, dict) else {}
|
||
day_age = _resolve_day_age(pack)
|
||
result: dict[str, Any] = {"dayAge": day_age, "analyses": {}}
|
||
|
||
actual_bw = _resolve_bw_grams(pack)
|
||
if day_age is not None:
|
||
# Always emit a bw block (unknown if missing) so the model cannot treat
|
||
# day-standard BW as the farm's actual weight.
|
||
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
|
||
|
||
actual_fcr = _first_number(
|
||
pack.get("fcr_terakhir"),
|
||
pack.get("fcr"),
|
||
_nested_get(pack, "fcr_eef", "fcr", "actual"),
|
||
_nested_get(pack, "weight", "current", "fcr"),
|
||
_nested_get(pack, "fcrSummary", "current"),
|
||
)
|
||
if actual_fcr is not None and day_age is not None:
|
||
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
|
||
|
||
mortality_rate = _resolve_mortality(pack)
|
||
# Always emit mortality (unknown if missing) — silent omission caused the LLM
|
||
# to narrate STANDAR CP 707 mortality as if it were the farm actual.
|
||
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
|
||
|
||
iot = _resolve_iot(pack)
|
||
temp = _first_number(
|
||
iot.get("suhu_rata_rata_C"),
|
||
iot.get("avgTemp"),
|
||
pack.get("suhu_rata_rata_C"),
|
||
)
|
||
hum = _first_number(
|
||
iot.get("kelembapan_persen"),
|
||
iot.get("humidity"),
|
||
pack.get("kelembapan_persen"),
|
||
)
|
||
ammonia = _first_number(
|
||
iot.get("amonia_ppm"),
|
||
iot.get("ammonia"),
|
||
pack.get("amonia_ppm"),
|
||
)
|
||
if temp is not None and day_age is not None:
|
||
result["analyses"]["environment"] = analyze_environment(
|
||
temp,
|
||
hum if hum is not None else 65.0,
|
||
ammonia if ammonia is not None else 0.0,
|
||
day_age,
|
||
)
|
||
|
||
return result
|