update applying current ai insight from dashboard-cpsp
This commit is contained in:
1 parent
31272035e9
commit
b87f20e3b3
27 files changed
+5300
-343
No files matched your search
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Environment standards from the CP 707 book, for client-side charting only.
|
||||
*
|
||||
* Mirrors temperature / air-quality tables in
|
||||
* `backend/apps/operations/services/cp707_knowledge.py` — that Python module
|
||||
* is authoritative for LLM grading. This TS copy exists so IoT charts can
|
||||
* draw targets without a round trip. Keep both in sync when the book edition
|
||||
* changes.
|
||||
*/
|
||||
|
||||
/** Target suhu pemeliharaan per umur (buku CP 707). */
|
||||
const TEMPERATURE_STANDARD = [
|
||||
{ from: 1, to: 2, temp_C: 32 },
|
||||
{ from: 3, to: 4, temp_C: 31 },
|
||||
{ from: 5, to: 7, temp_C: 30 },
|
||||
{ from: 8, to: 14, temp_C: 29 },
|
||||
{ from: 15, to: 21, temp_C: 28 },
|
||||
{ from: 22, to: 28, temp_C: 26 },
|
||||
{ from: 29, to: 35, temp_C: 23 },
|
||||
{ from: 36, to: 99, temp_C: 22 },
|
||||
];
|
||||
|
||||
/** Kelembapan ideal sama untuk semua umur menurut tabel buku: 50–70 %. */
|
||||
export const HUMIDITY_RANGE_PCT = { min: 50, max: 70 };
|
||||
|
||||
/** Standar kualitas udara (Lampiran 3). */
|
||||
export const AIR_QUALITY = {
|
||||
/** Amonia: ideal <10 ppm, batas kritis >25 ppm. */
|
||||
ammoniaSafe_ppm: 10,
|
||||
ammoniaCritical_ppm: 25,
|
||||
/** CO2: ideal <3000 ppm, kritis >3500 ppm. */
|
||||
co2Ideal_ppm: 3000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standar konsumsi air per 1000 ekor per hari pada suhu 21°C (buku CP 707).
|
||||
* Di atas 21°C kebutuhan naik rata-rata 6.5% per derajat.
|
||||
*/
|
||||
const WATER_CONSUMPTION_STANDARD: { week: number; minLiter: number; maxLiter: number }[] = [
|
||||
{ week: 1, minLiter: 58, maxLiter: 65 },
|
||||
{ week: 2, minLiter: 102, maxLiter: 115 },
|
||||
{ week: 3, minLiter: 149, maxLiter: 167 },
|
||||
{ week: 4, minLiter: 192, maxLiter: 216 },
|
||||
{ week: 5, minLiter: 232, maxLiter: 261 },
|
||||
{ week: 6, minLiter: 274, maxLiter: 308 },
|
||||
{ week: 7, minLiter: 309, maxLiter: 347 },
|
||||
{ week: 8, minLiter: 342, maxLiter: 385 },
|
||||
];
|
||||
|
||||
/** Standar konsumsi air (rata-rata min–max) untuk umur hari tertentu, atau null. */
|
||||
export const getWaterStandardByDay = (
|
||||
dayAge: number | null | undefined
|
||||
): { min: number; max: number } | null => {
|
||||
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
|
||||
const week = Math.min(Math.ceil(dayAge / 7), WATER_CONSUMPTION_STANDARD.length);
|
||||
const row = WATER_CONSUMPTION_STANDARD.find((r) => r.week === week);
|
||||
return row ? { min: row.minLiter, max: row.maxLiter } : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Suhu target untuk umur tertentu, atau null bila umurnya tidak masuk akal.
|
||||
* Umur di luar tabel jatuh ke baris terakhir (36+ hari), sesuai isi buku.
|
||||
*/
|
||||
export const getTempStandardByDay = (dayAge: number | null | undefined): number | null => {
|
||||
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
|
||||
const row = TEMPERATURE_STANDARD.find((r) => dayAge >= r.from && dayAge <= r.to);
|
||||
return row ? row.temp_C : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Number out of a display-formatted reading.
|
||||
*
|
||||
* `computeDashboard` hands back strings like "32,1 °C" and "62,8 %" — already
|
||||
* scaled from the raw sensor encoding, which is why they are the safe source —
|
||||
* so the chart has to strip the unit and accept the Indonesian decimal comma.
|
||||
* A plain number passes through unchanged.
|
||||
*/
|
||||
export const parseReading = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||
const cleaned = value
|
||||
.replace(/[^\d,.-]/g, '')
|
||||
.replace(/\.(?=\d{3}\b)/g, '')
|
||||
.replace(',', '.');
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
@@ -0,0 +1,989 @@
|
||||
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;
|
||||
docWeight?: 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 doc = num(row.doc_weight) ?? num(args.docWeight) ?? 40;
|
||||
const target = num(row.target_gram) ?? (hari > 0 ? getTargetWeightForAge(hari, doc) : 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
|
||||
|
||||
/**
|
||||
* Analyze trend of numerical data over a window
|
||||
* @param data Array of numerical values
|
||||
* @param windowSize Number of recent data points to analyze
|
||||
* @returns Trend description in Indonesian
|
||||
*/
|
||||
export function analyzeTrend(data: number[], windowSize = 7): string {
|
||||
if (!data || data.length < 2) {
|
||||
return 'tidak cukup data';
|
||||
}
|
||||
|
||||
// Filter out invalid values (null, undefined, NaN, 0)
|
||||
const validData = data.filter((val) => val != null && !isNaN(val) && val !== 0);
|
||||
|
||||
if (validData.length < 2) {
|
||||
return 'tidak cukup data';
|
||||
}
|
||||
|
||||
// Take last N values
|
||||
const recentData = validData.slice(-Math.min(windowSize, validData.length));
|
||||
|
||||
if (recentData.length < 2) {
|
||||
return 'tidak cukup data';
|
||||
}
|
||||
|
||||
// Calculate simple linear regression slope
|
||||
const n = recentData.length;
|
||||
const xMean = (n - 1) / 2; // Mean of indices 0, 1, 2, ..., n-1
|
||||
const yMean = recentData.reduce((sum, val) => sum + val, 0) / n;
|
||||
|
||||
let numerator = 0;
|
||||
let denominator = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
numerator += (i - xMean) * ((recentData[i] ?? 0) - yMean);
|
||||
denominator += Math.pow(i - xMean, 2);
|
||||
}
|
||||
|
||||
const slope = denominator !== 0 ? numerator / denominator : 0;
|
||||
|
||||
// Calculate relative slope (as percentage of mean)
|
||||
const relativeSlope = yMean !== 0 ? (slope / yMean) * 100 : 0;
|
||||
|
||||
// Determine trend based on relative slope
|
||||
// Threshold: >2% = increasing, <-2% = decreasing, else stable
|
||||
if (relativeSlope > 2) {
|
||||
return 'meningkat';
|
||||
} else if (relativeSlope < -2) {
|
||||
return 'menurun';
|
||||
} else {
|
||||
return 'stabil';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cycle phase based on day of cycle
|
||||
* @param day Current day of cycle (1-based)
|
||||
* @returns Phase name
|
||||
*/
|
||||
export function getCyclePhase(day: number): string {
|
||||
if (day <= 14) {
|
||||
return 'Brooding';
|
||||
} else if (day <= 28) {
|
||||
return 'Growth';
|
||||
} else {
|
||||
return 'Finisher';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Cobb standard performance metrics for a given day
|
||||
* @param day Day of cycle (1-based)
|
||||
* @returns Object with FCR and weight, or null if not available
|
||||
*/
|
||||
export function getCobbStandardForDay(day: number): { fcr: number; weight: number } | null {
|
||||
const standard = cobbStandardPerformance.find((entry) => entry.day === day);
|
||||
if (standard) {
|
||||
return {
|
||||
fcr: standard.fcr,
|
||||
// Weight targets live in page builders / CP707 knowledge; FCR row has no weight column here.
|
||||
weight: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate deviation between actual and target values
|
||||
* @param actual Actual value
|
||||
* @param target Target value
|
||||
* @returns Object with absolute and percentage deviation
|
||||
*/
|
||||
export function calculateDeviation(
|
||||
actual: number,
|
||||
target: number
|
||||
): { absolute: number; percent: number } {
|
||||
const absolute = actual - target;
|
||||
const percent = target !== 0 ? (absolute / target) * 100 : 0;
|
||||
return { absolute, percent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine environmental status based on readings and cycle phase
|
||||
* @param temp Temperature in Celsius
|
||||
* @param humidity Humidity percentage
|
||||
* @param ammonia Ammonia in ppm
|
||||
* @param day Current day of cycle
|
||||
* @returns Status description in Indonesian
|
||||
*/
|
||||
export function getEnvironmentalStatus(
|
||||
temp: number,
|
||||
humidity: number,
|
||||
ammonia: number,
|
||||
day: number
|
||||
): string {
|
||||
const issues: string[] = [];
|
||||
|
||||
// Age-appropriate temperature ranges
|
||||
// Younger chicks need warmer temperatures
|
||||
let optimalTempMin: number;
|
||||
let optimalTempMax: number;
|
||||
|
||||
if (day <= 7) {
|
||||
optimalTempMin = 32;
|
||||
optimalTempMax = 35;
|
||||
} else if (day <= 14) {
|
||||
optimalTempMin = 30;
|
||||
optimalTempMax = 32;
|
||||
} else if (day <= 21) {
|
||||
optimalTempMin = 28;
|
||||
optimalTempMax = 30;
|
||||
} else {
|
||||
optimalTempMin = 26;
|
||||
optimalTempMax = 28;
|
||||
}
|
||||
|
||||
// Check temperature
|
||||
if (temp < optimalTempMin) {
|
||||
issues.push('suhu terlalu rendah');
|
||||
} else if (temp > optimalTempMax) {
|
||||
issues.push('suhu terlalu tinggi');
|
||||
}
|
||||
|
||||
// Check humidity (optimal: 50-70%)
|
||||
if (humidity < 50) {
|
||||
issues.push('kelembapan rendah');
|
||||
} else if (humidity > 70) {
|
||||
issues.push('kelembapan tinggi');
|
||||
}
|
||||
|
||||
// Check ammonia (safe: <25ppm, warning: 25-50ppm, critical: >50ppm)
|
||||
if (ammonia > 50) {
|
||||
issues.push('amonia kritis');
|
||||
} else if (ammonia > 25) {
|
||||
issues.push('amonia tinggi');
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return 'optimal';
|
||||
} else if (issues.length === 1) {
|
||||
return issues[0] ?? 'optimal';
|
||||
} else {
|
||||
return issues.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze feed consumption trend
|
||||
* @param feedHistory Array of daily feed consumption (in sacks)
|
||||
* @param _currentDay Current day of cycle (reserved for future use)
|
||||
* @returns Trend description in Indonesian
|
||||
*/
|
||||
export function analyzeFeedTrend(feedHistory: number[], _currentDay: number): string {
|
||||
if (!feedHistory || feedHistory.length < 3) {
|
||||
return 'tidak cukup data';
|
||||
}
|
||||
|
||||
// Get last 7 days of feed data
|
||||
const recentFeed = feedHistory.slice(-7);
|
||||
|
||||
// Rough expectation: feed consumption should increase gradually as chickens grow
|
||||
// Early days: 0.5-1 sack/1000 birds/day
|
||||
// Mid cycle: 1-2 sacks/1000 birds/day
|
||||
// Late cycle: 2-3 sacks/1000 birds/day
|
||||
|
||||
// This is a simplified heuristic - in real deployment, this would be calibrated
|
||||
// based on actual farm data and population size
|
||||
|
||||
// For now, just analyze if consumption is increasing (expected as birds grow)
|
||||
const trend = analyzeTrend(recentFeed, 7);
|
||||
|
||||
if (trend === 'meningkat') {
|
||||
return 'sesuai pertumbuhan';
|
||||
} else if (trend === 'menurun') {
|
||||
return 'menurun (perlu perhatian)';
|
||||
} else {
|
||||
return 'stabil';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Turns whatever JSON the local model returns into {summary, insight}.
|
||||
*
|
||||
* The models routinely invent their own field names — `tren_mortalitas_harian`,
|
||||
* `dampak_populasi`, `rekomendasi_tindakan` instead of `kesimpulan`/`insight`.
|
||||
* The old per-page parser answered that by substituting a cheerful default
|
||||
* ("Analisis operasional kandang terpantau dengan baik."), which could mask a
|
||||
* report of total flock loss, and the counting page answered it by discarding
|
||||
* the response outright.
|
||||
*
|
||||
* Rule here: never invent reassuring text, and never silently drop content. Any
|
||||
* field the model produced is folded into the output; only a response with no
|
||||
* usable text at all returns null, so the UI can show a real error instead.
|
||||
*/
|
||||
|
||||
export interface AiResult {
|
||||
summary: string;
|
||||
insight: string;
|
||||
}
|
||||
|
||||
/** Field names seen standing in for the conclusion. */
|
||||
const KESIMPULAN_KEYS = [
|
||||
'kesimpulan',
|
||||
'kesimpulan_analisis',
|
||||
'ringkasan',
|
||||
'headline',
|
||||
'summary',
|
||||
'conclusion',
|
||||
];
|
||||
|
||||
/** Field names seen standing in for the recommendation. */
|
||||
const INSIGHT_KEYS = [
|
||||
'insight',
|
||||
'insights',
|
||||
'rekomendasi',
|
||||
'rekomendasi_tindakan',
|
||||
'tindakan',
|
||||
'saran',
|
||||
'actions',
|
||||
'recommendations',
|
||||
];
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Turns a leftover field name into a readable label: `tren_mortalitas_harian` -> `Tren mortalitas harian`. */
|
||||
const humanizeKey = (key: string) => {
|
||||
const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
};
|
||||
|
||||
/** Flattens any value into readable plain text. Arrays become bullet lines. */
|
||||
const renderValue = (value: unknown, depth = 0): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => renderValue(item, depth + 1))
|
||||
.filter((item) => item.length > 0)
|
||||
.map((item) => (depth === 0 ? `• ${item}` : item))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.entries(value)
|
||||
.map(([key, nested]) => {
|
||||
const rendered = renderValue(nested, depth + 1);
|
||||
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
|
||||
})
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
const rendered = renderValue(source[key]);
|
||||
if (rendered) return { key, text: rendered };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
|
||||
const extractJsonObject = (text: string): string | null => {
|
||||
const cleaned = text
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/\s*```\s*$/, '')
|
||||
.replace(/\/\/.*/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/,\s*([}\]])/g, '$1')
|
||||
.trim();
|
||||
|
||||
const first = cleaned.indexOf('{');
|
||||
const last = cleaned.lastIndexOf('}');
|
||||
if (first === -1 || last === -1 || last <= first) return null;
|
||||
return cleaned.slice(first, last + 1);
|
||||
};
|
||||
|
||||
export const parseAiResult = (text: string): AiResult | null => {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
const candidate = extractJsonObject(text);
|
||||
if (!candidate) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isPlainObject(parsed)) return null;
|
||||
|
||||
const used = new Set<string>();
|
||||
|
||||
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
|
||||
if (kesimpulanHit) used.add(kesimpulanHit.key);
|
||||
|
||||
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
|
||||
if (insightHit) used.add(insightHit.key);
|
||||
|
||||
// Anything the model invented a name for still carries its analysis, so it is
|
||||
// appended under a readable label rather than thrown away.
|
||||
const leftovers = Object.entries(parsed)
|
||||
.filter(([key]) => !used.has(key))
|
||||
.map(([key, value]) => {
|
||||
const rendered = renderValue(value);
|
||||
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
|
||||
})
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
let kesimpulan = kesimpulanHit?.text ?? '';
|
||||
let insight = insightHit?.text ?? '';
|
||||
|
||||
if (!kesimpulan && leftovers.length > 0) {
|
||||
// Promote the first salvaged field to the conclusion so the card is never
|
||||
// headed by a generic sentence the model never wrote.
|
||||
kesimpulan = leftovers.shift() as string;
|
||||
}
|
||||
if (leftovers.length > 0) {
|
||||
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
if (!kesimpulan && !insight) return null;
|
||||
|
||||
return {
|
||||
summary: kesimpulan || 'Model tidak mengembalikan ringkasan eksplisit.',
|
||||
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Narrows a per-page insight's context data down to the period the user picked.
|
||||
*
|
||||
* The per-page AI Insight used to send the whole cycle to the model no matter
|
||||
* which timeframe was selected, and only changed a label in the prompt — so
|
||||
* "Harian" and "Mingguan" produced near-identical answers. This trims the data
|
||||
* itself, which is the only thing the model actually reads.
|
||||
*/
|
||||
|
||||
export type InsightTimeframe = 'harian' | 'mingguan';
|
||||
|
||||
/**
|
||||
* Rows of a day-indexed history carry a day number and/or a date.
|
||||
*
|
||||
* Pages are inconsistent about the field name: the dashboard builds `day`/`date`
|
||||
* while the FCR and EEF pages build `hari`/`tanggal`. Recognising only the
|
||||
* English spelling meant those two pages' histories were never sliced at all —
|
||||
* their timeframe selector changed nothing.
|
||||
*/
|
||||
type DayRow = Record<string, unknown> & {
|
||||
day?: unknown;
|
||||
hari?: unknown;
|
||||
date?: unknown;
|
||||
tanggal?: unknown;
|
||||
};
|
||||
|
||||
const DAY_KEYS = ['day', 'hari'] as const;
|
||||
const DATE_KEYS = ['date', 'tanggal'] as const;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
const dayOf = (row: DayRow): number | null => {
|
||||
for (const key of DAY_KEYS) {
|
||||
const value = row[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasDateField = (row: DayRow): boolean =>
|
||||
DATE_KEYS.some((key) => typeof row[key] === 'string');
|
||||
|
||||
/**
|
||||
* A history indexed by time, as opposed to a snapshot list (cameras, devices,
|
||||
* coops) that has no time dimension. Only the former may be sliced by period —
|
||||
* trimming a device list to "today" would silently hide devices.
|
||||
*/
|
||||
const isDayIndexedArray = (value: unknown[]): value is DayRow[] =>
|
||||
value.length > 0 &&
|
||||
value.every(
|
||||
(item) =>
|
||||
isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow))
|
||||
);
|
||||
|
||||
const sliceHistory = (
|
||||
rows: DayRow[],
|
||||
timeframe: InsightTimeframe,
|
||||
selectedDay: number | null = null
|
||||
): DayRow[] => {
|
||||
const days = rows.map(dayOf).filter((day): day is number => day !== null);
|
||||
|
||||
if (timeframe === 'harian') {
|
||||
if (days.length > 0) {
|
||||
// Picking a day means "the cycle so far, up to that day" — day 0 through
|
||||
// day N — not that day in isolation. A single row shows no trend and
|
||||
// cannot support a cumulative figure like mortality-to-date.
|
||||
if (selectedDay !== null)
|
||||
return rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay);
|
||||
return rows.filter((row) => dayOf(row) === Math.max(...days));
|
||||
}
|
||||
// Date-only history: the array position is the only ordering we have.
|
||||
if (selectedDay !== null) return rows.slice(0, selectedDay + 1);
|
||||
return rows.slice(-1);
|
||||
}
|
||||
|
||||
if (days.length > 0) {
|
||||
const latestDay = Math.max(...days);
|
||||
// The 7-day window ending today, matched on each row's own `day` so gaps in
|
||||
// the data do not shift the window.
|
||||
return rows.filter((row) => {
|
||||
const day = dayOf(row);
|
||||
return day !== null && day > latestDay - 7 && day <= latestDay;
|
||||
});
|
||||
}
|
||||
return rows.slice(-7);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a copy of `contextData` where every day-indexed history is trimmed to
|
||||
* `timeframe`. Snapshot lists, scalars, and nested objects are preserved.
|
||||
*/
|
||||
export const scopeContextToTimeframe = <T>(
|
||||
contextData: T,
|
||||
timeframe: InsightTimeframe,
|
||||
selectedDay: number | null = null
|
||||
): T => {
|
||||
if (Array.isArray(contextData)) {
|
||||
const scoped = isDayIndexedArray(contextData)
|
||||
? sliceHistory(contextData as DayRow[], timeframe, selectedDay)
|
||||
: contextData;
|
||||
return scoped.map((item) =>
|
||||
scopeContextToTimeframe(item, timeframe, selectedDay)
|
||||
) as unknown as T;
|
||||
}
|
||||
|
||||
if (isPlainObject(contextData)) {
|
||||
const scoped: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(contextData)) {
|
||||
scoped[key] = scopeContextToTimeframe(value, timeframe, selectedDay);
|
||||
}
|
||||
return scoped as unknown as T;
|
||||
}
|
||||
|
||||
return contextData;
|
||||
};
|
||||
|
||||
export const UNAVAILABLE = 'tidak tersedia';
|
||||
|
||||
/**
|
||||
* How one page's summary scalars relate to its day-indexed history.
|
||||
*
|
||||
* `derive` maps an OUTPUT key — which must carry its unit, e.g.
|
||||
* `bobotRataRata_gram` — to the history field it is computed from and how to
|
||||
* aggregate it across the window. `drop` lists the original unitless keys the
|
||||
* derived ones replace; leaving them in would hand the model both the stale
|
||||
* figure and the period one and let it pick. `unavailable` names scalars with
|
||||
* no day-indexed source at all: they are marked rather than left showing a
|
||||
* value from a different period.
|
||||
*/
|
||||
export type ScalarScopeSpec = {
|
||||
historyKey: string;
|
||||
/**
|
||||
* `replaces` names the original key this derived value stands in for. The
|
||||
* prompt drops the original, but the card's summary grid still renders it —
|
||||
* without the link the grid kept showing cycle-to-date figures beside a
|
||||
* narrative scoped to one period, which reads as the toggle doing nothing.
|
||||
*/
|
||||
derive: Record<
|
||||
string,
|
||||
{
|
||||
field: string;
|
||||
/**
|
||||
* `last` takes the final row verbatim. `lastValid` walks backwards past
|
||||
* zeros — use it for readings where 0 means "the sensor sent nothing",
|
||||
* not "the measurement was zero". The IoT scale stopped reporting at day
|
||||
* 38 and has written 0 every day since, so `last` handed the card
|
||||
* "Rata-rata Bobot 0 kg" while the page beside it showed the real figure.
|
||||
*/
|
||||
agg: 'mean' | 'sum' | 'last' | 'lastValid';
|
||||
replaces?: string;
|
||||
/**
|
||||
* What to write when the window holds no usable reading. The default is
|
||||
* `UNAVAILABLE`, which is right for figures a missing day says nothing
|
||||
* about. Set `0` for the ones the supervisor wants reported as a plain
|
||||
* zero instead — a day with no weighing (day 48) has to render "0 g",
|
||||
* not a blank, because a blank read as a broken card.
|
||||
*/
|
||||
emptyAs?: 0;
|
||||
}
|
||||
>;
|
||||
drop?: string[];
|
||||
unavailable?: string[];
|
||||
};
|
||||
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
const aggregate = (rows: DayRow[], field: string, agg: 'mean' | 'sum' | 'last' | 'lastValid') => {
|
||||
const values = rows
|
||||
.map((row) => row[field])
|
||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
|
||||
if (values.length === 0) return null;
|
||||
if (agg === 'lastValid') {
|
||||
// Mirrors `getLatestValid` on the FCR/EEF pages, so the insight card and
|
||||
// the page it sits on cannot report different numbers. All-zero means the
|
||||
// reading is missing, which has to surface as "tidak tersedia" rather than
|
||||
// a confident 0.
|
||||
for (let i = values.length - 1; i >= 0; i--) {
|
||||
const value = values[i];
|
||||
if (value !== undefined && value !== 0) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (agg === 'last') return values[values.length - 1] ?? null;
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
return round2(agg === 'sum' ? total : total / values.length);
|
||||
};
|
||||
|
||||
/**
|
||||
* Bring a per-page context's scalars into the same period as its history.
|
||||
*
|
||||
* Slicing the history alone is not enough: a "Mingguan" payload whose chart
|
||||
* covers days 42-48 while `averageWeight` still holds the day-48 figure lets
|
||||
* the model quote either, which is the exact contradiction already fixed on the
|
||||
* dashboard. Run this AFTER `scopeContextToTimeframe`, on the sliced context.
|
||||
*/
|
||||
export const scopeScalarsToTimeframe = (
|
||||
scopedContext: Record<string, unknown>,
|
||||
timeframe: InsightTimeframe,
|
||||
spec: ScalarScopeSpec,
|
||||
selectedDay: number | null = null
|
||||
): Record<string, unknown> => {
|
||||
const history = scopedContext[spec.historyKey];
|
||||
if (!Array.isArray(history)) return scopedContext;
|
||||
const rows = history as DayRow[];
|
||||
|
||||
const out: Record<string, unknown> = { ...scopedContext };
|
||||
for (const key of spec.drop ?? []) delete out[key];
|
||||
|
||||
const days = rows.map(dayOf).filter((day): day is number => day !== null);
|
||||
// Harian = cumulative from cycle start (hari ke-0), matching dashboard reports.
|
||||
// Do not label from the first day that happens to have a row (e.g. "2 s/d 8").
|
||||
let label: string;
|
||||
if (days.length === 0) {
|
||||
label = UNAVAILABLE;
|
||||
} else if (timeframe === 'harian') {
|
||||
const endDay = selectedDay ?? Math.max(...days);
|
||||
label =
|
||||
selectedDay === null && Math.min(...days) === Math.max(...days)
|
||||
? `hari ke-${endDay}`
|
||||
: `hari ke-0 s/d ke-${endDay}`;
|
||||
} else if (Math.min(...days) === Math.max(...days)) {
|
||||
label = `hari ke-${Math.max(...days)}`;
|
||||
} else {
|
||||
label = `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`;
|
||||
}
|
||||
|
||||
for (const [outKey, rule] of Object.entries(spec.derive)) {
|
||||
const value = aggregate(rows, rule.field, rule.agg);
|
||||
const finalValue = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value;
|
||||
out[outKey] = finalValue;
|
||||
if (rule.replaces) out[rule.replaces] = finalValue;
|
||||
}
|
||||
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||
|
||||
out.periode = label;
|
||||
// Only the multi-day window needs the "not today" warning; saying it in daily
|
||||
// mode contradicts the label directly above it.
|
||||
// Only a window that is not today needs the "not today" warning; saying it in
|
||||
// a single-day-today report contradicts the label directly above it.
|
||||
const satuHariTerakhir =
|
||||
timeframe === 'harian' && days.length > 0 && Math.min(...days) === Math.max(...days);
|
||||
const bukanHariIni = satuHariTerakhir ? '' : 'BUKAN kondisi hari ini. ';
|
||||
const dataSpan =
|
||||
days.length > 0 && Math.min(...days) > 0
|
||||
? ` Baris data tersedia mulai hari ke-${Math.min(...days)}.`
|
||||
: '';
|
||||
out.catatan_periode =
|
||||
`Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data).${dataSpan} ` +
|
||||
`${bukanHariIni}Satuan setiap angka tertulis di akhir nama field ` +
|
||||
`(_gram, _persen, _rasio, _karung, _ekor, _kg) — pakai satuan itu persis. ` +
|
||||
`Field berisi "${UNAVAILABLE}" memang tidak ada datanya: tulis "data tidak tersedia" ` +
|
||||
`dan JANGAN mengarang angkanya.`;
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Human-readable description of the window, for the prompt and the UI. */
|
||||
export const describeTimeframe = (
|
||||
timeframe: InsightTimeframe,
|
||||
currentDay?: number | null,
|
||||
selectedDay: number | null = null
|
||||
) => {
|
||||
if (timeframe === 'harian') {
|
||||
// Daily now means "the cycle so far", so the label must state the range. A
|
||||
// past end-day must not be described as "hari ini" either — the model
|
||||
// repeats that phrasing back and claims the figures are today's.
|
||||
if (selectedDay !== null) {
|
||||
const ekor = `hari ke-0 s/d ke-${selectedDay} (kumulatif sejak awal siklus)`;
|
||||
return selectedDay === currentDay ? `${ekor}, berakhir hari ini` : `${ekor}, BUKAN hari ini`;
|
||||
}
|
||||
return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)';
|
||||
}
|
||||
return currentDay
|
||||
? `7 hari terakhir (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})`
|
||||
: '7 hari terakhir';
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* KPI.chicken_life = DOC − cumulative mortality (excludes harvest).
|
||||
* Stock akhir (in-house remaining) = chicken_life − harvest_total.
|
||||
*/
|
||||
export function stockAkhirFromKpi(
|
||||
kpi: { chicken_life?: number | null; harvest_total?: number | null } | null | undefined
|
||||
): number | null {
|
||||
if (kpi?.chicken_life == null) return null;
|
||||
return Math.max(0, kpi.chicken_life - (kpi.harvest_total ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { KpiStatus } from '../components/KpiCard.tsx';
|
||||
|
||||
/**
|
||||
* Daily mortality % of DOC thresholds (manager operational rules):
|
||||
* - <= 0.05%: Normal / OK (green)
|
||||
* - > 0.05% and < 0.10%: Warning (yellow)
|
||||
* - >= 0.10%: Critical (red)
|
||||
*/
|
||||
export const DAILY_MORTALITY_DOC_THRESHOLDS = {
|
||||
warning_pct: 0.05,
|
||||
critical_pct: 0.1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Calculates daily mortality percentage relative to initial DOC count.
|
||||
*/
|
||||
export function getDailyMortalityPct(
|
||||
mortalityToday: number | null | undefined,
|
||||
docInCount: number | null | undefined
|
||||
): number | null {
|
||||
if (mortalityToday == null || docInCount == null || docInCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (mortalityToday / docInCount) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the KPI status for daily mortality based on DOC thresholds.
|
||||
* - 'na' when data is unavailable
|
||||
* - 'critical' when daily mortality >= 0.10%
|
||||
* - 'warning' when daily mortality > 0.05%
|
||||
* - 'ok' when daily mortality <= 0.05%
|
||||
*/
|
||||
export function getDailyMortalityStatus(
|
||||
mortalityToday: number | null | undefined,
|
||||
docInCount: number | null | undefined
|
||||
): KpiStatus {
|
||||
if (mortalityToday == null) return 'na';
|
||||
if (docInCount == null || docInCount <= 0) return 'ok';
|
||||
|
||||
const dailyPct = (mortalityToday / docInCount) * 100;
|
||||
if (dailyPct >= DAILY_MORTALITY_DOC_THRESHOLDS.critical_pct) {
|
||||
return 'critical';
|
||||
}
|
||||
if (dailyPct > DAILY_MORTALITY_DOC_THRESHOLDS.warning_pct) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
Reference in new issue
Block a user