Files
dashboard-cpsp/utils/insightContextBuilders.ts
T
2026-10-01 13:23:45 +07:00

987 lines
35 KiB
TypeScript
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.
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<typeof row> => 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<typeof row> => 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<typeof row> => 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<typeof row> => 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<number, DashboardInsightKpiPoint>();
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<Record<'brooding' | 'growth' | 'finisher', EndCyclePhaseSummary>>;
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<number, DashboardInsightKpiPoint[]>();
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<number, EndCycleIotDayAggregate[]>();
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,
};
}