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'; }