180 lines
6.4 KiB
TypeScript
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';
|
|
}
|