mirror of
https://github.com/acamarata/pray-calc-ml.git
synced 2026-06-30 19:04:26 +00:00
Weighted least-squares calibration of Islamic prayer time depression angles from observed mosque announcement data. Uses golden-section search to minimize the sum of squared residuals independently for Fajr and Isha. Internal Jean Meeus solar ephemeris — zero runtime dependencies. API: calibrateAngles, scoreAngles, predictFajr, predictIsha. Full TypeScript, dual CJS/ESM via tsup. 32 ESM tests, 6 CJS tests, all passing on Node 20/22/24.
73 lines
2.3 KiB
TypeScript
73 lines
2.3 KiB
TypeScript
/**
|
|
* Score an existing set of angles against observations.
|
|
*
|
|
* Use this to evaluate how well a known method (e.g. ISNA's 15°/15°)
|
|
* fits a collection of observed mosque announcements before calibrating.
|
|
*/
|
|
|
|
import { predictFajr, predictIsha } from './predict.js';
|
|
import type { Observation } from './types.js';
|
|
|
|
export interface ScoreResult {
|
|
/** Weighted RMS error across all observations, in minutes. */
|
|
rmsMinutes: number;
|
|
/** Mean signed error for Fajr in minutes (positive = predicted late). */
|
|
fajrBiasMinutes: number;
|
|
/** Mean signed error for Isha in minutes. */
|
|
ishaBiasMinutes: number;
|
|
/** Per-observation residuals in minutes. */
|
|
residuals: Array<{ fajrMin: number | null; ishaMin: number | null }>;
|
|
}
|
|
|
|
/**
|
|
* Evaluate fixed depression angles against observed prayer times.
|
|
*
|
|
* @param observations - Observed Fajr/Isha times.
|
|
* @param fajrAngle - Fajr depression angle in degrees.
|
|
* @param ishaAngle - Isha depression angle in degrees.
|
|
*/
|
|
export function scoreAngles(
|
|
observations: Observation[],
|
|
fajrAngle: number,
|
|
ishaAngle: number,
|
|
): ScoreResult {
|
|
let totalWeightedSS = 0;
|
|
let totalWeight = 0;
|
|
let fajrWeightedBias = 0, fajrWeightCount = 0;
|
|
let ishaWeightedBias = 0, ishaWeightCount = 0;
|
|
|
|
const residuals = observations.map(o => {
|
|
const w = o.weight ?? 1;
|
|
let fajrMin: number | null = null;
|
|
let ishaMin: number | null = null;
|
|
|
|
if (o.fajr !== undefined) {
|
|
const pred = predictFajr(o.date, o.lat, o.lng, o.tz, fajrAngle);
|
|
if (isFinite(pred)) {
|
|
fajrMin = (pred - o.fajr) * 60;
|
|
totalWeightedSS += w * fajrMin * fajrMin;
|
|
totalWeight += w;
|
|
fajrWeightedBias += w * fajrMin;
|
|
fajrWeightCount += w;
|
|
}
|
|
}
|
|
if (o.isha !== undefined) {
|
|
const pred = predictIsha(o.date, o.lat, o.lng, o.tz, ishaAngle);
|
|
if (isFinite(pred)) {
|
|
ishaMin = (pred - o.isha) * 60;
|
|
totalWeightedSS += w * ishaMin * ishaMin;
|
|
totalWeight += w;
|
|
ishaWeightedBias += w * ishaMin;
|
|
ishaWeightCount += w;
|
|
}
|
|
}
|
|
return { fajrMin, ishaMin };
|
|
});
|
|
|
|
return {
|
|
rmsMinutes: totalWeight > 0 ? Math.sqrt(totalWeightedSS / totalWeight) : 0,
|
|
fajrBiasMinutes: fajrWeightCount > 0 ? fajrWeightedBias / fajrWeightCount : 0,
|
|
ishaBiasMinutes: ishaWeightCount > 0 ? ishaWeightedBias / ishaWeightCount : 0,
|
|
residuals,
|
|
};
|
|
}
|