This commit is contained in:
1 parent
34336b9bd1
commit
333b1cf370
16 files changed
+2079
-130
No files matched your search
+120
-3
@@ -280,6 +280,84 @@ async function fetchLiveIotSensorRange(
|
||||
throw new Error('Invalid data structure from API');
|
||||
}
|
||||
|
||||
/** One cycle day of environment readings, averaged from every sensor sample. */
|
||||
export interface DailyEnvironmentPoint {
|
||||
hari: number;
|
||||
tanggal: string;
|
||||
suhu_C: number;
|
||||
suhuMin_C: number;
|
||||
suhuMax_C: number;
|
||||
kelembapan_persen: number;
|
||||
co2_ppm: number | null;
|
||||
jumlahSampel: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment history aggregated per CYCLE DAY, for root-cause analysis.
|
||||
*
|
||||
* `fetchLiveIotSensorRange` cannot serve this: it throws away everything older
|
||||
* than 12 hours and reduces the timestamp to "HH:MM", which is right for the
|
||||
* live chart and useless for asking why the birds died on day 13. This one
|
||||
* keeps the API's own `day` field — verified to be the cycle day, and already
|
||||
* cut at the WIB midnight boundary rather than UTC's.
|
||||
*/
|
||||
async function fetchIotDailyEnvironment(
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<DailyEnvironmentPoint[]> {
|
||||
const response = await fetch(
|
||||
`${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}`
|
||||
);
|
||||
if (response.status === 404) return [];
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
|
||||
const json = await response.json();
|
||||
if (!json?.success || !Array.isArray(json.data)) return [];
|
||||
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ tanggal: string; suhu: number[]; rh: number[]; co2: number[] }
|
||||
>();
|
||||
for (const item of json.data) {
|
||||
const hari = Number(item?.day);
|
||||
const suhu = Number(item?.suhu);
|
||||
const rh = Number(item?.kelembaban);
|
||||
// A dead sensor reports 0 for both; averaging those in drags the day's mean
|
||||
// below the standard and manufactures a "kandang kedinginan" finding.
|
||||
if (!Number.isFinite(hari) || hari <= 0 || !(suhu > 0) || !(rh > 0)) continue;
|
||||
const bucket =
|
||||
buckets.get(hari) ??
|
||||
(buckets.set(hari, {
|
||||
tanggal: String(item?.update_at ?? '').slice(0, 10),
|
||||
suhu: [],
|
||||
rh: [],
|
||||
co2: [],
|
||||
}),
|
||||
buckets.get(hari)!);
|
||||
bucket.suhu.push(suhu);
|
||||
bucket.rh.push(rh);
|
||||
const co2 = Number(item?.co2);
|
||||
if (Number.isFinite(co2) && co2 > 0) bucket.co2.push(co2);
|
||||
}
|
||||
|
||||
const mean = (values: number[]) =>
|
||||
values.length === 0 ? 0 : values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const round1 = (value: number) => Math.round(value * 10) / 10;
|
||||
|
||||
return [...buckets.entries()]
|
||||
.map(([hari, bucket]) => ({
|
||||
hari,
|
||||
tanggal: bucket.tanggal,
|
||||
suhu_C: round1(mean(bucket.suhu)),
|
||||
suhuMin_C: round1(Math.min(...bucket.suhu)),
|
||||
suhuMax_C: round1(Math.max(...bucket.suhu)),
|
||||
kelembapan_persen: round1(mean(bucket.rh)),
|
||||
co2_ppm: bucket.co2.length > 0 ? round1(mean(bucket.co2)) : null,
|
||||
jumlahSampel: bucket.suhu.length,
|
||||
}))
|
||||
.sort((a, b) => a.hari - b.hari);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns mock IoT sensor range data
|
||||
*/
|
||||
@@ -675,6 +753,20 @@ export const apiClient = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Environment readings averaged per cycle day, for the AI Insight
|
||||
* root-cause analysis. DEMO mode has no dated history to aggregate, so it
|
||||
* returns nothing rather than mock days that would invent correlations.
|
||||
*/
|
||||
getIotDailyEnvironment: async (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
mode: 'LIVE' | 'DEMO'
|
||||
): Promise<DailyEnvironmentPoint[]> => {
|
||||
if (mode !== 'LIVE') return [];
|
||||
return fetchIotDailyEnvironment(startDate, endDate);
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets feed sack counting data.
|
||||
* In LIVE mode, throws errors (no fallback to mock).
|
||||
@@ -1530,8 +1622,24 @@ export const apiClient = {
|
||||
* @param kandangId - Kandang ID (optional, null means all kandangs)
|
||||
* @returns Promise<{success: boolean, data?: any, message?: string}>
|
||||
*/
|
||||
getAiInsight: async (cycleId: string, kandangId: string | null = null) => {
|
||||
const queryParams = kandangId ? `?kandangId=${kandangId}` : '';
|
||||
/**
|
||||
* Reads a STORED insight. Never generates one.
|
||||
*
|
||||
* `reportType`/`reportPeriod` scope the lookup the same way the generate
|
||||
* endpoint stores it — without them a card asking for "Hari 12" gets whatever
|
||||
* report happens to be newest for the cycle.
|
||||
*/
|
||||
getAiInsight: async (
|
||||
cycleId: string,
|
||||
kandangId: string | null = null,
|
||||
reportType: 'daily' | 'weekly' | 'end_cycle' | null = null,
|
||||
reportPeriod: string | null = null
|
||||
) => {
|
||||
const params = new URLSearchParams();
|
||||
if (kandangId) params.set('kandangId', kandangId);
|
||||
if (reportType) params.set('reportType', reportType);
|
||||
if (reportPeriod) params.set('reportPeriod', reportPeriod);
|
||||
const queryParams = params.toString() ? `?${params.toString()}` : '';
|
||||
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/${cycleId}${queryParams}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
@@ -1634,7 +1742,15 @@ export const apiClient = {
|
||||
temperature = 0.2,
|
||||
topic = '',
|
||||
context_query = '',
|
||||
contextData: Record<string, any> | null = null
|
||||
contextData: Record<string, any> | null = null,
|
||||
/**
|
||||
* Teks insight yang sedang tampil, dikirim HANYA ketika operator menekan
|
||||
* generate lagi untuk periode data yang sama. Jalur proxy tidak punya
|
||||
* cache, jadi backend tidak bisa tahu sendiri ini permintaan ulang —
|
||||
* kehadiran field inilah penandanya. Backend memakainya untuk meminta
|
||||
* sudut pandang dan tindakan yang berbeda, dengan angka yang tetap sama.
|
||||
*/
|
||||
previousInsight: string | null = null
|
||||
) => {
|
||||
const response = await fetch(`${DB_API_BASE_URL}/ai-insights/proxy`, {
|
||||
method: 'POST',
|
||||
@@ -1646,6 +1762,7 @@ export const apiClient = {
|
||||
topic,
|
||||
context_query,
|
||||
contextData,
|
||||
previous_insight: previousInsight,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user