Files
dashboard-cpsp/backend/apps/operations/services/cp707_knowledge.py
T

747 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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",
"stdMortality": std_mortality,
"message": "Data mortalitas tidak tersedia.",
}
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
status = "critical"
direction = "di_atas_standar"
message = (
f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). "
"Lakukan nekropsi darurat dan perketat biosekuriti."
)
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
status = "warning"
direction = "di_atas_standar"
message = f"Mortalitas kumulatif {rate:.2f}% perlu diwaspadai. Standar CP 707 <5%."
elif std_mortality is not None and rate > std_mortality * 1.5:
status = "warning"
direction = "di_atas_standar"
message = (
f"Mortalitas {rate:.2f}% melebihi standar kumulatif 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"
message = f"Mortalitas {rate:.2f}% dalam batas normal CP 707 (<5%)."
return {
"status": status,
"direction": direction,
"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.
═══════════════════════════════════════════════"""
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_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:
return _first_number(
context_pack.get("mortalitas_kumulatif_pct"),
context_pack.get("mortalitas_persen"),
context_pack.get("mortalityRate"),
context_pack.get("mortality_rate_pct"),
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
_nested_get(context_pack, "chickenCounting", "mortality", "mortalityCount"),
)
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 actual_bw is not None and day_age is not None:
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)
if mortality_rate is not None:
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