Files
dashboard/utils/scaleDataProcessor.ts
Alberto-Audrix 32a36cceff
CI / lint-and-test (push) Canceled after 0s
first commit
2026-07-28 08:55:05 +07:00

309 lines
7.9 KiB
TypeScript

import {
ScalePredictionRangeResponse,
SensorWeightSummaryResponse,
DailyWeightPoint,
DailyWeightGainPoint,
DeviceWeightData,
WeightStatistics,
} from '../types.ts';
import { getTargetWeightForDay } from './weightUtils.ts';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface DeviceInfo {
id: string;
name: string;
status: boolean;
last_online: string;
}
function parseDateOnly(dateString: string): Date | null {
const [year, month, day] = dateString.split('-').map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
function getDayOfCycle(dateString: string, cycleStartDate: Date): number | null {
const pointDate = parseDateOnly(dateString);
if (!pointDate) {
return null;
}
const startDate = new Date(cycleStartDate);
startDate.setHours(0, 0, 0, 0);
pointDate.setHours(0, 0, 0, 0);
const diffMs = pointDate.getTime() - startDate.getTime();
if (diffMs < 0) {
return null;
}
return Math.floor(diffMs / MS_PER_DAY) + 1;
}
/**
* Process scale prediction range data into daily weight points
*/
export function processDailyWeightData(
scaleData: ScalePredictionRangeResponse,
cycleStartDate: Date,
maxDay?: number
): DailyWeightPoint[] {
const dailyMap = new Map<
string,
{
weights: number[];
chickenCounts: number[];
date: string;
day: number;
}
>();
// Group data by date
scaleData.data.forEach((dayData) => {
const fallbackAge = dayData.items[0]?.age;
const dayOfCycle = getDayOfCycle(dayData.date, cycleStartDate) ?? fallbackAge ?? 1;
if (dayOfCycle < 1) {
return;
}
if (maxDay !== undefined && dayOfCycle > maxDay) {
return;
}
const weights: number[] = [];
const chickenCounts: number[] = [];
dayData.items.forEach((item) => {
if (item.avg_weight_actual > 0 && item.chicken_count > 0) {
weights.push(item.avg_weight_actual);
chickenCounts.push(item.chicken_count);
}
});
if (weights.length > 0) {
dailyMap.set(dayData.date, {
weights,
chickenCounts,
date: dayData.date,
day: dayOfCycle,
});
}
});
// Convert to DailyWeightPoint array
const result: DailyWeightPoint[] = [];
dailyMap.forEach((dayInfo) => {
// Calculate weighted average
let totalWeight = 0;
let totalChickens = 0;
dayInfo.weights.forEach((weight, idx) => {
const count = dayInfo.chickenCounts[idx] ?? 0;
if (count <= 0) {
return;
}
totalWeight += weight * count;
totalChickens += count;
});
const avgWeight = totalChickens > 0 ? totalWeight / totalChickens : 0;
// Skip if no actual weight data
if (avgWeight === 0 || totalChickens === 0) {
return;
}
const minWeight = Math.min(...dayInfo.weights);
const maxWeight = Math.max(...dayInfo.weights);
const targetWeight = getTargetWeightForDay(dayInfo.day) || 0;
result.push({
day: dayInfo.day,
date: dayInfo.date,
actual: Math.round(avgWeight),
target: targetWeight,
min: Math.round(minWeight),
max: Math.round(maxWeight),
chickenCount: totalChickens,
});
});
// Sort by day
return result.sort((a, b) => a.day - b.day);
}
/**
* Calculate daily weight gain from daily weight data
*/
export function calculateDailyWeightGain(
dailyWeightData: DailyWeightPoint[]
): DailyWeightGainPoint[] {
const result: DailyWeightGainPoint[] = [];
for (let i = 1; i < dailyWeightData.length; i++) {
const today = dailyWeightData[i];
const yesterday = dailyWeightData[i - 1];
if (!today || !yesterday) {
continue;
}
// Skip if either day has no actual weight
if (!today.actual || !yesterday.actual) {
continue;
}
const gain = today.actual - yesterday.actual;
const targetGain = (today.target || 0) - (yesterday.target || 0);
// Determine status
let status: 'above' | 'near' | 'below';
const threshold = Math.abs(targetGain) * 0.1; // 10% threshold
if (gain >= targetGain + threshold) {
status = 'above';
} else if (gain >= targetGain - threshold) {
status = 'near';
} else {
status = 'below';
}
result.push({
day: today.day,
date: today.date,
gain,
targetGain,
status,
});
}
return result;
}
/**
* Process sensor summary data into device weight data
*/
export function processDeviceWeightData(
summaryData: SensorWeightSummaryResponse,
deviceInfoList: DeviceInfo[]
): DeviceWeightData[] {
return summaryData.data
.filter((sensor) => {
// Skip devices with no weight data or no chickens
if (
!sensor.avg_weight_actual ||
sensor.avg_weight_actual <= 0 ||
!sensor.chicken_count ||
sensor.chicken_count <= 0
) {
return false;
}
return true;
})
.map((sensor) => {
const deviceInfo = deviceInfoList.find((d) => d.id === sensor.device_id);
return {
deviceId: sensor.device_id,
deviceName: sensor.device_name,
avgWeight: Math.round(sensor.avg_weight_actual),
chickenCount: sensor.chicken_count,
isOnline: deviceInfo?.status ?? true,
lastOnline: deviceInfo?.last_online ?? new Date().toISOString(),
};
});
}
/**
* Calculate weight statistics from sensor summary data
*/
export function calculateWeightStatistics(
summaryData: SensorWeightSummaryResponse,
yesterdaySummary?: SensorWeightSummaryResponse
): WeightStatistics {
// Calculate weighted average weight - only include sensors with data
let totalWeight = 0;
let totalChickens = 0;
summaryData.data.forEach((sensor) => {
// Skip sensors with no data
if (sensor.avg_weight_actual > 0 && sensor.chicken_count > 0) {
totalWeight += sensor.avg_weight_actual * sensor.chicken_count;
totalChickens += sensor.chicken_count;
}
});
const avgWeight = totalChickens > 0 ? totalWeight / totalChickens : 0;
// Calculate daily gain
let yesterdayAvgWeight = 0;
if (yesterdaySummary) {
let yTotalWeight = 0;
let yTotalChickens = 0;
yesterdaySummary.data.forEach((sensor) => {
if (sensor.avg_weight_actual > 0 && sensor.chicken_count > 0) {
yTotalWeight += sensor.avg_weight_actual * sensor.chicken_count;
yTotalChickens += sensor.chicken_count;
}
});
yesterdayAvgWeight = yTotalChickens > 0 ? yTotalWeight / yTotalChickens : 0;
}
const dailyGain = yesterdayAvgWeight > 0 ? avgWeight - yesterdayAvgWeight : 0;
// Calculate uniformity (CV - Coefficient of Variation)
// Lower CV means better uniformity
const deviceWeights = summaryData.data
.filter((s) => s.avg_weight_actual > 0 && s.chicken_count > 0)
.map((s) => s.avg_weight_actual);
const mean = avgWeight;
const variance =
deviceWeights.length > 0
? deviceWeights.reduce((sum, w) => sum + Math.pow(w - mean, 2), 0) / deviceWeights.length
: 0;
const stdDev = Math.sqrt(variance);
const cv = mean > 0 ? (stdDev / mean) * 100 : 0;
const uniformity = Math.max(0, Math.min(100, 100 - cv)); // Convert CV to uniformity score
// Determine status
const getAdgStatus = (adg: number): 'ok' | 'warning' | 'critical' => {
if (adg >= 60) return 'ok';
if (adg >= 40) return 'warning';
return 'critical';
};
const getUniformityStatus = (uniformity: number): 'ok' | 'warning' | 'critical' => {
if (uniformity >= 85) return 'ok';
if (uniformity >= 75) return 'warning';
return 'critical';
};
return {
averageWeight: {
value: Math.round(avgWeight),
unit: 'g',
status: 'ok', // Will be updated with actual target comparison
},
avgDailyGain: {
value: Math.round(dailyGain),
unit: 'g/hari',
status: getAdgStatus(dailyGain),
},
uniformity: {
value: Math.round(uniformity),
unit: '%',
status: getUniformityStatus(uniformity),
},
};
}