import type { InsightContext } from '../types/api.ts'; import { getTargetWeightForAge } from './weight.ts'; const num = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; /** Round to at most 2 decimal places (avoids float noise like 89.50399999999999). */ const round2 = (value: number | null): number | null => value === null ? null : Math.round(value * 100) / 100; /** Positive finite number only — 0 / negative treated as missing sensor data. */ const positiveNum = (value: unknown): number | null => { const n = num(value); return n != null && n > 0 ? n : null; }; const requireKandangId = (kandangId: number | null | undefined, topic: string): number => { if (kandangId == null || !Number.isFinite(kandangId) || kandangId <= 0) { throw new Error(`kandangId wajib — insight ${topic} tidak boleh untuk semua kandang`); } return kandangId; }; const dayOf = (row: { day?: unknown; hari?: unknown; age?: unknown }): number | null => num(row.day) ?? num(row.hari) ?? num(row.age); // ─── FCR ───────────────────────────────────────────────────────────────────── export type FcrInsightRow = { day?: number; hari?: number; feedTotal?: number | null; pakan_karung?: number | null; iotWeight?: number | null; /** IoT average bird weight in grams (same as KPI `iot_weight`). */ bobot_iot_gram?: number | null; /** @deprecated use bobot_iot_gram — legacy name wrongly implied kg */ bobot_iot_kg?: number | null; chickenLife?: number | null; ayam_hidup_ekor?: number | null; harvestWeightKg?: number | null; beratPanen_kg?: number | null; fcr?: number | null; fcr_aktual?: number | null; [key: string]: unknown; }; export type BuildFcrInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; fcrRows?: FcrInsightRow[]; /** Optional precomputed latest scalars (units in field names). */ fcr_terakhir?: number | null; pakan_karung_terakhir?: number | null; bobot_iot_gram_terakhir?: number | null; /** @deprecated use bobot_iot_gram_terakhir */ bobot_iot_kg_terakhir?: number | null; ayam_hidup_ekor?: number | null; beratPanen_kg?: number | null; }; /** * Build curated FCR InsightContext for one kandang. * Numeric fields carry units in their names so the model cannot invent units. * IoT weight is grams (matches FCR page StatCards / KPI `iot_weight`). */ export function buildFcrInsightContext(args: BuildFcrInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'FCR'); const rows = Array.isArray(args.fcrRows) ? args.fcrRows : []; const tren = rows .map((row) => { const hari = dayOf(row); if (hari === null) return null; // Prefer explicit grams; legacy `bobot_iot_kg` on rows was often mislabeled grams. const bobotGram = num(row.iotWeight) ?? num(row.bobot_iot_gram) ?? num(row.bobot_iot_kg); return { hari, fcr_aktual: num(row.fcr) ?? num(row.fcr_aktual), pakan_karung: num(row.feedTotal) ?? num(row.pakan_karung), bobot_iot_gram: bobotGram, ayam_hidup_ekor: num(row.chickenLife) ?? num(row.ayam_hidup_ekor), beratPanen_kg: num(row.harvestWeightKg) ?? num(row.beratPanen_kg), }; }) .filter((row): row is NonNullable => row !== null) .sort((a, b) => a.hari - b.hari); const latest = [...tren].reverse().find((row) => row.fcr_aktual != null) ?? tren[tren.length - 1]; const fcr_terakhir = args.fcr_terakhir ?? latest?.fcr_aktual ?? null; const pakan_karung_terakhir = args.pakan_karung_terakhir ?? latest?.pakan_karung ?? null; const bobot_iot_gram_terakhir = args.bobot_iot_gram_terakhir ?? args.bobot_iot_kg_terakhir ?? latest?.bobot_iot_gram ?? null; const ayam_hidup_ekor = args.ayam_hidup_ekor ?? latest?.ayam_hidup_ekor ?? null; const beratPanen_kg = args.beratPanen_kg ?? latest?.beratPanen_kg ?? null; const hari_ke = args.hari_ke ?? latest?.hari ?? null; return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke, totalDays: args.totalDays ?? null, fcr_terakhir, pakan_karung_terakhir, bobot_iot_gram_terakhir, ayam_hidup_ekor, beratPanen_kg, tren_fcr_harian: tren, tren_7_hari_terakhir: tren.slice(-7).map((r) => ({ hari: r.hari, fcr: r.fcr_aktual, pakan: r.pakan_karung, bobot_gram: r.bobot_iot_gram, ayam: r.ayam_hidup_ekor, })), }; } // ─── EEF ───────────────────────────────────────────────────────────────────── export type EefInsightRow = { day?: number; hari?: number; eef?: number | null; fcr?: number | null; chickenLifePercentage?: number | null; persen_hidup?: number | null; averageHarvestBw?: number | null; bw_panen_gram?: number | null; averageHarvestDay?: number | null; [key: string]: unknown; }; export type BuildEefInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; eefRows?: EefInsightRow[]; eef_terakhir?: number | null; fcr_terakhir?: number | null; persen_hidup?: number | null; bw_panen_gram?: number | null; umur_panen_rata_hari?: number | null; }; export function buildEefInsightContext(args: BuildEefInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'EEF'); const rows = Array.isArray(args.eefRows) ? args.eefRows : []; const tren = rows .map((row) => { const hari = dayOf(row); if (hari === null) return null; return { hari, eef: num(row.eef), fcr: num(row.fcr), persen_hidup: round2(num(row.chickenLifePercentage) ?? num(row.persen_hidup)), bw_panen: num(row.averageHarvestBw) ?? num(row.bw_panen_gram), umur_panen_hari: num(row.averageHarvestDay), }; }) .filter((row): row is NonNullable => row !== null) .sort((a, b) => a.hari - b.hari); const latest = [...tren].reverse().find((row) => row.eef != null) ?? tren[tren.length - 1]; return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke: args.hari_ke ?? latest?.hari ?? null, totalDays: args.totalDays ?? null, eef_terakhir: args.eef_terakhir ?? latest?.eef ?? null, fcr_terakhir: args.fcr_terakhir ?? latest?.fcr ?? null, persen_hidup: round2(args.persen_hidup ?? latest?.persen_hidup ?? null), bw_panen_gram: args.bw_panen_gram ?? latest?.bw_panen ?? null, umur_panen_rata_hari: args.umur_panen_rata_hari ?? latest?.umur_panen_hari ?? null, tren_eef_harian: tren, tren_7_hari_terakhir: tren.slice(-7), }; } // ─── Weight ────────────────────────────────────────────────────────────────── export type WeightInsightRow = { day?: number; hari?: number; age?: number; average_weight?: number | null; bobot_gram?: number | null; target_gram?: number | null; uniformity?: number | null; average_daily_gain?: number | null; adg_gram_per_hari?: number | null; [key: string]: unknown; }; export type BuildWeightInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; weightRows?: WeightInsightRow[]; averageWeight?: number | null; targetWeight?: number | null; uniformity?: number | null; adg?: number | null; }; export function buildWeightInsightContext(args: BuildWeightInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'berat ayam'); const totalDays = args.totalDays ?? null; const rows = Array.isArray(args.weightRows) ? args.weightRows : []; const tren = rows .map((row) => { const hari = dayOf(row); if (hari === null) return null; const bobot = num(row.average_weight) ?? num(row.bobot_gram); const target = num(row.target_gram) ?? (totalDays != null ? getTargetWeightForAge(hari, totalDays) : null); return { hari, bobot_gram: bobot != null && bobot > 0 ? bobot : null, target_gram: target, adg_gram_per_hari: num(row.average_daily_gain) ?? num(row.adg_gram_per_hari), uniformity_persen: num(row.uniformity), }; }) .filter((row): row is NonNullable => row !== null) .sort((a, b) => a.hari - b.hari); const latest = [...tren].reverse().find((row) => row.bobot_gram != null) ?? tren[tren.length - 1]; return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke: args.hari_ke ?? latest?.hari ?? null, totalDays, averageWeight: args.averageWeight ?? latest?.bobot_gram ?? null, targetWeight: args.targetWeight ?? latest?.target_gram ?? null, uniformity: args.uniformity ?? latest?.uniformity_persen ?? null, adg: args.adg ?? latest?.adg_gram_per_hari ?? null, tren_harian: tren, }; } // ─── IoT panel ─────────────────────────────────────────────────────────────── export type BuildIotInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; suhu_rata_rata_C?: number | null; experience_suhu_C?: number | null; kelembapan_persen?: number | null; kecepatan_angin_mPerDetik?: number | null; konsumsi_air_L?: number | null; setpoint_C?: number | null; suhu_min_C?: number | null; suhu_max_C?: number | null; experience_suhu_min_C?: number | null; experience_suhu_max_C?: number | null; kelembapan_min_persen?: number | null; kelembapan_max_persen?: number | null; kecepatan_angin_min_mPerDetik?: number | null; kecepatan_angin_max_mPerDetik?: number | null; waktu_data?: string | null; }; export function buildIotInsightContext(args: BuildIotInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'panel IoT'); return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke: args.hari_ke ?? null, totalDays: args.totalDays ?? null, suhu_rata_rata_C: args.suhu_rata_rata_C ?? null, experience_suhu_C: args.experience_suhu_C ?? null, kelembapan_persen: args.kelembapan_persen ?? null, kecepatan_angin_mPerDetik: args.kecepatan_angin_mPerDetik ?? null, konsumsi_air_L: args.konsumsi_air_L ?? null, setpoint_C: args.setpoint_C ?? null, suhu_min_C: args.suhu_min_C ?? null, suhu_max_C: args.suhu_max_C ?? null, experience_suhu_min_C: args.experience_suhu_min_C ?? null, experience_suhu_max_C: args.experience_suhu_max_C ?? null, kelembapan_min_persen: args.kelembapan_min_persen ?? null, kelembapan_max_persen: args.kelembapan_max_persen ?? null, kecepatan_angin_min_mPerDetik: args.kecepatan_angin_min_mPerDetik ?? null, kecepatan_angin_max_mPerDetik: args.kecepatan_angin_max_mPerDetik ?? null, waktu_data: args.waktu_data ?? null, }; } // ─── Feed sack (hitung karung) ─────────────────────────────────────────────── export type FeedSackInsightRow = { day?: number; hari?: number; masuk?: number | null; dituang?: number | null; keluar?: number | null; masukManual?: number | null; dituangManual?: number | null; in_today?: number | null; feed_use_today?: number | null; out_today?: number | null; [key: string]: unknown; }; export type BuildFeedSackInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; dailyRows?: FeedSackInsightRow[]; saldo_awal_karung?: number | null; total_karung_masuk_iot?: number | null; total_karung_dituang_iot?: number | null; total_karung_keluar_iot?: number | null; total_karung_masuk_manual?: number | null; total_karung_dituang_manual?: number | null; rata_karung_per_hari?: number | null; }; export function buildFeedSackInsightContext(args: BuildFeedSackInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'hitung karung'); const rows = Array.isArray(args.dailyRows) ? args.dailyRows : []; const tren = rows .map((row) => { const hari = dayOf(row); if (hari === null) return null; return { hari, masuk: num(row.masuk) ?? num(row.in_today), dituang: num(row.dituang) ?? num(row.feed_use_today), keluar: num(row.keluar) ?? num(row.out_today), masukManual: num(row.masukManual), dituangManual: num(row.dituangManual), }; }) .filter((row): row is NonNullable => row !== null) .sort((a, b) => a.hari - b.hari); const sumField = (field: 'masuk' | 'dituang' | 'keluar' | 'masukManual' | 'dituangManual') => { if (tren.length === 0) return null; let total = 0; let any = false; for (const row of tren) { const v = row[field]; if (v != null) { total += v; any = true; } } return any ? total : null; }; const total_dituang_iot = args.total_karung_dituang_iot ?? sumField('dituang'); const hari_ke = args.hari_ke ?? (tren.length > 0 ? tren[tren.length - 1]!.hari : null); const rata = args.rata_karung_per_hari ?? (total_dituang_iot != null && hari_ke != null && hari_ke > 0 ? Math.round((total_dituang_iot / hari_ke) * 10) / 10 : null); return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke, totalDays: args.totalDays ?? null, saldo_awal_karung: args.saldo_awal_karung ?? null, total_karung_masuk_iot: args.total_karung_masuk_iot ?? sumField('masuk'), total_karung_dituang_iot: total_dituang_iot, total_karung_keluar_iot: args.total_karung_keluar_iot ?? sumField('keluar'), total_karung_masuk_manual: args.total_karung_masuk_manual ?? sumField('masukManual'), total_karung_dituang_manual: args.total_karung_dituang_manual ?? sumField('dituangManual'), rata_karung_per_hari: rata, tren_harian: tren, }; } // ─── Counting (hitung ayam) ────────────────────────────────────────────────── export type CountingInsightRow = { day?: number; hari?: number; stockAkhir?: number | null; populasi?: number | null; mati?: number | null; panen?: number | null; beratPanen?: number | null; /** Running totals when the page already computed them (preferred). */ mortalityTotal?: number | null; harvestTotal?: number | null; harvestWeightTotal?: number | null; chickenCount?: number | null; [key: string]: unknown; }; export type BuildCountingInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; dailyRows?: CountingInsightRow[]; populasi_awal_ekor?: number | null; populasi_kini_ekor?: number | null; mortalitas_kumulatif_ekor?: number | null; /** Null when DOC-in unknown — never invent 0%. */ mortalitas_persen?: number | null; panen_kumulatif_ekor?: number | null; berat_panen_kumulatif_kg?: number | null; }; export function buildCountingInsightContext(args: BuildCountingInsightContextArgs): InsightContext { const kandangId = requireKandangId(args.kandangId, 'hitung ayam'); const rows = Array.isArray(args.dailyRows) ? args.dailyRows : []; const populasi_awal = args.populasi_awal_ekor ?? null; // Sort first, then accumulate — unsorted input would corrupt running totals. const ordered = [...rows] .map((row) => { const hari = dayOf(row); if (hari === null) return null; return { row, hari }; }) .filter((item): item is { row: CountingInsightRow; hari: number } => item !== null) .sort((a, b) => a.hari - b.hari); let runMort = 0; let runPanen = 0; let runBerat = 0; const tren = ordered.map(({ row, hari }) => { const mati = num(row.mati) ?? 0; const panen = num(row.panen) ?? 0; const berat = num(row.beratPanen) ?? 0; runMort += mati; runPanen += panen; runBerat += berat; const mortCum = num(row.mortalityTotal) ?? runMort; const panenCum = num(row.harvestTotal) ?? runPanen; const beratCum = num(row.harvestWeightTotal) ?? runBerat; const mortPct = populasi_awal != null && populasi_awal > 0 ? Math.round((mortCum / populasi_awal) * 10000) / 100 : null; return { hari, populasi: num(row.stockAkhir) ?? num(row.populasi), mati_ekor: num(row.mati), panen_ekor: num(row.panen), chicken_count_iot_ekor: num(row.chickenCount), mortalitas_kumulatif_ekor: mortCum, panen_kumulatif_ekor: panenCum, berat_panen_kumulatif_kg: Math.round(beratCum * 100) / 100, mortalitas_persen: mortPct, }; }); const latest = tren[tren.length - 1]; const mortalitas_ekor = args.mortalitas_kumulatif_ekor ?? latest?.mortalitas_kumulatif_ekor ?? null; const mortalitas_persen = args.mortalitas_persen !== undefined ? args.mortalitas_persen : (latest?.mortalitas_persen ?? (populasi_awal != null && populasi_awal > 0 && mortalitas_ekor != null ? Math.round((mortalitas_ekor / populasi_awal) * 10000) / 100 : null)); return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke: args.hari_ke ?? latest?.hari ?? null, totalDays: args.totalDays ?? null, populasi_awal_ekor: populasi_awal, populasi_kini_ekor: args.populasi_kini_ekor ?? latest?.populasi ?? null, mortalitas_kumulatif_ekor: mortalitas_ekor, mortalitas_persen, panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? latest?.panen_kumulatif_ekor ?? null, berat_panen_kumulatif_kg: args.berat_panen_kumulatif_kg ?? latest?.berat_panen_kumulatif_kg ?? null, tren_populasi_harian: tren, }; } // ─── Dashboard ─────────────────────────────────────────────────────────────── export type BuildDashboardInsightContextArgs = { kandangId: number; kandangName?: string; cycleId?: number; hari_ke?: number | null; totalDays?: number | null; fcr_terakhir?: number | null; eef_terakhir?: number | null; bobot_avg_gram?: number | null; mortalitas_hari_ini_ekor?: number | null; /** Cumulative mortality % — preferred by the backend grader over deriving from % hidup. */ mortalitas_kumulatif_persen?: number | null; karung_dituang_hari_ini?: number | null; pakan_kumulatif_karung?: number | null; ayam_hidup_ekor?: number | null; persen_hidup?: number | null; /** Muted-row / detail scalars for LLM analysis. */ doc_in_ekor?: number | null; kematian_kumulatif_ekor?: number | null; panen_kumulatif_ekor?: number | null; tonase_panen_kg?: number | null; uniformity_persen?: number | null; }; export function buildDashboardInsightContext( args: BuildDashboardInsightContextArgs ): InsightContext { const kandangId = requireKandangId(args.kandangId, 'dashboard'); return { kandangId, kandangName: args.kandangName, cycleId: args.cycleId, hari_ke: args.hari_ke ?? null, totalDays: args.totalDays ?? null, fcr_terakhir: args.fcr_terakhir != null && args.fcr_terakhir > 0 ? args.fcr_terakhir : null, eef_terakhir: args.eef_terakhir != null && args.eef_terakhir > 0 ? args.eef_terakhir : null, bobot_avg_gram: args.bobot_avg_gram != null && args.bobot_avg_gram > 0 ? args.bobot_avg_gram : null, mortalitas_hari_ini_ekor: args.mortalitas_hari_ini_ekor ?? null, mortalitas_kumulatif_persen: args.mortalitas_kumulatif_persen ?? null, karung_dituang_hari_ini: args.karung_dituang_hari_ini ?? null, pakan_kumulatif_karung: args.pakan_kumulatif_karung ?? null, ayam_hidup_ekor: args.ayam_hidup_ekor ?? null, persen_hidup: args.persen_hidup ?? null, doc_in_ekor: args.doc_in_ekor ?? null, kematian_kumulatif_ekor: args.kematian_kumulatif_ekor ?? null, panen_kumulatif_ekor: args.panen_kumulatif_ekor ?? null, tonase_panen_kg: args.tonase_panen_kg ?? null, uniformity_persen: args.uniformity_persen ?? null, }; } /** Day-indexed weight point for dashboard insight chart (display only). */ export type DashboardInsightWeightPoint = { hari: number; actual: number | null; target: number | null; }; /** Day-indexed KPI snapshot so the card can scope tiles to a selected day. */ export type DashboardInsightKpiPoint = { hari: number; fcr: number | null; eef: number | null; ayam_hidup_ekor: number | null; mortalitas_kumulatif_persen: number | null; bobot_avg_gram: number | null; kematian_kumulatif_ekor: number | null; panen_kumulatif_ekor: number | null; tonase_panen_kg: number | null; uniformity_persen: number | null; pakan_kumulatif_karung: number | null; }; /** * Display-only metrics for main-dashboard AI Insight visuals. * Kept separate from LLM `InsightContext` so chart series are not sent to the model. */ export type DashboardInsightMetrics = { hari_ke: number | null; fcr: number | null; eef: number | null; ayam_hidup_ekor: number | null; mortalitas_kumulatif_persen: number | null; bobot_avg_gram: number | null; /** Cycle DOC intake — constant across days. */ doc_in_ekor: number | null; kematian_kumulatif_ekor: number | null; panen_kumulatif_ekor: number | null; tonase_panen_kg: number | null; uniformity_persen: number | null; pakan_kumulatif_karung: number | null; weightSeries: DashboardInsightWeightPoint[]; kpiSeries: DashboardInsightKpiPoint[]; }; export type BuildDashboardInsightMetricsArgs = { /** KPI rows already filtered by visibility / anchor (newest-first or any order). */ kpis: Array<{ age?: number | null; date?: string | null; fcr?: number | null; eef?: number | null; chicken_life?: number | null; chicken_life_percentage?: number | null; iot_weight?: number | null; mortality_total?: number | null; harvest_total?: number | null; harvest_weight_total?: number | null; feed_total?: number | null; }>; /** Weight rows already filtered by visibility / anchor. */ weights: Array<{ age?: number | null; date?: string | null; average_weight?: number | null; doc_weight?: number | null; uniformity?: number | null; }>; /** Fallback day label when no KPI age is present. */ currentDay?: number | null; /** DOC chick-in count for the cycle (muted row). */ docInCount?: number | null; /** Optional override when KPI feed_total is missing (e.g. karung.feed_use_total). */ pakanKumulatifFallback?: number | null; }; const mortalitasFromPersenHidup = (persenHidup: number | null): number | null => { if (persenHidup === null || !Number.isFinite(persenHidup)) return null; return Math.round((100 - persenHidup) * 100) / 100; }; /** * Build display metrics for dashboard AI Insight heroes / tiles / weight chart. * Callers must pre-filter rows with `throughVisibleDate` (and anchor) like Dashboard does. */ export function buildDashboardInsightMetrics( args: BuildDashboardInsightMetricsArgs ): DashboardInsightMetrics { const weightByAge = new Map< number, { actual: number | null; target: number | null; uniformity: number | null } >(); for (const row of args.weights) { const hari = num(row.age); if (hari === null || hari <= 0) continue; const actual = positiveNum(row.average_weight); const doc = num(row.doc_weight) ?? 40; const target = getTargetWeightForAge(hari, doc); weightByAge.set(hari, { actual, target, uniformity: num(row.uniformity), }); } const weightSeries: DashboardInsightWeightPoint[] = [...weightByAge.entries()] .map(([hari, point]) => ({ hari, actual: point.actual, target: point.target })) .sort((a, b) => a.hari - b.hari); const kpiByAge = new Map(); for (const row of args.kpis) { const hari = num(row.age); if (hari === null || hari <= 0) continue; const iotWeight = positiveNum(row.iot_weight); const weightFromSeries = weightByAge.get(hari)?.actual ?? null; const bobot = iotWeight !== null ? iotWeight : weightFromSeries; const persenHidup = num(row.chicken_life_percentage); const uniformity = weightByAge.get(hari)?.uniformity ?? null; kpiByAge.set(hari, { hari, fcr: positiveNum(row.fcr), eef: positiveNum(row.eef), // chicken_life is DOC − mortality; in-house remaining subtracts harvest. ayam_hidup_ekor: (() => { const life = num(row.chicken_life); if (life == null) return null; return Math.max(0, life - (num(row.harvest_total) ?? 0)); })(), mortalitas_kumulatif_persen: mortalitasFromPersenHidup(persenHidup), bobot_avg_gram: bobot, kematian_kumulatif_ekor: num(row.mortality_total), panen_kumulatif_ekor: num(row.harvest_total), // KPI harvest_weight_total is grams; insight tile labels kg. tonase_panen_kg: (() => { const g = num(row.harvest_weight_total); return g == null ? null : g / 1000; })(), uniformity_persen: uniformity, pakan_kumulatif_karung: num(row.feed_total), }); } const kpiSeries = [...kpiByAge.values()].sort((a, b) => a.hari - b.hari); const latestKpi = kpiSeries.length > 0 ? kpiSeries[kpiSeries.length - 1]! : null; const latestWeight = weightSeries.length > 0 ? weightSeries[weightSeries.length - 1]! : null; const latestWeightMeta = latestWeight != null ? weightByAge.get(latestWeight.hari) : undefined; const bobotLatest = latestKpi?.bobot_avg_gram ?? (latestWeight?.actual != null && latestWeight.actual > 0 ? latestWeight.actual : null); const hari_ke = latestKpi?.hari ?? latestWeight?.hari ?? (num(args.currentDay) != null && (args.currentDay as number) > 0 ? num(args.currentDay) : null); const docIn = num(args.docInCount) != null && (args.docInCount as number) > 0 ? num(args.docInCount) : null; return { hari_ke, fcr: latestKpi?.fcr ?? null, eef: latestKpi?.eef ?? null, ayam_hidup_ekor: latestKpi?.ayam_hidup_ekor ?? null, mortalitas_kumulatif_persen: latestKpi?.mortalitas_kumulatif_persen ?? null, bobot_avg_gram: bobotLatest, doc_in_ekor: docIn, kematian_kumulatif_ekor: latestKpi?.kematian_kumulatif_ekor ?? null, panen_kumulatif_ekor: latestKpi?.panen_kumulatif_ekor ?? null, tonase_panen_kg: latestKpi?.tonase_panen_kg ?? null, uniformity_persen: latestKpi?.uniformity_persen ?? latestWeightMeta?.uniformity ?? null, pakan_kumulatif_karung: latestKpi?.pakan_kumulatif_karung ?? num(args.pakanKumulatifFallback) ?? null, weightSeries, kpiSeries, }; } /** One IoT day aggregate for end-cycle environment rollups. */ export type EndCycleIotDayAggregate = { date: string; hari: number | null; avg_temp_C: number | null; avg_humidity_pct: number | null; sample_count: number; hours_temp_below: number; hours_humidity_above: number; }; export type EndCycleWeeklySummary = { minggu_ke: number; jumlah_hari_data: number; fcr_rata_rasio: number | null; fcr_akhir_rasio: number | null; bobot_akhir_gram: number | null; pakan_akhir_karung: number | null; mortalitas_kumulatif_persen: number | null; mortalitas_delta_persen: number | null; jam_suhu_di_bawah_standar: number; jam_kelembapan_di_atas_standar: number; jam_amonia_tinggi: number; }; export type EndCyclePhaseSummary = { jumlah_minggu: number; mortalitas_delta_persen: number | null; fcr_akhir_rasio: number | null; }; export type EndCycleRollups = { weekly_summaries: EndCycleWeeklySummary[]; phase_summaries: Partial>; mati_ekor: number | null; panen_ekor: number | null; saldo_ekor: number | null; doc_in_ekor: number | null; tren_kpi_harian: DashboardInsightKpiPoint[]; }; const avg = (values: number[]): number | null => values.length ? Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 1000) / 1000 : null; /** * Build weekly/phase rollups for end-cycle insight from dashboard KPI series. * Optional IoT day aggregates enrich temperature/humidity out-of-range hours. */ export function buildEndCycleRollups( metrics: DashboardInsightMetrics, iotDays: EndCycleIotDayAggregate[] = [] ): EndCycleRollups { const byWeek = new Map(); for (const row of metrics.kpiSeries) { if (!row.hari || row.hari <= 0) continue; const week = Math.max(1, Math.ceil(row.hari / 7)); const list = byWeek.get(week) ?? []; list.push(row); byWeek.set(week, list); } const iotByWeek = new Map(); for (const day of iotDays) { const hari = day.hari; if (hari == null || hari <= 0) continue; const week = Math.max(1, Math.ceil(hari / 7)); const list = iotByWeek.get(week) ?? []; list.push(day); iotByWeek.set(week, list); } const weekly_summaries: EndCycleWeeklySummary[] = []; let prevMort: number | null = null; for (const week of [...byWeek.keys()].sort((a, b) => a - b)) { const rows = [...(byWeek.get(week) ?? [])].sort((a, b) => a.hari - b.hari); const fcrs = rows.map((r) => r.fcr).filter((v): v is number => v != null); const bws = rows.map((r) => r.bobot_avg_gram).filter((v): v is number => v != null); const feeds = rows.map((r) => r.pakan_kumulatif_karung).filter((v): v is number => v != null); const morts = rows .map((r) => r.mortalitas_kumulatif_persen) .filter((v): v is number => v != null); const mortEnd = morts.length ? morts[morts.length - 1]! : null; const mortDelta = mortEnd == null ? null : Math.round((mortEnd - (prevMort ?? 0)) * 1000) / 1000; if (mortEnd != null) prevMort = mortEnd; const iotRows = iotByWeek.get(week) ?? []; weekly_summaries.push({ minggu_ke: week, jumlah_hari_data: rows.length, fcr_rata_rasio: avg(fcrs), fcr_akhir_rasio: fcrs.length ? fcrs[fcrs.length - 1]! : null, bobot_akhir_gram: bws.length ? bws[bws.length - 1]! : null, pakan_akhir_karung: feeds.length ? feeds[feeds.length - 1]! : null, mortalitas_kumulatif_persen: mortEnd, mortalitas_delta_persen: mortDelta, jam_suhu_di_bawah_standar: iotRows.reduce((s, d) => s + (d.hours_temp_below || 0), 0), jam_kelembapan_di_atas_standar: iotRows.reduce( (s, d) => s + (d.hours_humidity_above || 0), 0 ), jam_amonia_tinggi: 0, }); } const phase_summaries: EndCycleRollups['phase_summaries'] = {}; const bucket = (phase: 'brooding' | 'growth' | 'finisher', weeks: number[]) => { const rows = weekly_summaries.filter((w) => weeks.includes(w.minggu_ke)); if (!rows.length) return; const deltas = rows.map((r) => r.mortalitas_delta_persen).filter((v): v is number => v != null); phase_summaries[phase] = { jumlah_minggu: rows.length, mortalitas_delta_persen: deltas.length ? Math.round(deltas.reduce((a, b) => a + b, 0) * 1000) / 1000 : null, fcr_akhir_rasio: rows[rows.length - 1]?.fcr_akhir_rasio ?? null, }; }; bucket('brooding', [1, 2]); bucket('growth', [3, 4]); bucket( 'finisher', weekly_summaries.map((w) => w.minggu_ke).filter((w) => w >= 5) ); return { weekly_summaries, phase_summaries, mati_ekor: metrics.kematian_kumulatif_ekor, panen_ekor: metrics.panen_kumulatif_ekor, saldo_ekor: metrics.ayam_hidup_ekor, doc_in_ekor: metrics.doc_in_ekor, tren_kpi_harian: metrics.kpiSeries, }; } /** * Aggregate raw panel-iot readings into per-day env summaries with OOR hours. * `hari` is derived from cycle start when possible. */ export function buildIotDayAggregates(args: { rows: Array<{ date: string; timestamp?: string | null; average_temperature?: number | null; humidity?: number | null; }>; cycleStartDate?: string | null; getTempTarget: (hari: number) => number | null; humidityMax?: number; }): EndCycleIotDayAggregate[] { const humidityMax = args.humidityMax ?? 70; const byDate = new Map< string, { temps: number[]; hums: number[]; below: number; above: number } >(); for (const row of args.rows) { const date = row.date; if (!date) continue; const bucket = byDate.get(date) ?? { temps: [], hums: [], below: 0, above: 0 }; const temp = num(row.average_temperature); const hum = num(row.humidity); let hari: number | null = null; if (args.cycleStartDate) { const start = new Date(`${args.cycleStartDate}T00:00:00`); const cur = new Date(`${date}T00:00:00`); if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) { hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1; } } if (temp != null) { bucket.temps.push(temp); const target = hari != null ? args.getTempTarget(hari) : null; if (target != null && temp < target - 3) { // Treat each sample as ~1 hour contribution when dense; else count as 1. bucket.below += 1; } } if (hum != null) { bucket.hums.push(hum); if (hum > humidityMax) bucket.above += 1; } byDate.set(date, bucket); } const out: EndCycleIotDayAggregate[] = []; for (const [date, bucket] of [...byDate.entries()].sort(([a], [b]) => a.localeCompare(b))) { let hari: number | null = null; if (args.cycleStartDate) { const start = new Date(`${args.cycleStartDate}T00:00:00`); const cur = new Date(`${date}T00:00:00`); if (!Number.isNaN(start.getTime()) && !Number.isNaN(cur.getTime())) { hari = Math.floor((cur.getTime() - start.getTime()) / 86400000) + 1; } } out.push({ date, hari, avg_temp_C: avg(bucket.temps), avg_humidity_pct: avg(bucket.hums), sample_count: bucket.temps.length || bucket.hums.length, hours_temp_below: bucket.below, hours_humidity_above: bucket.above, }); } return out; } /** * Scope display metrics to a selected cycle day. * Weight chart keeps rows with `hari <= upToDay`. * Tile scalars use the KPI snapshot at exactly that day — never a newer day's values. */ export function scopeDashboardInsightMetrics( metrics: DashboardInsightMetrics, upToDay: number | null ): DashboardInsightMetrics { if (upToDay === null || !Number.isFinite(upToDay) || upToDay <= 0) { return metrics; } const weightSeries = metrics.weightSeries.filter((row) => row.hari <= upToDay); const kpiSeries = metrics.kpiSeries.filter((row) => row.hari <= upToDay); const exact = metrics.kpiSeries.find((row) => row.hari === upToDay) ?? null; const weightAtDay = weightSeries.find((row) => row.hari === upToDay) ?? null; return { hari_ke: upToDay, fcr: exact?.fcr ?? null, eef: exact?.eef ?? null, ayam_hidup_ekor: exact?.ayam_hidup_ekor ?? null, mortalitas_kumulatif_persen: exact?.mortalitas_kumulatif_persen ?? null, bobot_avg_gram: exact?.bobot_avg_gram ?? weightAtDay?.actual ?? null, doc_in_ekor: metrics.doc_in_ekor, kematian_kumulatif_ekor: exact?.kematian_kumulatif_ekor ?? null, panen_kumulatif_ekor: exact?.panen_kumulatif_ekor ?? null, tonase_panen_kg: exact?.tonase_panen_kg ?? null, uniformity_persen: exact?.uniformity_persen ?? null, pakan_kumulatif_karung: exact?.pakan_kumulatif_karung ?? null, weightSeries, kpiSeries, }; }