987 lines
35 KiB
TypeScript
987 lines
35 KiB
TypeScript
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,
|
||
};
|
||
}
|