309 lines
7.9 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|