Files

180 lines
6.4 KiB
TypeScript

import { weightStandardData } from '../mockData/weightStandardData.ts';
// Using the 43g Day Old chick weight standard as default for this app
const standardTargetData = weightStandardData.data.find((row) => row.day === 43)?.values;
/** Converts grams to kg via string math to avoid floating-point artifacts. */
function formatGramsAsKgExact(grams: number): string {
const isNegative = grams < 0;
const absValue = Math.abs(grams);
const parts = String(absValue).split('.');
const intPart = parts[0] ?? '0';
const fracPart = parts[1] ?? '';
const kgDecimalIndex = intPart.length - 3;
const allDigits = intPart + fracPart;
let kgStr: string;
if (kgDecimalIndex <= 0) {
kgStr = `0.${'0'.repeat(-kgDecimalIndex)}${allDigits}`;
} else {
kgStr = `${allDigits.slice(0, kgDecimalIndex)}.${allDigits.slice(kgDecimalIndex)}`;
}
const [kgInt, kgFrac = ''] = kgStr.split('.');
const formatted = kgFrac
? `${Number(kgInt).toLocaleString('id-ID')},${kgFrac}`
: Number(kgInt).toLocaleString('id-ID');
return isNegative ? `-${formatted}` : formatted;
}
/** Formats a number exactly while keeping locale-friendly separators. */
function formatExactNumber(value: number): string {
const isNegative = value < 0;
const absValue = Math.abs(value);
const parts = String(absValue).split('.');
const intPart = parts[0] ?? '0';
const fracPart = parts[1] ?? '';
const formatted = fracPart
? `${Number(intPart).toLocaleString('id-ID')},${fracPart}`
: Number(intPart).toLocaleString('id-ID');
return isNegative ? `-${formatted}` : formatted;
}
/**
* Gets the target weight for a specific day in the cycle.
* @param day The day of the cycle (e.g., 1 to 35).
* @returns The target weight in grams, or null if not available.
*/
export const getTargetWeightForDay = (day: number): number | null => {
if (!standardTargetData) {
return null;
}
// day is 1-based, array is 0-based
if (day > 0 && day <= standardTargetData.length) {
return standardTargetData[day - 1] ?? null;
}
return null;
};
/**
* Formats weight values with intelligent unit conversion.
* Converts grams to kilograms when value exceeds 999g.
* Uses smart decimals: whole numbers show no decimals, fractional show 1 decimal.
*
* @param grams - The weight value in grams
* @param options - Optional formatting configuration
* @returns Formatted weight string with unit (e.g., "750 g", "1,6 kg", "450 kg")
*
* @example
* formatWeight(450) // "450 g"
* formatWeight(1583) // "1,6 kg"
* formatWeight(450000) // "450 kg"
* formatWeight(750, { suffix: '/ekor' }) // "750 g/ekor"
* formatWeight(1500.19, { preservePrecision: true }) // "1,50019 kg"
* formatWeight(187855, { preservePrecision: true }) // "187,855 kg"
*/
export function formatWeight(
grams: number | null | undefined,
options?: {
suffix?: string; // Optional suffix like "/ekor"
forceDecimals?: boolean; // Force 1 decimal even for whole numbers
maxDecimals?: number; // Override decimal precision (default: 1)
preservePrecision?: boolean; // Show value as-is without rounding
}
): string {
// Handle edge cases
if (grams == null || grams < 0) {
return `0 g${options?.suffix || ''}`;
}
const suffix = options?.suffix || '';
if (options?.preservePrecision) {
if (grams <= 999) {
return `${formatExactNumber(grams)} g${suffix}`;
}
return `${formatGramsAsKgExact(grams)} kg${suffix}`;
}
// For values <= 999g, display as grams
if (grams <= 999) {
const formatted = Math.round(grams).toLocaleString('id-ID');
return `${formatted} g${suffix}`;
}
// For values > 999g, convert to kg
const kg = grams / 1000;
const maxDecimals = options?.maxDecimals ?? 1;
// Smart decimals: hide decimals for whole numbers unless forced
const isWholeNumber = kg % 1 === 0;
const shouldShowDecimals = options?.forceDecimals || !isWholeNumber;
const minDecimals = shouldShowDecimals ? maxDecimals : 0;
const formatted = kg.toLocaleString('id-ID', {
minimumFractionDigits: minDecimals,
maximumFractionDigits: maxDecimals,
});
return `${formatted} kg${suffix}`;
}
/** CP 707 mendefinisikan keseragaman sebagai persen ayam dalam +/-10% dari bobot rata-rata. */
export const CP707_UNIFORMITY_BAND_PERCENT = 10;
/** Ambang CP 707: >80% dianggap seragam. */
export const CP707_UNIFORMITY_TARGET = 80;
/**
* Abramowitz & Stegun 7.1.26 -- galat maksimum 1.5e-7, jauh lebih kecil
* daripada ketidakpastian data timbangan itu sendiri.
*/
function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
const z = Math.abs(x);
const t = 1 / (1 + 0.3275911 * z);
const poly =
t *
(0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
return sign * (1 - poly * Math.exp(-z * z));
}
/**
* Keseragaman bobot menurut standar CP 707: persen ayam yang bobotnya berada
* dalam +/-10% dari rata-rata.
*
* Field `uniformity` bawaan API `distribusi_bobot` TIDAK dipakai karena
* memakai band +/-5% -- setengah lebih sempit dari CP 707 -- sehingga nilainya
* (median 70 hari = 39.8) tidak sebanding dengan ambang 80% milik CP 707.
*
* Perhitungan memakai `avg` dan `Standard_deviasi` asli dari API, bukan bucket
* distribusi, karena bucket selebar 50 g jauh melebihi lebar band di awal
* siklus (pada bobot 75 g, band +/-10% hanya 15 g) sehingga hasil dari bucket
* tidak dapat dipakai.
*
* @returns persentase 0-100, atau null bila data tidak memadai.
*/
export function calculateCp707Uniformity(
avg: number | null | undefined,
standardDeviation: number | null | undefined
): number | null {
if (typeof avg !== 'number' || !Number.isFinite(avg) || avg <= 0) return null;
if (typeof standardDeviation !== 'number' || !Number.isFinite(standardDeviation)) return null;
if (standardDeviation < 0) return null;
// Sebaran tanpa variasi: seluruh ayam otomatis berada di dalam band.
if (standardDeviation === 0) return 100;
const halfBand = (CP707_UNIFORMITY_BAND_PERCENT / 100) * avg;
const pct = erf(halfBand / (standardDeviation * Math.SQRT2)) * 100;
return Math.max(0, Math.min(100, pct));
}
/** Status kartu keseragaman berdasarkan ambang CP 707. */
export function getCp707UniformityStatus(uniformity: number | null): 'ok' | 'warning' | 'critical' {
if (uniformity === null) return 'critical';
if (uniformity >= CP707_UNIFORMITY_TARGET) return 'ok';
if (uniformity >= CP707_UNIFORMITY_TARGET - 10) return 'warning';
return 'critical';
}