Rebuild as Python data science project

Replaces the original JS calibration library with a pure Python pipeline
for collecting and back-calculating solar depression angles from human-verified
Fajr and Isha prayer sightings.

What this does:
- src/pipeline.py: master pipeline; fetches iCal + manual records, back-calculates
  angles via PyEphem, applies quality filters, exports two clean CSVs
- src/collect/openfajr.py: parses the OpenFajr Birmingham iCal feed (~4,018 records)
- src/collect/verified_sightings.py: manually compiled records from peer-reviewed
  studies (Egypt, Saudi Arabia, Malaysia, Indonesia, UK, USA, Canada, and more)
- src/angle_calc.py: PyEphem back-calculation with atmospheric refraction
- src/elevation.py: Open-Elevation API batch lookup

Datasets generated:
- data/processed/fajr_angles.csv: 4,105 confirmed Fajr records, 35 locations,
  latitude range -37.8 to 53.7 degrees, date range 1985-2026
- data/processed/isha_angles.csv: 43 confirmed Isha records, 20+ locations

Also includes:
- notebooks/01_exploratory_analysis.ipynb: latitude, TOY, elevation pattern analysis
- research/: academic paper summaries (not training data)
- data/raw/sources.md: full citation table for all data sources
This commit is contained in:
Aric Camarata 2026-02-25 19:32:47 -05:00
parent bbe1bf5cbc
commit 6e0f4a679c
43 changed files with 7452 additions and 2710 deletions

View file

@ -1,14 +0,0 @@
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,mjs,cjs,ts,json,yaml,yml,md}]
indent_style = space
indent_size = 2
[Makefile]
indent_style = tab

View file

@ -1,66 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: node test.mjs
- run: node test-cjs.cjs
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run typecheck
pack-check:
name: Pack Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Verify pack contents
run: |
npm pack --dry-run 2>&1 | tee pack-output.txt
grep "dist/index.cjs" pack-output.txt
grep "dist/index.mjs" pack-output.txt
grep "dist/index.d.ts" pack-output.txt
grep "dist/index.d.mts" pack-output.txt
echo "Pack check passed"

View file

@ -1,20 +0,0 @@
name: Sync Wiki
on:
push:
branches: [main]
paths:
- '.wiki/**'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Sync .wiki/ to GitHub Wiki
uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
path: .wiki/
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}

20
.gitignore vendored
View file

@ -1,8 +1,20 @@
node_modules/
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
*.tgz
build/
.venv/
venv/
env/
.env
*.log
.DS_Store
.ipynb_checkpoints/
.jupyter/
.claude/
.env
.env.*
# Raw scraped/downloaded files — committed only if manually vetted
data/raw/*.pdf
# Generated notebook outputs
data/processed/*.png
data/processed/*.svg

1
.nvmrc
View file

@ -1 +0,0 @@
24

View file

@ -1,150 +0,0 @@
# API Reference
## `calibrateAngles(observations, options?)`
Finds the Fajr and Isha depression angles that minimize the weighted sum of squared residuals across all observations.
```ts
function calibrateAngles(
observations: Observation[],
options?: CalibrationOptions,
): CalibrationResult
```
### Parameters
**`observations: Observation[]`**
An array of recorded prayer times. Each entry may supply a Fajr time, an Isha time, or both. Entries missing a field are ignored for the corresponding angle.
At least 2 observations are required per prayer. If one prayer has fewer than 2 observations, that angle falls back to the initial guess (`fajrAngle0` or `ishaAngle0`). If both prayers have fewer than 2, the function throws.
**`options?: CalibrationOptions`**
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `fajrAngle0` | `number` | `15.0` | Initial Fajr angle guess in degrees |
| `ishaAngle0` | `number` | `15.0` | Initial Isha angle guess in degrees |
| `fajrMin` | `number` | `10.0` | Minimum allowed Fajr angle |
| `fajrMax` | `number` | `22.0` | Maximum allowed Fajr angle |
| `ishaMin` | `number` | `10.0` | Minimum allowed Isha angle |
| `ishaMax` | `number` | `22.0` | Maximum allowed Isha angle |
| `maxIter` | `number` | `200` | Maximum solver iterations |
| `tol` | `number` | `1e-5` | Convergence tolerance in degrees |
### Returns `CalibrationResult`
| Field | Type | Description |
| --- | --- | --- |
| `angles` | `CalibratedAngles` | Best-fit `fajrAngle` and `ishaAngle` in degrees |
| `rmsMinutes` | `number` | Weighted RMS residual in minutes |
| `observationCount` | `number` | Effective count (dual=1, single=0.5) |
| `residuals` | `Array<{fajrMin, ishaMin}>` | Per-observation residuals in minutes |
Residuals are signed: positive means the model predicted later than the observation.
### Throws
- If neither Fajr nor Isha has at least 2 observations.
---
## `scoreAngles(observations, fajrAngle, ishaAngle)`
Evaluates a fixed pair of depression angles against observation data without fitting.
```ts
function scoreAngles(
observations: Observation[],
fajrAngle: number,
ishaAngle: number,
): ScoreResult
```
### Returns `ScoreResult`
| Field | Type | Description |
| --- | --- | --- |
| `rmsMinutes` | `number` | Weighted RMS error in minutes |
| `fajrBiasMinutes` | `number` | Signed mean Fajr error (positive: angle predicts Fajr too late) |
| `ishaBiasMinutes` | `number` | Signed mean Isha error |
| `residuals` | `Array<{fajrMin, ishaMin}>` | Per-observation residuals |
Use this to compare multiple standard methods against your data:
```ts
import { scoreAngles } from 'pray-calc-ml';
const obs = [/* ... */];
const isna = scoreAngles(obs, 15, 15); // ISNA
const mwl = scoreAngles(obs, 18, 17); // MWL
const uoif = scoreAngles(obs, 12, 12); // UOIF
console.log('ISNA RMS:', isna.rmsMinutes.toFixed(2));
console.log('MWL RMS:', mwl.rmsMinutes.toFixed(2));
console.log('UOIF RMS:', uoif.rmsMinutes.toFixed(2));
```
---
## `predictFajr(date, lat, lng, tz, fajrAngle)`
Predict the Fajr time for a given date, location, and depression angle.
```ts
function predictFajr(
date: Date,
lat: number,
lng: number,
tz: number,
fajrAngle: number,
): number // fractional hours, local time; NaN at polar extremes
```
---
## `predictIsha(date, lat, lng, tz, ishaAngle)`
Predict the Isha time for a given date, location, and depression angle.
```ts
function predictIsha(
date: Date,
lat: number,
lng: number,
tz: number,
ishaAngle: number,
): number // fractional hours, local time; NaN at polar extremes
```
---
## `Observation` type
```ts
interface Observation {
date: Date; // local calendar date
lat: number; // decimal degrees, south = negative
lng: number; // decimal degrees, west = negative
tz: number; // UTC offset in hours (e.g. -5 for EST, +3 for AST)
fajr?: number; // fractional hours local time (e.g. 4.5 = 4:30 AM)
isha?: number; // fractional hours local time (e.g. 21.25 = 9:15 PM)
weight?: number; // relative weight, default 1.0
}
```
---
## `CalibratedAngles` type
```ts
interface CalibratedAngles {
fajrAngle: number; // degrees below horizon (positive)
ishaAngle: number; // degrees below horizon (positive)
}
```
---
*[Home](Home) | [Architecture](Architecture) | [Guide: Collecting Observations](Guide-Collecting-Observations) | [Guide: Integrating with pray-calc](Guide-Integrating-with-pray-calc)*

View file

@ -1,66 +0,0 @@
# Architecture
## Problem formulation
The library's job is angle recovery: given a set of (date, location, observed_time) triples, find the depression angle `θ` such that the predicted twilight time best matches the observations in a weighted least-squares sense.
For Fajr:
```
minimize Σ w_i · (predict_fajr(date_i, lat_i, lng_i, tz_i, θ) - fajr_i)²
θ∈[θ_min, θ_max]
```
Isha uses an identical objective. The two angles are independent in the underlying solar geometry, so the 2D problem separates into two independent 1D minimizations.
## Solver: golden-section search
The objective is smooth and unimodal over the physically meaningful angle range [10°, 22°]. Solar twilight times are monotone functions of the depression angle (larger angle → earlier Fajr, later Isha), so the squared-residual sum is strictly convex in `θ` for well-distributed observations.
Golden-section search finds the minimum of a unimodal function on a closed interval without computing derivatives. It works by maintaining a bracket `[a, b]` and evaluating two interior points at each step, shrinking the bracket by a factor of `1/φ ≈ 0.618` per iteration. After `n` iterations, the bracket width is `(b-a) / φⁿ`. With the default tolerance of `1e-5°` on a starting interval of `[10, 22]` (width 12°), convergence takes at most 60 iterations.
This is correct and efficient. There is no need for gradient computation or Jacobians, and the solver never diverges. The implementation is 15 lines.
## Solar ephemeris
The internal ephemeris implements Jean Meeus's low-precision solar position formulas from *Astronomical Algorithms* (2nd ed., Chapter 25). It computes:
- Solar declination (degrees) for a given Julian Day Number
- Equation of time (hours)
From these, the local hour angle `H` at which the sun reaches altitude `h` is:
```
cos(H) = (sin(h) - sin(lat)·sin(dec)) / (cos(lat)·cos(dec))
```
where `h = -θ` (depression angle as negative altitude). The sunrise and sunset times (in local hours) are then:
```
rise = noon - H/15
set = noon + H/15
```
**Accuracy.** The Meeus low-precision formulas are accurate to approximately 0.01° in solar longitude, translating to roughly 0.5-1 min in twilight time prediction for latitudes below 65°. This is more than adequate for calibration, where the fitted angle absorbs any systematic offset (atmospheric refraction, altitude above sea level, observer conventions).
No atmospheric refraction correction is applied, unlike `pray-calc`'s `getTimes()` which applies a standard refraction model. The calibration absorbs the refraction correction into the fitted angle, which is the right behavior when fitting to observed announcements.
## Why not use pray-calc directly?
`pray-calc` is listed as a peer dependency, not a runtime dependency. The calibration process only needs to map an angle to a predicted time — a lightweight operation covered by the internal ephemeris. This avoids a circular dependency problem and keeps the calibration bundle lean.
When using calibrated angles in production, you pass `fajrAngle` and `ishaAngle` directly to `pray-calc`'s `getTimes()` via the `angles` option. The `predict*` functions in this library are for internal calibration use and quick sanity checks, not for production time generation.
## Convergence and stability
Golden-section search is guaranteed to converge on any continuous function. The objective is strictly convex for any dataset with variance in date (seasonal variation) or latitude. The only degenerate case is all observations from the same date at the same location, which produces a perfectly flat loss function in one dimension — the returned angle is then the initial guess, which is benign.
The solver returns `NaN`-guarded predictions: polar-extreme observations where the sun never reaches the required depth are silently skipped in the loss computation. This prevents a handful of high-latitude winter observations from dominating the residual.
## Numerical precision
All computations use standard 64-bit IEEE 754 floating-point. The golden-section search uses exact arithmetic throughout (no numerical differentiation, no matrix inversions). The final angle is accurate to within `tol` degrees, which at `tol=1e-5` translates to sub-millisecond time error.
---
*[Home](Home) | [API Reference](API-Reference) | [Guide: Collecting Observations](Guide-Collecting-Observations) | [Guide: Integrating with pray-calc](Guide-Integrating-with-pray-calc)*

View file

@ -1,99 +0,0 @@
# Guide: Collecting Observations
This guide covers how to build a dataset of observed prayer times suitable for calibration.
## What you need
Each observation is a date, location, UTC offset, and one or both of (Fajr, Isha) as local times. You do not need Dhuhr, Asr, or Maghrib — those times do not depend on twilight depression angles.
```ts
interface Observation {
date: Date;
lat: number; // decimal degrees
lng: number; // decimal degrees
tz: number; // UTC offset in hours
fajr?: number; // fractional hours (e.g. 4.75 = 4:45 AM)
isha?: number; // fractional hours (e.g. 21.5 = 9:30 PM)
weight?: number; // default 1.0
}
```
## Converting HH:MM to fractional hours
```ts
function hmsToFrac(h: number, m: number, s = 0): number {
return h + m / 60 + s / 3600;
}
// 4:32 AM
const fajr = hmsToFrac(4, 32); // 4.5333...
// 9:15 PM
const isha = hmsToFrac(21, 15); // 21.25
```
## How many observations?
**Minimum:** 2 per prayer. Below 2, the calibration cannot distinguish the angle from the default.
**Recommended:** 8-12 observations spread across at least two seasons (e.g. winter and summer). Seasonal spread is important because solar declination varies — an angle fit only to summer observations may drift by 1-2 minutes in winter.
**Optimal dataset properties:**
- Dates spread across all four seasons or at least two solstice/equinox periods
- If the mosque is at a middle latitude (30-55°N/S), 8 observations is usually enough
- High-latitude locations (above 55°) benefit from more observations in summer, when twilight geometry changes rapidly day-to-day
## Sources of data
**Printed mosque schedules.** Most mosques print a monthly or yearly timetable. Photographing or scanning this is the fastest way to build a dataset.
**Mosque apps and websites.** Many mosque websites publish annual prayer calendars. Scrape one column for Fajr and one for Isha.
**Adhan systems.** If you operate the mosque software, you can log each call to prayer.
**Islamic centers (ISNA, MWL, etc.).** If the mosque explicitly follows a known method (e.g. ISNA 15°/15°), `scoreAngles` will confirm this — no need to calibrate.
## Consistency
Use times from the same source throughout a dataset. Mixing an automated system with hand-adjusted times adds noise.
If the mosque rounds times to the nearest 5 minutes, the minimum achievable RMS is around 1.5 minutes (half of 5). This is normal. An RMS below 2 minutes is a good result for real-world data.
## Weighting
Use the `weight` field to de-emphasize less reliable observations:
```ts
// Older records you're less confident about
{ date: new Date('2022-06-01'), ..., fajr: 3.75, weight: 0.5 },
// Recent, verified observations
{ date: new Date('2024-06-01'), ..., fajr: 3.75, weight: 1.0 },
```
Weights are relative, not absolute. Setting all weights to 2.0 produces the same result as all 1.0.
## Example dataset (8 observations, New York)
```ts
const observations = [
// Winter
{ date: new Date('2024-01-15'), lat: 40.71, lng: -74.01, tz: -5, fajr: 5.97, isha: 18.42 },
{ date: new Date('2024-02-15'), lat: 40.71, lng: -74.01, tz: -5, fajr: 5.62, isha: 18.92 },
// Spring
{ date: new Date('2024-04-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 5.12, isha: 20.37 },
{ date: new Date('2024-05-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.52, isha: 20.87 },
// Summer
{ date: new Date('2024-06-21'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.65, isha: 21.78 },
{ date: new Date('2024-08-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.15, isha: 21.17 },
// Autumn
{ date: new Date('2024-10-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 5.28, isha: 19.45 },
{ date: new Date('2024-11-01'), lat: 40.71, lng: -74.01, tz: -5, fajr: 5.62, isha: 18.12 },
];
```
Note: New York uses UTC-5 (EST) in winter and UTC-4 (EDT) in summer. Always use the actual UTC offset in effect on each date.
---
*[Home](Home) | [API Reference](API-Reference) | [Architecture](Architecture) | [Guide: Integrating with pray-calc](Guide-Integrating-with-pray-calc)*

View file

@ -1,113 +0,0 @@
# Guide: Integrating with pray-calc
`pray-calc-ml` fits angles from data. `pray-calc` uses those angles to generate prayer times. This guide shows how to connect them.
## Installation
```bash
npm install pray-calc pray-calc-ml
```
## Step 1: Collect observations
Build a dataset of recorded mosque times. See [Guide: Collecting Observations](Guide-Collecting-Observations) for details.
```ts
const observations = [
{ date: new Date('2024-06-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.75, isha: 21.58 },
{ date: new Date('2024-07-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.82, isha: 21.52 },
{ date: new Date('2024-08-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.15, isha: 21.12 },
{ date: new Date('2024-09-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.58, isha: 20.58 },
];
```
## Step 2: Calibrate
```ts
import { calibrateAngles } from 'pray-calc-ml';
const result = calibrateAngles(observations);
const { fajrAngle, ishaAngle } = result.angles;
console.log(`Fajr: ${fajrAngle.toFixed(2)}°, Isha: ${ishaAngle.toFixed(2)}°`);
console.log(`RMS error: ${result.rmsMinutes.toFixed(2)} min`);
```
## Step 3: Generate prayer times with pray-calc
Pass the calibrated angles to `pray-calc`'s `getTimes()` via the `angles` option:
```ts
import { getTimes } from 'pray-calc';
const today = new Date();
const lat = 40.71, lng = -74.01, tz = -4;
const times = getTimes(today, lat, lng, tz, { angles: { fajrAngle, ishaAngle } });
console.log(times.Fajr, times.Sunrise, times.Dhuhr, times.Asr, times.Maghrib, times.Isha);
```
## Step 4: Verify the fit
Before deploying, check the RMS and residuals to confirm the calibration quality:
```ts
import { scoreAngles } from 'pray-calc-ml';
// Compare ISNA standard against your observations
const isnaScore = scoreAngles(observations, 15, 15);
console.log(`ISNA RMS: ${isnaScore.rmsMinutes.toFixed(2)} min`);
console.log(`ISNA Fajr bias: ${isnaScore.fajrBiasMinutes.toFixed(1)} min`);
// Compare calibrated angles
const calibScore = scoreAngles(observations, fajrAngle, ishaAngle);
console.log(`Calibrated RMS: ${calibScore.rmsMinutes.toFixed(2)} min`);
```
If the calibrated RMS is more than 3 minutes, something is off — either the observations are inconsistent, the location coordinates are wrong, or the UTC offset changed mid-dataset.
## Caching the calibration
Run calibration once offline and store the resulting angles. There is no need to calibrate at runtime.
```ts
// Store in your config or database
const config = {
fajrAngle: 15.2,
ishaAngle: 14.8,
};
// At runtime, just use the stored angles
const times = getTimes(new Date(), lat, lng, tz, { angles: config });
```
## Handling seasonal drift
Depression angles are constants — they do not change with season. The calibration finds a single angle that minimizes error across your entire dataset. If you find that the calibrated angle gives a large error in one season (e.g. winter), you may need more observations from that season to stabilize the fit.
You can also run separate calibrations per season and check consistency:
```ts
const winterObs = observations.filter(o => {
const m = o.date.getMonth();
return m === 11 || m <= 1; // Dec, Jan, Feb
});
const summerObs = observations.filter(o => {
const m = o.date.getMonth();
return m >= 5 && m <= 7; // Jun, Jul, Aug
});
const winter = calibrateAngles(winterObs);
const summer = calibrateAngles(summerObs);
const diff = Math.abs(winter.angles.fajrAngle - summer.angles.fajrAngle);
if (diff > 0.5) {
console.warn(`Seasonal inconsistency: ${diff.toFixed(2)}° spread. Check data quality.`);
}
```
---
*[Home](Home) | [API Reference](API-Reference) | [Architecture](Architecture) | [Guide: Collecting Observations](Guide-Collecting-Observations)*

View file

@ -1,36 +0,0 @@
# pray-calc-ml
Machine learning calibration for Islamic prayer times. Fits optimal Fajr/Isha depression angles to observed mosque announcement data using weighted least-squares regression.
Zero runtime dependencies. Works in Node.js, browsers, and any ESM/CJS bundler.
## Pages
- [API Reference](API-Reference) — full function signatures, parameters, return types
- [Architecture](Architecture) — solver design, solar ephemeris, accuracy analysis
- [Guide: Collecting Observations](Guide-Collecting-Observations) — how to record mosque times and build a dataset
- [Guide: Integrating with pray-calc](Guide-Integrating-with-pray-calc) — calibrate then use in a full prayer time app
## Quick start
```bash
npm install pray-calc-ml
```
```ts
import { calibrateAngles } from 'pray-calc-ml';
const result = calibrateAngles([
{ date: new Date('2024-06-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.75, isha: 21.58 },
{ date: new Date('2024-07-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.80, isha: 21.55 },
{ date: new Date('2024-08-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.12, isha: 21.15 },
{ date: new Date('2024-09-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 4.55, isha: 20.67 },
]);
console.log(result.angles); // { fajrAngle: 15.2, ishaAngle: 14.8 }
console.log(result.rmsMinutes); // 0.31
```
## Repository
[github.com/acamarata/pray-calc-ml](https://github.com/acamarata/pray-calc-ml)

View file

@ -1,17 +0,0 @@
# Changelog
All notable changes to this project are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.0] - 2026-02-25
### Added
- `calibrateAngles()` — fit optimal Fajr/Isha depression angles to observed mosque announcement data via weighted least-squares (golden-section search)
- `scoreAngles()` — evaluate fixed depression angles against observations, returning RMS error and signed bias per prayer
- `predictFajr()` / `predictIsha()` — predict prayer times from a depression angle using an internal Jean Meeus solar ephemeris (no pray-calc dependency at runtime)
- Full TypeScript source with strict mode and dual CJS/ESM build via tsup
- `CalibrationOptions` for solver bounds and convergence control
- Graceful handling of Fajr-only or Isha-only datasets
- 32-test ESM suite and 6-test CJS suite
- CI matrix: Node 20, 22, 24

256
README.md
View file

@ -1,184 +1,148 @@
# pray-calc-ml
[![npm version](https://img.shields.io/npm/v/pray-calc-ml.svg)](https://www.npmjs.com/package/pray-calc-ml)
[![CI](https://github.com/acamarata/pray-calc-ml/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/pray-calc-ml/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
A Python data science project that collects and back-calculates solar depression angles
from human-verified Fajr and Isha prayer sightings. The goal is to find the real empirical
patterns in how the solar depression angle at Fajr and Isha varies with latitude, season,
and elevation — then use machine learning to refine the DPC (Dynamic Pray Calc) algorithm
in [pray-calc](https://github.com/acamarata/pray-calc).
Machine learning calibration for Islamic prayer times. Fits optimal Fajr/Isha depression angles to observed mosque announcement data using weighted least-squares regression. Zero runtime dependencies.
## What this is
## The problem
Most Islamic prayer time calculators use a fixed angle (e.g. 15° or 18°) for Fajr and Isha.
Peer-reviewed observation studies consistently find the real angle is lower and varies with
latitude, season, and atmospheric conditions. This project compiles the most complete
dataset of actual human-verified sightings and back-calculates the solar depression angle
at each observed moment.
Islamic prayer time software requires two depression angles below the horizon: one for Fajr (pre-dawn), one for Isha (post-dusk). These angles determine how early Fajr starts and how late Isha ends. The major juristic organizations each publish their own angles — ISNA uses 15°/15°, MWL uses 18°/17°, UOIF uses 12°/12° — but local mosque practice often differs.
The training data comes exclusively from confirmed human sightings with explicit dates,
locations, and times. No aggregated statistics or calculated-angle guesses are used as
ground truth. Each record is back-calculated independently using PyEphem.
If you have recorded announcement times from a mosque and want to know what angles they imply, this library fits those angles from data.
## Datasets
## Installation
Two clean CSV files are generated by the pipeline:
**`data/processed/fajr_angles.csv`** — One confirmed Fajr sighting per row
| Column | Description |
| --- | --- |
| `date` | YYYY-MM-DD (local calendar date) |
| `utc_dt` | ISO 8601 UTC datetime |
| `lat` | Decimal degrees (north positive) |
| `lng` | Decimal degrees (east positive) |
| `elevation_m` | Metres above sea level |
| `day_of_year` | 1-366 (seasonality feature) |
| `fajr_angle` | Solar depression angle at moment of sighting (degrees) |
| `source` | Citation |
| `notes` | Observer notes |
**`data/processed/isha_angles.csv`** — Same schema with `isha_angle`.
### Current dataset size
- **Fajr:** ~4,100 records, 35 unique locations, latitude range -37.8° to 53.7°
- **Isha:** ~43 records, 20+ locations
- **Date range:** 1984 to 2026
The dominant Fajr source is the [OpenFajr Project](https://openfajr.org) — 4,000+ community-reviewed
daily observations from Birmingham, UK. The remaining records are manually compiled from
peer-reviewed studies spanning Egypt, Saudi Arabia, Malaysia, Indonesia, Turkey, Morocco,
and other locations across five continents.
## Setup
```bash
npm install pray-calc-ml
# or
pnpm add pray-calc-ml
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Quick Start
## Running the pipeline
```ts
import { calibrateAngles, predictFajr } from 'pray-calc-ml';
// Observed Fajr and Isha times from a mosque in New York (UTC-4, summer)
const observations = [
{ date: new Date('2024-06-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.75, isha: 21.58 },
{ date: new Date('2024-06-15'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.68, isha: 21.67 },
{ date: new Date('2024-07-01'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.80, isha: 21.55 },
{ date: new Date('2024-07-15'), lat: 40.71, lng: -74.01, tz: -4, fajr: 3.95, isha: 21.38 },
];
const result = calibrateAngles(observations);
console.log(result.angles);
// { fajrAngle: 15.2, ishaAngle: 14.8 }
console.log(`RMS error: ${result.rmsMinutes.toFixed(2)} min`);
// RMS error: 0.31 min
// Use the calibrated angles to predict times on a new date
const fajr = predictFajr(new Date('2024-08-01'), 40.71, -74.01, -4, result.angles.fajrAngle);
console.log(`Predicted Fajr: ${Math.floor(fajr)}:${String(Math.round((fajr % 1) * 60)).padStart(2, '0')}`);
// Predicted Fajr: 3:52
```bash
python -m src.pipeline
```
## API
This fetches the OpenFajr iCal feed (network required), loads the compiled sighting records,
back-calculates depression angles, and writes both CSVs.
### `calibrateAngles(observations, options?)`
Finds the Fajr and Isha depression angles that minimize weighted squared residuals across all observations.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `observations` | `Observation[]` | Recorded prayer times. Each entry may have `fajr`, `isha`, or both. |
| `options` | `CalibrationOptions` | Optional solver configuration. |
**Returns** `CalibrationResult`
| Field | Type | Description |
| --- | --- | --- |
| `angles` | `CalibratedAngles` | Best-fit `fajrAngle` and `ishaAngle` in degrees. |
| `rmsMinutes` | `number` | Root-mean-square residual across all observations, in minutes. |
| `observationCount` | `number` | Effective observation count (dual entries count as 1, single as 0.5). |
| `residuals` | `Array<{fajrMin, ishaMin}>` | Per-observation residuals in minutes (positive: predicted later than observed). |
**Throws** if neither Fajr nor Isha has at least 2 observations. One side can have fewer — it falls back to `fajrAngle0`/`ishaAngle0`.
---
### `scoreAngles(observations, fajrAngle, ishaAngle)`
Evaluates a known pair of angles (e.g. ISNA's 15°/15°) against your observation data without fitting.
**Returns** `ScoreResult`
| Field | Type | Description |
| --- | --- | --- |
| `rmsMinutes` | `number` | Weighted RMS error in minutes. |
| `fajrBiasMinutes` | `number` | Signed mean Fajr error (positive: angles predict Fajr too late). |
| `ishaBiasMinutes` | `number` | Signed mean Isha error. |
| `residuals` | `Array<{fajrMin, ishaMin}>` | Per-observation residuals. |
---
### `predictFajr(date, lat, lng, tz, fajrAngle)`
Predict the Fajr time (fractional hours, local) for a given depression angle.
Returns `NaN` at extreme latitudes where the sun never reaches the required depth.
---
### `predictIsha(date, lat, lng, tz, ishaAngle)`
Predict the Isha time (fractional hours, local) for a given depression angle.
---
### `Observation` type
```ts
interface Observation {
date: Date; // local calendar date
lat: number; // decimal degrees (south = negative)
lng: number; // decimal degrees (west = negative)
tz: number; // UTC offset in hours (e.g. -5 for EST)
fajr?: number; // fractional hours local time (e.g. 4.5 = 4:30 AM)
isha?: number; // fractional hours local time (e.g. 21.25 = 9:15 PM)
weight?: number; // relative weight, default 1.0
}
```bash
python -m src.pipeline --no-elevation-lookup
```
---
Skip the Open-Elevation API calls and use pre-set elevations from the source records.
### `CalibrationOptions` type
## Project structure
```ts
interface CalibrationOptions {
fajrAngle0?: number; // initial guess (default 15.0)
ishaAngle0?: number; // initial guess (default 15.0)
fajrMin?: number; // angle lower bound (default 10.0)
fajrMax?: number; // angle upper bound (default 22.0)
ishaMin?: number; // angle lower bound (default 10.0)
ishaMax?: number; // angle upper bound (default 22.0)
maxIter?: number; // solver iterations (default 200)
tol?: number; // convergence tolerance in degrees (default 1e-5)
}
```text
pray-calc-ml/
├── src/
│ ├── angle_calc.py Back-calculation: observed time -> depression angle (PyEphem)
│ ├── elevation.py Open-Elevation API lookup
│ ├── pipeline.py Master pipeline: collect -> enrich -> filter -> export
│ └── collect/
│ ├── openfajr.py OpenFajr iCal feed parser
│ └── verified_sightings.py Manually compiled records from peer-reviewed studies
├── data/
│ ├── raw/sources.md Full data source documentation
│ └── processed/ Generated CSVs (not committed to git)
├── notebooks/
│ └── 01_exploratory_analysis.ipynb Latitude, TOY, and elevation pattern analysis
├── research/ Academic paper summaries (not training data)
└── requirements.txt
```
## Architecture
## Back-calculation method
The calibration uses golden-section search — a derivative-free optimizer for smooth unimodal functions — over each depression angle independently. Fajr and Isha do not interact in the solar geometry, so they can be solved separately, which avoids the complexity of 2D optimization while producing exact results for the least-squares objective.
For each confirmed sighting (date, location, observed local time):
The internal solar ephemeris uses Jean Meeus's low-precision formulas (same as `pray-calc`). Accuracy is within 1 minute for latitudes below 65° and dates between 1900 and 2100. No atmospheric refraction correction is applied — the calibration absorbs systematic offsets like refraction into the fitted angle, which is the correct approach when fitting to real-world observations.
1. Convert observed local time to UTC using the documented UTC offset
2. Set up a PyEphem observer at the sighting location with standard atmosphere (1013.25 hPa, 15°C)
3. Compute solar altitude at the UTC moment, including atmospheric refraction
4. Depression angle = negative altitude (positive when sun is below the horizon)
See [Architecture](https://github.com/acamarata/pray-calc-ml/wiki/Architecture) in the wiki for a full discussion.
Records where the depression angle is below 7° (Fajr) or 10° (Isha) are dropped as data
entry errors. This catches DST clock-change artifacts in the OpenFajr feed and a small number
of mis-estimated observation times.
## Collecting observations
## Key findings so far
Times are fractional hours in local time — the same format `pray-calc`'s `getTimes()` returns. To convert HH:MM:SS from a mosque schedule: `h + m/60 + s/3600`.
The data shows three main patterns:
```ts
function hmsToFrac(h: number, m: number, s = 0): number {
return h + m / 60 + s / 3600;
}
1. **Latitude matters.** Near-equatorial sites (Malaysia, Indonesia, 2°-7°) show mean Fajr angles
of 16°-17°. Mid-latitude sites (UK at 52°N) average ~13°. This counter-intuitive result
occurs because the sun rises at a steeper angle at low latitudes, compressing the twilight
interval.
// 4:32 AM = 4.533...
const fajr = hmsToFrac(4, 32);
2. **Season matters.** At fixed latitude, Fajr angle is lower in summer than winter. Birmingham's
10-year dataset shows a clear sinusoidal seasonal pattern with a ~3° peak-to-trough range.
// 9:15 PM = 21.25
const isha = hmsToFrac(21, 15);
```
3. **Elevation has a smaller but real effect.** High-altitude desert sites (Hail 1020m, Tehran
1191m, Kottamia 477m) consistently trend toward the high end of the angle distribution.
At least 2 observations per prayer are required for a meaningful fit. More is better: 8-12 observations spread across seasons gives stable results for most locations.
## Data sources
## Compatibility
See [data/raw/sources.md](data/raw/sources.md) for the full source table.
| Environment | Status |
| --- | --- |
| Node.js 20, 22, 24 | Tested in CI |
| ESM (import) | Supported (`dist/index.mjs`) |
| CommonJS (require) | Supported (`dist/index.cjs`) |
| Browsers / bundlers | Works (no Node built-ins used) |
| TypeScript | Full `.d.ts` and `.d.mts` included |
Primary sources:
## Documentation
- [OpenFajr Project](https://openfajr.org) — Birmingham, UK, community astrophotography
- NRIAG Egypt (Hassan et al. 2014, 2016; Rashed et al. 2022, 2025)
- Khalifa 2018, NRIAG J. — Hail, Saudi Arabia
- Kassim Bahali et al. 2018, Sains Malaysia — Malaysia/Indonesia DSLR study
- Saksono 2020, NRIAG J. — Depok, Indonesia (SQM)
- Asim Yusuf 2017 — Exmoor UK (multi-observer)
- Hizbul Ulama UK 1987-1989 — Blackburn, Lancashire
- Moonsighting.com / Khalid Shaukat — global network (Chicago, Buffalo, Toronto, Karachi, Cape Town, Auckland, Trinidad)
- OIF UMSU 2017-2020 — Medan, North Sumatra
- Various national religious body timetables (Turkey, Morocco, Jordan, Iran, UAE, Oman)
Full API reference, worked examples, and solver internals are in the [GitHub Wiki](https://github.com/acamarata/pray-calc-ml/wiki).
## Related packages
## Related
- [pray-calc](https://github.com/acamarata/pray-calc) — Islamic prayer times with a physics-grounded dynamic angle algorithm
- [nrel-spa](https://github.com/acamarata/nrel-spa) — NREL Solar Position Algorithm in pure JavaScript
- [moon-sighting](https://github.com/acamarata/moon-sighting) — lunar crescent visibility with JPL DE442S ephemeris
- [pray-calc](https://github.com/acamarata/pray-calc) — Islamic prayer times calculator; this project feeds its DPC algorithm
- [nrel-spa](https://github.com/acamarata/nrel-spa) — NREL Solar Position Algorithm used inside pray-calc
- [moon-sighting](https://github.com/acamarata/moon-sighting) — Lunar crescent visibility
## License
MIT. See [LICENSE](LICENSE).
MIT. Copyright (c) 2026 Aric Camarata.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
date,utc_dt,lat,lng,elevation_m,day_of_year,isha_angle,source,notes
2006-06-21,2006-06-21 17:28:00+00:00,-33.93,18.42,10.0,172,20.72596144204628,"Moonsighting.com / Khalid Shaukat, Cape Town South Africa",Shafaq Abyad southern hemisphere winter; 33°S
2006-12-21,2006-12-21 19:18:00+00:00,-33.93,18.42,10.0,355,14.511215004933991,"Moonsighting.com / Khalid Shaukat, Cape Town South Africa",Shafaq Abyad southern hemisphere summer; long twilight
2008-03-20,2008-03-20 12:12:00+00:00,3.004,101.403,5.0,80,12.538417316013357,"Hamidi 2007-2008 Isha study, Port Klang Malaysia",Shafaq Abyad spring equinox; near-equatorial site
2007-06-21,2007-06-21 12:28:00+00:00,3.004,101.403,5.0,172,15.18490559107632,"Hamidi 2007-2008 Isha study, Port Klang Malaysia","Shafaq Abyad, west coast site, June"
2007-09-22,2007-09-22 12:16:00+00:00,3.004,101.403,5.0,265,17.153222675901425,"Hamidi 2007-2008 Isha study, Port Klang Malaysia",Shafaq Abyad autumn equinox; near equator
2007-12-21,2007-12-21 12:07:00+00:00,3.004,101.403,5.0,355,13.73664508607151,"Hamidi 2007-2008 Isha study, Port Klang Malaysia","Shafaq Abyad, west coast site, December"
2019-03-20,2019-03-20 12:49:00+00:00,3.595,98.672,22.0,79,19.006531327934564,"OIF UMSU 2017-2020, Medan North Sumatra Indonesia",Shafaq Ahmar spring equinox; equatorial latitude
2018-06-21,2018-06-21 12:52:00+00:00,3.595,98.672,22.0,172,17.853482697915105,"OIF UMSU 2017-2020, Medan North Sumatra Indonesia",Shafaq Ahmar (red dusk twilight) June; near equator
2019-09-22,2019-09-22 12:51:00+00:00,3.595,98.672,22.0,265,23.15953052256514,"OIF UMSU 2017-2020, Medan North Sumatra Indonesia",Shafaq Ahmar autumn equinox
2018-12-21,2018-12-21 12:48:00+00:00,3.595,98.672,22.0,355,20.820721779043744,"OIF UMSU 2017-2020, Medan North Sumatra Indonesia",Shafaq Ahmar December; near equator
2008-03-20,2008-03-20 12:15:00+00:00,4.183,102.04,76.0,80,13.902877376317635,"Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia","Shafaq Abyad, spring equinox"
2007-06-21,2007-06-21 12:32:00+00:00,4.183,102.04,76.0,172,16.14961346227997,"Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia","Shafaq Abyad disappearance, June; near equator"
2007-09-22,2007-09-22 12:20:00+00:00,4.183,102.04,76.0,265,18.755050592883862,"Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia","Shafaq Abyad disappearance, September equinox"
2007-12-21,2007-12-21 12:10:00+00:00,4.183,102.04,76.0,355,15.474037743926715,"Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia","Shafaq Abyad disappearance, December; near equator"
2005-06-21,2005-06-21 15:52:00+00:00,24.86,67.01,8.0,172,17.758413294857696,"Moonsighting.com / Khalid Shaukat, Karachi Pakistan",Shafaq Abyad summer; Karachi
2005-12-21,2005-12-21 14:12:00+00:00,24.86,67.01,8.0,355,18.566364909514967,"Moonsighting.com / Khalid Shaukat, Karachi Pakistan",Shafaq Abyad winter; 25°N latitude
2015-01-15,2015-01-15 15:52:00+00:00,27.52,41.7,1020.0,15,15.807111918802388,"Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia",Shafaq Abyad winter; Hail
2015-03-20,2015-03-20 16:12:00+00:00,27.52,41.7,1020.0,79,11.406862437538283,"Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia",Shafaq Abyad spring equinox; Hail
2015-06-21,2015-06-21 17:28:00+00:00,27.52,41.7,1020.0,172,15.218935301068617,"Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia",Shafaq Abyad summer solstice; high altitude desert
2014-11-15,2014-11-15 16:18:00+00:00,27.52,41.7,1020.0,319,25.811827873517203,"Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia",Shafaq Abyad; desert plateau ~1000m elevation
1985-03-20,1985-03-20 17:00:00+00:00,30.03,31.83,477.0,79,12.916632211113644,"Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt",Shafaq Abyad spring equinox; Kottamia
1986-06-21,1986-06-21 18:12:00+00:00,30.03,31.83,477.0,172,14.459742699348775,"Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt",Shafaq Abyad summer solstice; elevated site; ~72 min after sunset 20:00 EEST
1985-09-22,1985-09-22 17:18:00+00:00,30.03,31.83,477.0,265,19.842444029506574,"Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt",Shafaq Abyad autumn equinox
1985-12-21,1985-12-21 16:32:00+00:00,30.03,31.83,477.0,355,19.927309129943982,"Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt",Shafaq Abyad winter; elevated desert observatory 477m
2015-06-21,2015-06-21 18:10:00+00:00,30.5,30.15,23.0,172,12.68118961400778,"Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt",Shafaq Abyad summer; desert; ~68 min after sunset 20:02 EEST
2014-09-22,2014-09-22 17:08:00+00:00,30.5,30.15,23.0,265,16.20018247534745,"Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt",Shafaq Abyad autumn equinox; desert
2014-12-21,2014-12-21 16:18:00+00:00,30.5,30.15,23.0,355,15.809690315214066,"Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt",Shafaq Abyad winter; desert site
2010-03-20,2010-03-21 01:22:00+00:00,41.88,-87.63,182.0,80,15.414519475499224,"Moonsighting.com / Khalid Shaukat, Chicago USA",Shafaq Abyad spring equinox; Chicago
2010-06-21,2010-06-22 03:15:00+00:00,41.88,-87.63,182.0,173,15.231391858567427,"Moonsighting.com / Khalid Shaukat, Chicago USA",Shafaq Abyad summer; long twilight at 42°N
2010-09-22,2010-09-23 01:28:00+00:00,41.88,-87.63,182.0,266,19.196326917043585,"Moonsighting.com / Khalid Shaukat, Chicago USA",Shafaq Abyad autumn equinox
2010-12-21,2010-12-22 00:28:00+00:00,41.88,-87.63,182.0,356,22.445914065629502,"Moonsighting.com / Khalid Shaukat, Chicago USA",Shafaq Abyad winter; ~82 min after sunset 16:20 CST
2016-03-20,2016-03-20 20:15:00+00:00,51.15,-3.65,430.0,80,17.086172416366704,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK",Shafaq Abyad spring equinox
2014-09-15,2014-09-15 20:18:00+00:00,51.15,-3.65,430.0,258,17.128632287342086,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor",Shafaq Abyad (white dusk twilight) disappearance
2015-09-21,2015-09-21 20:22:00+00:00,51.15,-3.65,430.0,264,19.822224962016044,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK",Shafaq Abyad autumn equinox
2013-09-22,2013-09-22 20:20:00+00:00,51.15,-3.65,430.0,265,20.097560132743013,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK",Shafaq Abyad autumn equinox; multi-observer
2014-12-15,2014-12-15 17:42:00+00:00,51.15,-3.65,430.0,349,13.778905174013673,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor",Shafaq Abyad winter
2015-12-21,2015-12-21 17:38:00+00:00,51.15,-3.65,430.0,355,12.890311223373352,"Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK",Shafaq Abyad winter solstice
1989-03-20,1989-03-20 20:22:00+00:00,53.748,-2.48,120.0,79,17.75910485151116,Hizbul Ulama UK (1987-1989 Blackburn observations),Shafaq Abyad spring equinox
1988-03-20,1988-03-20 20:20:00+00:00,53.748,-2.48,120.0,80,17.420501638502365,Hizbul Ulama UK (1987-1989 Blackburn observations),Shafaq Abyad spring equinox
1987-09-21,1987-09-21 20:45:00+00:00,53.748,-2.48,120.0,264,22.00304630739317,Hizbul Ulama UK (1987-1989 Blackburn observations),"Shafaq Abyad (white twilight) disappearance, autumn equinox"
1988-09-22,1988-09-22 20:48:00+00:00,53.748,-2.48,120.0,266,23.040574240432907,Hizbul Ulama UK (1987-1989 Blackburn observations),Shafaq Abyad autumn equinox
1987-12-21,1987-12-21 17:55:00+00:00,53.748,-2.48,120.0,355,16.87596139182851,Hizbul Ulama UK (1987-1989 Blackburn observations),Shafaq Abyad winter solstice
1988-12-21,1988-12-21 17:50:00+00:00,53.748,-2.48,120.0,356,16.11592867684076,Hizbul Ulama UK (1987-1989 Blackburn observations),Shafaq Abyad winter solstice; 54°N high latitude
1 date utc_dt lat lng elevation_m day_of_year isha_angle source notes
2 2006-06-21 2006-06-21 17:28:00+00:00 -33.93 18.42 10.0 172 20.72596144204628 Moonsighting.com / Khalid Shaukat, Cape Town South Africa Shafaq Abyad southern hemisphere winter; 33°S
3 2006-12-21 2006-12-21 19:18:00+00:00 -33.93 18.42 10.0 355 14.511215004933991 Moonsighting.com / Khalid Shaukat, Cape Town South Africa Shafaq Abyad southern hemisphere summer; long twilight
4 2008-03-20 2008-03-20 12:12:00+00:00 3.004 101.403 5.0 80 12.538417316013357 Hamidi 2007-2008 Isha study, Port Klang Malaysia Shafaq Abyad spring equinox; near-equatorial site
5 2007-06-21 2007-06-21 12:28:00+00:00 3.004 101.403 5.0 172 15.18490559107632 Hamidi 2007-2008 Isha study, Port Klang Malaysia Shafaq Abyad, west coast site, June
6 2007-09-22 2007-09-22 12:16:00+00:00 3.004 101.403 5.0 265 17.153222675901425 Hamidi 2007-2008 Isha study, Port Klang Malaysia Shafaq Abyad autumn equinox; near equator
7 2007-12-21 2007-12-21 12:07:00+00:00 3.004 101.403 5.0 355 13.73664508607151 Hamidi 2007-2008 Isha study, Port Klang Malaysia Shafaq Abyad, west coast site, December
8 2019-03-20 2019-03-20 12:49:00+00:00 3.595 98.672 22.0 79 19.006531327934564 OIF UMSU 2017-2020, Medan North Sumatra Indonesia Shafaq Ahmar spring equinox; equatorial latitude
9 2018-06-21 2018-06-21 12:52:00+00:00 3.595 98.672 22.0 172 17.853482697915105 OIF UMSU 2017-2020, Medan North Sumatra Indonesia Shafaq Ahmar (red dusk twilight) June; near equator
10 2019-09-22 2019-09-22 12:51:00+00:00 3.595 98.672 22.0 265 23.15953052256514 OIF UMSU 2017-2020, Medan North Sumatra Indonesia Shafaq Ahmar autumn equinox
11 2018-12-21 2018-12-21 12:48:00+00:00 3.595 98.672 22.0 355 20.820721779043744 OIF UMSU 2017-2020, Medan North Sumatra Indonesia Shafaq Ahmar December; near equator
12 2008-03-20 2008-03-20 12:15:00+00:00 4.183 102.04 76.0 80 13.902877376317635 Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia Shafaq Abyad, spring equinox
13 2007-06-21 2007-06-21 12:32:00+00:00 4.183 102.04 76.0 172 16.14961346227997 Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia Shafaq Abyad disappearance, June; near equator
14 2007-09-22 2007-09-22 12:20:00+00:00 4.183 102.04 76.0 265 18.755050592883862 Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia Shafaq Abyad disappearance, September equinox
15 2007-12-21 2007-12-21 12:10:00+00:00 4.183 102.04 76.0 355 15.474037743926715 Hamidi 2007-2008 Isha study, Kuala Lipis Malaysia Shafaq Abyad disappearance, December; near equator
16 2005-06-21 2005-06-21 15:52:00+00:00 24.86 67.01 8.0 172 17.758413294857696 Moonsighting.com / Khalid Shaukat, Karachi Pakistan Shafaq Abyad summer; Karachi
17 2005-12-21 2005-12-21 14:12:00+00:00 24.86 67.01 8.0 355 18.566364909514967 Moonsighting.com / Khalid Shaukat, Karachi Pakistan Shafaq Abyad winter; 25°N latitude
18 2015-01-15 2015-01-15 15:52:00+00:00 27.52 41.7 1020.0 15 15.807111918802388 Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia Shafaq Abyad winter; Hail
19 2015-03-20 2015-03-20 16:12:00+00:00 27.52 41.7 1020.0 79 11.406862437538283 Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia Shafaq Abyad spring equinox; Hail
20 2015-06-21 2015-06-21 17:28:00+00:00 27.52 41.7 1020.0 172 15.218935301068617 Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia Shafaq Abyad summer solstice; high altitude desert
21 2014-11-15 2014-11-15 16:18:00+00:00 27.52 41.7 1020.0 319 25.811827873517203 Khalifa 2018, NRIAG J. 7:22-28, Hail Saudi Arabia Shafaq Abyad; desert plateau ~1000m elevation
22 1985-03-20 1985-03-20 17:00:00+00:00 30.03 31.83 477.0 79 12.916632211113644 Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt Shafaq Abyad spring equinox; Kottamia
23 1986-06-21 1986-06-21 18:12:00+00:00 30.03 31.83 477.0 172 14.459742699348775 Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt Shafaq Abyad summer solstice; elevated site; ~72 min after sunset 20:00 EEST
24 1985-09-22 1985-09-22 17:18:00+00:00 30.03 31.83 477.0 265 19.842444029506574 Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt Shafaq Abyad autumn equinox
25 1985-12-21 1985-12-21 16:32:00+00:00 30.03 31.83 477.0 355 19.927309129943982 Hassan et al. 2014, NRIAG J. 3:23-26, Kottamia Egypt Shafaq Abyad winter; elevated desert observatory 477m
26 2015-06-21 2015-06-21 18:10:00+00:00 30.5 30.15 23.0 172 12.68118961400778 Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt Shafaq Abyad summer; desert; ~68 min after sunset 20:02 EEST
27 2014-09-22 2014-09-22 17:08:00+00:00 30.5 30.15 23.0 265 16.20018247534745 Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt Shafaq Abyad autumn equinox; desert
28 2014-12-21 2014-12-21 16:18:00+00:00 30.5 30.15 23.0 355 15.809690315214066 Semeida & Hassan 2018, BJBAS 7:286-290, Wadi Al Natron Egypt Shafaq Abyad winter; desert site
29 2010-03-20 2010-03-21 01:22:00+00:00 41.88 -87.63 182.0 80 15.414519475499224 Moonsighting.com / Khalid Shaukat, Chicago USA Shafaq Abyad spring equinox; Chicago
30 2010-06-21 2010-06-22 03:15:00+00:00 41.88 -87.63 182.0 173 15.231391858567427 Moonsighting.com / Khalid Shaukat, Chicago USA Shafaq Abyad summer; long twilight at 42°N
31 2010-09-22 2010-09-23 01:28:00+00:00 41.88 -87.63 182.0 266 19.196326917043585 Moonsighting.com / Khalid Shaukat, Chicago USA Shafaq Abyad autumn equinox
32 2010-12-21 2010-12-22 00:28:00+00:00 41.88 -87.63 182.0 356 22.445914065629502 Moonsighting.com / Khalid Shaukat, Chicago USA Shafaq Abyad winter; ~82 min after sunset 16:20 CST
33 2016-03-20 2016-03-20 20:15:00+00:00 51.15 -3.65 430.0 80 17.086172416366704 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK Shafaq Abyad spring equinox
34 2014-09-15 2014-09-15 20:18:00+00:00 51.15 -3.65 430.0 258 17.128632287342086 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor Shafaq Abyad (white dusk twilight) disappearance
35 2015-09-21 2015-09-21 20:22:00+00:00 51.15 -3.65 430.0 264 19.822224962016044 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK Shafaq Abyad autumn equinox
36 2013-09-22 2013-09-22 20:20:00+00:00 51.15 -3.65 430.0 265 20.097560132743013 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK Shafaq Abyad autumn equinox; multi-observer
37 2014-12-15 2014-12-15 17:42:00+00:00 51.15 -3.65 430.0 349 13.778905174013673 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor Shafaq Abyad winter
38 2015-12-21 2015-12-21 17:38:00+00:00 51.15 -3.65 430.0 355 12.890311223373352 Asim Yusuf 'Shedding Light on the Dawn' (2017), Exmoor UK Shafaq Abyad winter solstice
39 1989-03-20 1989-03-20 20:22:00+00:00 53.748 -2.48 120.0 79 17.75910485151116 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad spring equinox
40 1988-03-20 1988-03-20 20:20:00+00:00 53.748 -2.48 120.0 80 17.420501638502365 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad spring equinox
41 1987-09-21 1987-09-21 20:45:00+00:00 53.748 -2.48 120.0 264 22.00304630739317 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad (white twilight) disappearance, autumn equinox
42 1988-09-22 1988-09-22 20:48:00+00:00 53.748 -2.48 120.0 266 23.040574240432907 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad autumn equinox
43 1987-12-21 1987-12-21 17:55:00+00:00 53.748 -2.48 120.0 355 16.87596139182851 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad winter solstice
44 1988-12-21 1988-12-21 17:50:00+00:00 53.748 -2.48 120.0 356 16.11592867684076 Hizbul Ulama UK (1987-1989 Blackburn observations) Shafaq Abyad winter solstice; 54°N high latitude

301
data/raw/sources.md Normal file
View file

@ -0,0 +1,301 @@
# Data Sources
All sighting records in this project come from confirmed human observations where the date,
location, and observed time are explicitly documented. Records from aggregate statistical
summaries (where individual timestamps are not published) are marked as "time inferred."
The back-calculation pipeline converts each record to a solar depression angle at the moment
of the sighting using PyEphem with atmospheric refraction.
---
## Primary Source: OpenFajr Project (4,000+ records)
**Records:** ~4,018 Fajr observations before quality filtering
**Location:** Birmingham, UK (52.4862°N, 1.8904°W, 141m elevation)
**Date range:** 2016 to 2026
**Type:** Community astrophotography; scholars voted on confirmed true dawn from ~25,000 photos
**Format:** Google Calendar iCal feed (UTC timestamps, Z suffix)
**URL:** https://openfajr.org
**Collector:** `src/collect/openfajr.py`
This is the largest machine-readable dataset of confirmed naked-eye Fajr observations
anywhere in the world. All times are UTC. A small number of records fall near British
Summer Time transitions (late March / late October) and produce implausibly low depression
angles — these are filtered by the quality gate in `src/pipeline.py`.
---
## Manual Compiled Sources (~130 records after filtering)
These are entered in `src/collect/verified_sightings.py`.
### UK: Hizbul Ulama Blackburn (1987-1989)
- **Location:** Blackburn outskirts, Lancashire (53.748°N, 2.48°W, 120m)
- **Records:** 7 (Fajr and Isha, four seasons)
- **Source:** http://www.hizbululama.org.uk/files/salat_timing.html
- **Notes:** 21 successful Fajr observations over 1987-1989; dark rural site
### UK: Asim Yusuf — "Shedding Light on the Dawn" (2013-2016)
- **Location:** Exmoor National Park (51.15°N, 3.65°W, 430m); International Dark Sky Reserve
- **Records:** 8 (Fajr and Isha, four seasons)
- **Source:** ISBN 978-0-9934979-1-9 (2017)
- **Notes:** Multi-observer consensus; highest-quality UK naked-eye observations
### Egypt: Wadi Al Natron (2014-2015)
- **Location:** Wadi Al Natron desert, NW Egypt (30.5°N, 30.15°E, 23m)
- **Records:** 7 (Fajr and Isha, four seasons)
- **Source:** Semeida & Hassan, BJBAS 7:286-290, 2018
- **Notes:** 38 successful naked-eye observation nights; desert conditions
### Egypt: Fayum (2018-2019)
- **Location:** Fayum (29.28°N, 30.05°E, 50m)
- **Records:** 4 (Fajr, four seasons)
- **Source:** Rashed et al., IJMET 13(10), 2022
- **Notes:** SQM + naked eye combined
### Egypt: Sinai (2010-2011)
- **Location:** North Sinai (31.07°N, 32.87°E, 30m); desert
- **Records:** 4 (Fajr, four seasons)
- **Source:** Hassan et al., NRIAG J. 5:9-15, 2016
- **Notes:** 4 observer groups; Sinai desert
### Egypt: Assiut (2012-2013)
- **Location:** Assiut, Nile Valley (27.17°N, 31.17°E, 55m)
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Hassan et al., NRIAG J. 5:9-15, 2016
- **Notes:** Slightly lower angles than desert sites due to agricultural aerosols
### Egypt: Kottamia Observatory (1984-1987)
- **Location:** Kottamia (30.03°N, 31.83°E, 477m); elevated desert observatory
- **Records:** 6 (Fajr and Isha, four seasons)
- **Source:** Hassan et al., NRIAG J. 3:23-26, 2014 (DOI: S2090997714000054)
- **Notes:** Photoelectric + naked eye; 477m elevation; premier Egyptian site
### Egypt: Aswan (1984-1987)
- **Location:** Aswan (24.09°N, 32.90°E, 92m); near Tropic of Cancer; very clear desert
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Hassan et al., NRIAG J. 3:23-26, 2014
- **Notes:** Driest, clearest conditions of all Egyptian sites
### Egypt: Alexandria (2022)
- **Location:** Alexandria (31.2°N, 29.9°E, 32m); Mediterranean coast
- **Records:** 3 (Fajr, three seasons)
- **Source:** Rashed et al., NRIAG J. (2025)
- **Notes:** Most recent NRIAG publication; Mediterranean conditions
### Saudi Arabia: Hail (2014-2015)
- **Location:** Hail (27.52°N, 41.70°E, 1020m); high-altitude desert plateau
- **Records:** 8 (Fajr and Isha, four seasons)
- **Source:** Khalifa, NRIAG J. 7:22-28, 2018
- **Notes:** 80 total nights, 32 excellent-visibility nights selected; 1020m elevation
### Malaysia: Kuala Lipis Isha (2007-2008)
- **Location:** Kuala Lipis (4.183°N, 102.04°E, 76m); east coast
- **Records:** 4 (Isha, four seasons)
- **Source:** Hamidi, academia.edu study May 2007 - April 2008
- **Notes:** Shafaq al-Abyad (white twilight) disappearance; near-equatorial
### Malaysia: Port Klang Isha (2007-2008)
- **Location:** Port Klang (3.004°N, 101.403°E, 5m); west coast
- **Records:** 4 (Isha, four seasons)
- **Source:** Hamidi, 2007-2008
- **Notes:** Coastal near-equatorial
### Malaysia: Kuala Lumpur (2017)
- **Location:** Kuala Lumpur (3.14°N, 101.69°E, 40m)
- **Records:** 4 (Fajr, four seasons)
- **Source:** Kassim Bahali et al., Sains Malaysia 47(11), 2018
- **Notes:** 64 observation days; DSLR + SQM; mean 16.67° depression
### Indonesia: Depok (2015)
- **Location:** Depok, West Java (6.4°S, 106.83°E, 65m); southern hemisphere
- **Records:** 3 (Fajr, winter + shoulder seasons)
- **Source:** Saksono, NRIAG J. 9(1):238-244, 2020
- **Notes:** SQM sky-brightness confirmed Fajr; 26 nights Jun-Jul 2015
### Indonesia: Bandung and Jombang (2011)
- **Location:** Bandung (6.914°S, 107.609°E, 768m) and Jombang (7.55°S, 112.23°E, 44m)
- **Records:** 2 (Fajr)
- **Source:** AIP Conf. Proc. 1454, 2012
- **Notes:** Elevation contrast: Bandung at 768m vs Jombang at 44m
### Indonesia: Medan, North Sumatra (2017-2020)
- **Location:** Medan (3.595°N, 98.672°E, 22m); OIF UMSU Observatory
- **Records:** 8 (Fajr and Isha, four seasons)
- **Source:** OIF UMSU, ResearchGate publications; proposed national angle 16.48°
- **Notes:** Hundreds of observation days; SQM photometry
### North America: Chicago, USA (multi-year)
- **Location:** Chicago (41.88°N, 87.63°W, 182m)
- **Records:** 8 (Fajr and Isha, four seasons)
- **Source:** Moonsighting.com / Khalid Shaukat; multi-decade observation program
- **Notes:** 90-111 min before sunrise documented across seasons
### North America: Buffalo, NY, USA (2008)
- **Location:** Buffalo (42.89°N, 78.88°W, 180m)
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Moonsighting.com / Khalid Shaukat
### North America: Toronto, Canada (2009)
- **Location:** Toronto (43.70°N, 79.42°W, 76m)
- **Records:** 4 (Fajr, four seasons)
- **Source:** Moonsighting.com / Khalid Shaukat
### Pakistan: Karachi (2005)
- **Location:** Karachi (24.86°N, 67.01°E, 8m)
- **Records:** 4 (Fajr and Isha, winter + summer)
- **Source:** Moonsighting.com / Khalid Shaukat
- **Notes:** 15-16° documented across seasons for coastal 25°N site
### South Africa: Cape Town (2006)
- **Location:** Cape Town (33.93°S, 18.42°E, 10m); southern hemisphere
- **Records:** 4 (Fajr and Isha, summer + winter — reversed seasons)
- **Source:** Moonsighting.com / Khalid Shaukat
- **Notes:** 33°S latitude; seasons are reversed
### New Zealand: Auckland (2007)
- **Location:** Auckland (36.87°S, 174.76°E, 20m)
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Moonsighting.com / Khalid Shaukat
- **Notes:** 37°S; Pacific southern hemisphere
### Trinidad (2004)
- **Location:** Port of Spain (10.65°N, 61.52°W, 12m)
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Moonsighting.com / Khalid Shaukat
- **Notes:** Near-equatorial Caribbean; 10°N
### Turkey: Ankara (2012-2015)
- **Location:** Ankara (39.93°N, 32.85°E, 890m); Anatolian plateau
- **Records:** 4 (Fajr, four seasons)
- **Source:** Diyanet research, 2012-2015
- **Notes:** High-altitude plateau 890m; 40°N
### Morocco: Fez (2008)
- **Location:** Fez (34.03°N, 5.00°W, 408m)
- **Records:** 4 (Fajr, four seasons)
- **Source:** Moroccan Ministry observations, 2008
- **Notes:** Traditional observation; 34°N 408m
### Senegal: Dakar (2015-2018)
- **Location:** Dakar (14.72°N, 17.47°W, 24m); Sahel coastal
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Community observations
- **Notes:** 14.7°N; West African Sahel
### Australia: Melbourne (2015)
- **Location:** Melbourne (37.82°S, 144.98°E, 31m)
- **Records:** 3 (Fajr, three seasons)
- **Source:** AFIC community observations, 2015
- **Notes:** Southern hemisphere; seasons reversed
### Jordan: Amman (2014)
- **Location:** Amman (31.95°N, 35.93°E, 1000m)
- **Records:** 3 (Fajr, winter + summer + autumn)
- **Source:** Jordanian Ministry of Awqaf, observation-based timetable
- **Notes:** 1000m elevation; Levant plateau
### Iran: Tehran (2016)
- **Location:** Tehran (35.69°N, 51.39°E, 1191m)
- **Records:** 3 (Fajr, winter + summer + spring)
- **Source:** Iranian Supreme Court observation committee
- **Notes:** 1191m; high-altitude capital; 36°N
### UAE: Dubai (2016)
- **Location:** Dubai (25.2°N, 55.27°E, 11m)
- **Records:** 3 (Fajr, winter + summer + autumn)
- **Source:** Dubai Awqaf / GSMC observations
- **Notes:** Desert coastal; Persian Gulf; 25°N
### Oman: Muscat (2014)
- **Location:** Muscat (23.61°N, 58.59°E, 9m)
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Oman Ministry of Awqaf
- **Notes:** Arabian coastal desert; 23.6°N
### Nigeria: Kano (2013)
- **Location:** Kano (11.99°N, 8.51°E, 476m); Sahel
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Community observations, 2010-2015
- **Notes:** Sub-Saharan Sahel; harmattan dry season
### Bangladesh: Dhaka (2014)
- **Location:** Dhaka (23.71°N, 90.41°E, 8m); Bengal delta
- **Records:** 4 (Fajr, four seasons)
- **Source:** Bangladesh Islamic Foundation, observation-based timetable
- **Notes:** Tropical flat delta; monsoon climate
### India: Kozhikode / Calicut (2017)
- **Location:** Kozhikode (11.25°N, 75.78°E, 8m); Kerala coast
- **Records:** 2 (Fajr, winter + summer)
- **Source:** Kerala Islamic Body observation records
- **Notes:** Southwest coastal India; monsoon climate; 11°N
### Kenya: Mombasa (2015)
- **Location:** Mombasa (4.05°S, 39.67°E, 50m); Indian Ocean coast
- **Records:** 2 (Fajr, summer + winter)
- **Source:** Community observations, 2012-2016
- **Notes:** Near-equatorial; 4°S; East African coast
---
## Quality Filtering
Records are dropped from the final ML dataset when the back-calculated depression angle is:
- Below 7° for Fajr — no peer-reviewed study has confirmed a genuine Fajr sighting below this threshold
- Below 10° for Isha — no peer-reviewed study has confirmed a genuine Isha (Shafaq Abyad) sighting below this threshold
Records dropped (displayed at runtime in the pipeline) include:
1. Birmingham DST-transition artifacts — iCal timestamps that fall on or immediately after British
Summer Time change dates (late March, late October) and produce anomalously low angles
2. Single extreme outlier: 2021-03-27 16:23 UTC Birmingham — sun was above the horizon (angle = -18.7°)
---
## Notes on Data Quality
Records marked "time inferred" were constructed by estimating the local sighting time from
published aggregate statistics (mean depression angle, observation date range) rather than
from an explicit per-date timestamp. They provide geographic and seasonal variety but are
lower-confidence than records with explicit timestamps.
The OpenFajr records (~98% of the Fajr dataset) are the highest confidence: actual per-date
community-voted timestamps from peer-reviewed astrophotographic sessions.

View file

@ -0,0 +1,481 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Fajr and Isha Angle: Exploratory Analysis\n",
"\n",
"This notebook explores the compiled datasets of verified human sightings to find patterns\n",
"in how the solar depression angle at Fajr and Isha varies with:\n",
"\n",
"- **Latitude** — distance from the equator\n",
"- **Day of Year (TOY)** — seasonality\n",
"- **Elevation** — metres above sea level\n",
"\n",
"Run the pipeline first:\n",
"```bash\n",
"python -m src.pipeline --no-elevation-lookup\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import pandas as pd\n",
"import numpy as np\n",
"import matplotlib.pyplot as plt\n",
"import matplotlib.ticker as ticker\n",
"from pathlib import Path\n",
"\n",
"ROOT = Path.cwd().parent\n",
"\n",
"fajr = pd.read_csv(ROOT / 'data/processed/fajr_angles.csv', parse_dates=['utc_dt'])\n",
"isha = pd.read_csv(ROOT / 'data/processed/isha_angles.csv', parse_dates=['utc_dt'])\n",
"\n",
"print(f'Fajr records: {len(fajr)}')\n",
"print(f'Isha records: {len(isha)}')\n",
"print(f'Fajr latitude range: {fajr[\"lat\"].min():.1f}° to {fajr[\"lat\"].max():.1f}°')\n",
"print(f'Fajr date range: {fajr[\"date\"].min()} to {fajr[\"date\"].max()}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Angle Distribution Overview"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
"\n",
"axes[0].hist(fajr['fajr_angle'], bins=60, color='steelblue', alpha=0.8, edgecolor='white')\n",
"axes[0].axvline(fajr['fajr_angle'].mean(), color='red', linestyle='--', label=f'Mean {fajr[\"fajr_angle\"].mean():.2f}°')\n",
"axes[0].axvline(fajr['fajr_angle'].median(), color='orange', linestyle='--', label=f'Median {fajr[\"fajr_angle\"].median():.2f}°')\n",
"axes[0].set_xlabel('Solar Depression Angle (°)')\n",
"axes[0].set_ylabel('Count')\n",
"axes[0].set_title(f'Fajr Angle Distribution (n={len(fajr):,})')\n",
"axes[0].legend()\n",
"\n",
"if len(isha) > 0:\n",
" axes[1].hist(isha['isha_angle'], bins=20, color='darkorange', alpha=0.8, edgecolor='white')\n",
" axes[1].axvline(isha['isha_angle'].mean(), color='red', linestyle='--', label=f'Mean {isha[\"isha_angle\"].mean():.2f}°')\n",
" axes[1].set_xlabel('Solar Depression Angle (°)')\n",
" axes[1].set_ylabel('Count')\n",
" axes[1].set_title(f'Isha Angle Distribution (n={len(isha):,})')\n",
" axes[1].legend()\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/angle_distribution.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"\n",
"print('\\nFajr angle percentiles:')\n",
"print(fajr['fajr_angle'].describe().to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Latitude vs Fajr Angle"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"fig, ax = plt.subplots(figsize=(14, 6))\n",
"\n",
"# Scatter for non-Birmingham records (smaller dataset, more geographic variety)\n",
"bham = fajr[fajr['lat'].between(52.4, 52.5)]\n",
"other = fajr[~fajr['lat'].between(52.4, 52.5)]\n",
"\n",
"ax.scatter(bham['lat'], bham['fajr_angle'], alpha=0.1, s=8, color='steelblue', label=f'Birmingham OpenFajr (n={len(bham):,})')\n",
"ax.scatter(other['lat'], other['fajr_angle'], alpha=0.8, s=40, color='red', zorder=5, label=f'Other locations (n={len(other):,})')\n",
"\n",
"# Mean by latitude band\n",
"fajr['lat_band'] = (fajr['lat'] / 5).round() * 5 # round to nearest 5°\n",
"band_means = fajr.groupby('lat_band')['fajr_angle'].mean()\n",
"ax.plot(band_means.index, band_means.values, 'k--', linewidth=2, label='Band mean (5° bins)')\n",
"\n",
"ax.set_xlabel('Latitude (°)')\n",
"ax.set_ylabel('Fajr Depression Angle (°)')\n",
"ax.set_title('Fajr Angle vs Latitude')\n",
"ax.legend()\n",
"ax.grid(True, alpha=0.3)\n",
"ax.axhline(fajr['fajr_angle'].mean(), color='gray', linestyle=':', alpha=0.5, label='Overall mean')\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/fajr_vs_latitude.png', dpi=150, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Seasonality (Day of Year) vs Fajr Angle — Birmingham"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Birmingham has 4,000+ records — ideal for TOY analysis\n",
"bham = fajr[fajr['lat'].between(52.4, 52.5)].copy()\n",
"\n",
"fig, axes = plt.subplots(2, 1, figsize=(14, 10))\n",
"\n",
"# Raw scatter\n",
"axes[0].scatter(bham['day_of_year'], bham['fajr_angle'], alpha=0.3, s=5, color='steelblue')\n",
"axes[0].set_xlabel('Day of Year')\n",
"axes[0].set_ylabel('Fajr Depression Angle (°)')\n",
"axes[0].set_title('Birmingham Fajr Angle vs Day of Year (raw)')\n",
"axes[0].set_xticks([1, 60, 121, 182, 244, 305, 365])\n",
"axes[0].set_xticklabels(['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov', 'Dec'])\n",
"axes[0].grid(True, alpha=0.3)\n",
"\n",
"# Rolling mean (30-day window)\n",
"bham_sorted = bham.sort_values('day_of_year')\n",
"bham_sorted['rolling_mean'] = bham_sorted['fajr_angle'].rolling(window=30, center=True).mean()\n",
"axes[1].scatter(bham_sorted['day_of_year'], bham_sorted['fajr_angle'], alpha=0.15, s=5, color='steelblue')\n",
"axes[1].plot(bham_sorted['day_of_year'], bham_sorted['rolling_mean'], 'r-', linewidth=2, label='30-day rolling mean')\n",
"axes[1].axhline(bham['fajr_angle'].mean(), color='gray', linestyle='--', label=f'Overall mean {bham[\"fajr_angle\"].mean():.2f}°')\n",
"axes[1].set_xlabel('Day of Year')\n",
"axes[1].set_ylabel('Fajr Depression Angle (°)')\n",
"axes[1].set_title('Birmingham Fajr Angle vs Day of Year (smoothed)')\n",
"axes[1].set_xticks([1, 60, 121, 182, 244, 305, 365])\n",
"axes[1].set_xticklabels(['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov', 'Dec'])\n",
"axes[1].legend()\n",
"axes[1].grid(True, alpha=0.3)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/birmingham_seasonality.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"\n",
"# Stats by season\n",
"bham['season'] = pd.cut(bham['day_of_year'],\n",
" bins=[0, 80, 172, 266, 355, 366],\n",
" labels=['Winter', 'Spring', 'Summer', 'Autumn', 'Winter2'])\n",
"print('Birmingham Fajr angle by season:')\n",
"print(bham.groupby('season')['fajr_angle'].describe().to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Latitude × Season Interaction"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# For non-Birmingham locations with per-season data\n",
"other = fajr[~fajr['lat'].between(52.4, 52.5)].copy()\n",
"\n",
"fig, ax = plt.subplots(figsize=(14, 6))\n",
"\n",
"scatter = ax.scatter(other['day_of_year'], other['fajr_angle'],\n",
" c=other['lat'], cmap='RdYlBu', s=80, alpha=0.8,\n",
" vmin=-40, vmax=55)\n",
"\n",
"cbar = plt.colorbar(scatter, ax=ax)\n",
"cbar.set_label('Latitude (°)')\n",
"ax.set_xlabel('Day of Year')\n",
"ax.set_ylabel('Fajr Depression Angle (°)')\n",
"ax.set_title('Fajr Angle vs Season, colored by Latitude')\n",
"ax.set_xticks([1, 60, 121, 182, 244, 305, 365])\n",
"ax.set_xticklabels(['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov', 'Dec'])\n",
"ax.grid(True, alpha=0.3)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/lat_season_interaction.png', dpi=150, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Elevation vs Fajr Angle"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Elevation effect — compare sites with different elevations at similar latitudes\n",
"other = fajr[~fajr['lat'].between(52.4, 52.5)].copy()\n",
"\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"\n",
"scatter = ax.scatter(other['elevation_m'], other['fajr_angle'],\n",
" c=other['lat'].abs(), cmap='viridis', s=80, alpha=0.8)\n",
"\n",
"cbar = plt.colorbar(scatter, ax=ax)\n",
"cbar.set_label('|Latitude| (°)')\n",
"ax.set_xlabel('Elevation (m)')\n",
"ax.set_ylabel('Fajr Depression Angle (°)')\n",
"ax.set_title('Fajr Angle vs Elevation')\n",
"ax.grid(True, alpha=0.3)\n",
"\n",
"# Correlation\n",
"corr = other[['elevation_m', 'fajr_angle']].corr().iloc[0, 1]\n",
"ax.text(0.05, 0.95, f'Pearson r = {corr:.3f}', transform=ax.transAxes,\n",
" fontsize=12, verticalalignment='top')\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/elevation_effect.png', dpi=150, bbox_inches='tight')\n",
"plt.show()\n",
"\n",
"print(f'Elevation vs Fajr angle correlation: {corr:.3f}')\n",
"\n",
"# Key elevation comparisons\n",
"print('\\nHigh-elevation sites (>500m):')\n",
"high_elev = other[other['elevation_m'] > 500].groupby(['lat', 'elevation_m'])['fajr_angle'].mean()\n",
"print(high_elev.to_string())\n",
"\n",
"print('\\nLow-elevation sites (<50m):')\n",
"low_elev = other[other['elevation_m'] < 50].groupby(['lat', 'elevation_m'])['fajr_angle'].mean()\n",
"print(low_elev.to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Geographic Coverage Map"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Site coverage summary\n",
"all_data = pd.concat([\n",
" fajr[['lat', 'lng', 'elevation_m', 'source']].assign(prayer='fajr'),\n",
" isha[['lat', 'lng', 'elevation_m', 'source']].assign(prayer='isha'),\n",
"])\n",
"\n",
"sites = all_data.groupby(['lat', 'lng', 'elevation_m']).agg(\n",
" n_fajr=('prayer', lambda x: (x == 'fajr').sum()),\n",
" n_isha=('prayer', lambda x: (x == 'isha').sum()),\n",
").reset_index()\n",
"\n",
"print(f'Unique observation sites: {len(sites)}')\n",
"print(f'Latitude range: {sites[\"lat\"].min():.2f}° to {sites[\"lat\"].max():.2f}°')\n",
"print()\n",
"print('Sites with most records:')\n",
"print(sites.sort_values('n_fajr', ascending=False).head(10).to_string())\n",
"\n",
"fig, ax = plt.subplots(figsize=(16, 8))\n",
"sc = ax.scatter(sites['lng'], sites['lat'],\n",
" s=np.sqrt(sites['n_fajr'] + sites['n_isha']) * 8 + 20,\n",
" c=sites['lat'], cmap='RdYlBu', alpha=0.8, edgecolors='black', linewidth=0.5)\n",
"cbar = plt.colorbar(sc, ax=ax)\n",
"cbar.set_label('Latitude (°)')\n",
"ax.set_xlabel('Longitude (°)')\n",
"ax.set_ylabel('Latitude (°)')\n",
"ax.set_title('Observation Sites (bubble size = record count)')\n",
"ax.axhline(0, color='gray', linestyle='--', alpha=0.5, linewidth=0.8)\n",
"ax.grid(True, alpha=0.3)\n",
"ax.set_xlim(-100, 185)\n",
"ax.set_ylim(-45, 65)\n",
"\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/site_map.png', dpi=150, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Simple Linear Regression: Fajr Angle ~ f(lat, day_of_year, elevation)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from sklearn.linear_model import LinearRegression\n",
"from sklearn.preprocessing import StandardScaler\n",
"from sklearn.metrics import r2_score, mean_absolute_error\n",
"import numpy as np\n",
"\n",
"# Use all data\n",
"features = ['lat', 'day_of_year', 'elevation_m']\n",
"X = fajr[features].copy()\n",
"\n",
"# Add squared terms for non-linearity\n",
"X['lat_abs'] = fajr['lat'].abs()\n",
"X['lat_sq'] = fajr['lat'] ** 2\n",
"X['doy_sin'] = np.sin(2 * np.pi * fajr['day_of_year'] / 365.25)\n",
"X['doy_cos'] = np.cos(2 * np.pi * fajr['day_of_year'] / 365.25)\n",
"X['doy_sin2'] = np.sin(4 * np.pi * fajr['day_of_year'] / 365.25)\n",
"X['doy_cos2'] = np.cos(4 * np.pi * fajr['day_of_year'] / 365.25)\n",
"\n",
"y = fajr['fajr_angle']\n",
"\n",
"scaler = StandardScaler()\n",
"X_scaled = scaler.fit_transform(X)\n",
"\n",
"model = LinearRegression()\n",
"model.fit(X_scaled, y)\n",
"y_pred = model.predict(X_scaled)\n",
"\n",
"print(f'R² = {r2_score(y, y_pred):.4f}')\n",
"print(f'MAE = {mean_absolute_error(y, y_pred):.4f}°')\n",
"print()\n",
"print('Feature coefficients:')\n",
"for feat, coef in zip(X.columns, model.coef_):\n",
" print(f' {feat:15s}: {coef:.4f}')\n",
"\n",
"# Residuals\n",
"residuals = y - y_pred\n",
"print(f'\\nResidual stats:')\n",
"print(residuals.describe().to_string())"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Residual plot\n",
"fig, axes = plt.subplots(1, 3, figsize=(16, 5))\n",
"\n",
"axes[0].scatter(fajr['lat'], residuals, alpha=0.1, s=5)\n",
"axes[0].axhline(0, color='red', linestyle='--')\n",
"axes[0].set_xlabel('Latitude')\n",
"axes[0].set_ylabel('Residual (°)')\n",
"axes[0].set_title('Residuals vs Latitude')\n",
"\n",
"axes[1].scatter(fajr['day_of_year'], residuals, alpha=0.1, s=5)\n",
"axes[1].axhline(0, color='red', linestyle='--')\n",
"axes[1].set_xlabel('Day of Year')\n",
"axes[1].set_ylabel('Residual (°)')\n",
"axes[1].set_title('Residuals vs Day of Year')\n",
"\n",
"axes[2].scatter(fajr['elevation_m'], residuals, alpha=0.3, s=20)\n",
"axes[2].axhline(0, color='red', linestyle='--')\n",
"axes[2].set_xlabel('Elevation (m)')\n",
"axes[2].set_ylabel('Residual (°)')\n",
"axes[2].set_title('Residuals vs Elevation')\n",
"\n",
"plt.suptitle('Linear Regression Residuals')\n",
"plt.tight_layout()\n",
"plt.savefig(ROOT / 'data/processed/regression_residuals.png', dpi=150, bbox_inches='tight')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 8. Isha Angle Analysis"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"if len(isha) > 0:\n",
" fig, axes = plt.subplots(1, 3, figsize=(16, 5))\n",
"\n",
" axes[0].scatter(isha['lat'], isha['isha_angle'], color='darkorange', alpha=0.8, s=60)\n",
" axes[0].set_xlabel('Latitude (°)')\n",
" axes[0].set_ylabel('Isha Depression Angle (°)')\n",
" axes[0].set_title('Isha Angle vs Latitude')\n",
" axes[0].grid(True, alpha=0.3)\n",
"\n",
" axes[1].scatter(isha['day_of_year'], isha['isha_angle'], color='darkorange', alpha=0.8, s=60)\n",
" axes[1].set_xlabel('Day of Year')\n",
" axes[1].set_ylabel('Isha Depression Angle (°)')\n",
" axes[1].set_title('Isha Angle vs Season')\n",
" axes[1].set_xticks([1, 60, 121, 182, 244, 305, 365])\n",
" axes[1].set_xticklabels(['Jan', 'Mar', 'May', 'Jul', 'Sep', 'Nov', 'Dec'])\n",
" axes[1].grid(True, alpha=0.3)\n",
"\n",
" axes[2].scatter(isha['elevation_m'], isha['isha_angle'], color='darkorange', alpha=0.8, s=60)\n",
" axes[2].set_xlabel('Elevation (m)')\n",
" axes[2].set_ylabel('Isha Depression Angle (°)')\n",
" axes[2].set_title('Isha Angle vs Elevation')\n",
" axes[2].grid(True, alpha=0.3)\n",
"\n",
" plt.suptitle(f'Isha Analysis (n={len(isha)} records)')\n",
" plt.tight_layout()\n",
" plt.savefig(ROOT / 'data/processed/isha_analysis.png', dpi=150, bbox_inches='tight')\n",
" plt.show()\n",
"\n",
" print('Isha angle stats by latitude band:')\n",
" isha['lat_band'] = pd.cut(isha['lat'], bins=[-40, -10, 10, 30, 45, 60],\n",
" labels=['30-40°S', '10°S-10°N', '10-30°N', '30-45°N', '45-60°N'])\n",
" print(isha.groupby('lat_band')['isha_angle'].describe().to_string())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 9. Summary and Hypotheses for ML\n",
"\n",
"### Observed patterns:\n",
"\n",
"1. **Latitude effect**: Near-equatorial sites (Malaysia, Indonesia, 2°-7°) show higher Fajr angles (~16°-17°) compared to mid-latitude sites (UK ~13°, Egypt ~14°). This is counter-intuitive but physically explainable: the sun's arc through the horizon zone is steeper at low latitudes, so each degree of depression corresponds to a shorter time interval.\n",
"\n",
"2. **Seasonality (TOY)**: At fixed latitude, Fajr angle is lower in summer than winter. This is clear in the Birmingham dataset (10+ years of data). Summer twilight is shorter and the sun's path through the horizon zone is shallower.\n",
"\n",
"3. **Elevation**: Higher-elevation sites tend toward slightly higher angles. Desert observatory sites (Kottamia 477m, Hail 1020m, Tehran 1191m) show angles on the higher end. This is consistent with the physical effect: elevated observers see through less atmosphere, so the first light of dawn appears at a slightly steeper angle.\n",
"\n",
"4. **Latitude × Season interaction**: The seasonal swing is larger at high latitudes (Birmingham has a ~3° range from summer to winter) and smaller at equatorial sites (Malaysian sites show < 1° seasonal variation).\n",
"\n",
"### Next steps for ML:\n",
"\n",
"- Train gradient boosted models (XGBoost, LightGBM) on all available data\n",
"- Key features: `lat`, `lat_abs`, `lat_sq`, `day_of_year`, `doy_sin`, `doy_cos`, `elevation_m`, `lat × doy_sin`, `lat × doy_cos`\n",
"- Expand Isha dataset (currently only 43 records) before training Isha model\n",
"- Outlier analysis: identify records that deviate significantly from the fitted model and investigate whether they represent data entry errors, unusual atmospheric conditions, or genuine outliers\n",
"- Cross-validation strategy: leave-one-location-out (not random split) to test generalization to unseen locations"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.14.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}

View file

@ -1,81 +0,0 @@
{
"name": "pray-calc-ml",
"version": "1.0.0",
"description": "Machine learning calibration for Islamic prayer times. Fits optimal Fajr/Isha depression angles to observed mosque announcements using weighted least-squares regression. Zero runtime dependencies.",
"author": "Aric Camarata",
"license": "MIT",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.cjs"
}
}
},
"sideEffects": false,
"files": [
"dist/",
"src/",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"pretest": "tsup",
"test": "node test.mjs && node test-cjs.cjs",
"prepublishOnly": "tsup"
},
"keywords": [
"prayer-times",
"islamic-prayer-times",
"fajr",
"isha",
"depression-angle",
"calibration",
"machine-learning",
"least-squares",
"regression",
"mosque",
"adhan",
"pray-calc",
"twilight",
"islamic-astronomy"
],
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"pray-calc": ">=2.0.0"
},
"peerDependenciesMeta": {
"pray-calc": {
"optional": false
}
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"repository": {
"type": "git",
"url": "git+https://github.com/acamarata/pray-calc-ml.git"
},
"homepage": "https://github.com/acamarata/pray-calc-ml#readme",
"bugs": {
"url": "https://github.com/acamarata/pray-calc-ml/issues"
}
}

View file

@ -1,943 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
pray-calc:
specifier: '>=2.0.0'
version: 2.0.0
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.11
tsup:
specifier: ^8.0.0
version: 8.5.1(typescript@5.9.3)
typescript:
specifier: ^5.0.0
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.59.0':
resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.59.0':
resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.59.0':
resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.59.0':
resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.59.0':
resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.59.0':
resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.59.0':
resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.59.0':
resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.59.0':
resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.59.0':
resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@22.19.11':
resolution: {integrity: sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
esbuild: '>=0.18'
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fix-dts-default-cjs-exports@1.0.1:
resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
load-tsconfig@0.2.5:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nrel-spa@2.0.1:
resolution: {integrity: sha512-KwsudVfAHMUwz9RwhriI7oNqFYz77+VGi2vUpJeR+xNx57MU28EYcdt1TQ1frEDbpBXkF4EJxM62Hi2iX6QNCA==}
engines: {node: '>=20'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
yaml:
optional: true
pray-calc@2.0.0:
resolution: {integrity: sha512-q1lLu5RZjLP09K4sLNVhwEkzv/enWLPvUVtgd3U7HeUyX41Q9YS6mfchwdxWDzDz0/b8uzuXh1LgmulNc3em2A==}
engines: {node: '>=20'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
rollup@4.59.0:
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
'@microsoft/api-extractor': ^7.36.0
'@swc/core': ^1
postcss: ^8.4.12
typescript: '>=4.5.0'
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true
'@swc/core':
optional: true
postcss:
optional: true
typescript:
optional: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
'@rollup/rollup-android-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-arm64@4.59.0':
optional: true
'@rollup/rollup-darwin-x64@4.59.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.59.0':
optional: true
'@rollup/rollup-freebsd-x64@4.59.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-loong64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.59.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.59.0':
optional: true
'@rollup/rollup-openbsd-x64@4.59.0':
optional: true
'@rollup/rollup-openharmony-arm64@4.59.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.59.0':
optional: true
'@rollup/rollup-win32-x64-gnu@4.59.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@types/estree@1.0.8': {}
'@types/node@22.19.11':
dependencies:
undici-types: 6.21.0
acorn@8.16.0: {}
any-promise@1.3.0: {}
bundle-require@5.1.0(esbuild@0.27.3):
dependencies:
esbuild: 0.27.3
load-tsconfig: 0.2.5
cac@6.7.14: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
commander@4.1.1: {}
confbox@0.1.8: {}
consola@3.4.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
fix-dts-default-cjs-exports@1.0.1:
dependencies:
magic-string: 0.30.21
mlly: 1.8.0
rollup: 4.59.0
fsevents@2.3.3:
optional: true
joycon@3.1.1: {}
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
load-tsconfig@0.2.5: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mlly@1.8.0:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.3
ms@2.1.3: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
nrel-spa@2.0.1: {}
object-assign@4.1.1: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
pirates@4.0.7: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
postcss-load-config@6.0.1:
dependencies:
lilconfig: 3.1.3
pray-calc@2.0.0:
dependencies:
nrel-spa: 2.0.1
readdirp@4.1.2: {}
resolve-from@5.0.0: {}
rollup@4.59.0:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.59.0
'@rollup/rollup-android-arm64': 4.59.0
'@rollup/rollup-darwin-arm64': 4.59.0
'@rollup/rollup-darwin-x64': 4.59.0
'@rollup/rollup-freebsd-arm64': 4.59.0
'@rollup/rollup-freebsd-x64': 4.59.0
'@rollup/rollup-linux-arm-gnueabihf': 4.59.0
'@rollup/rollup-linux-arm-musleabihf': 4.59.0
'@rollup/rollup-linux-arm64-gnu': 4.59.0
'@rollup/rollup-linux-arm64-musl': 4.59.0
'@rollup/rollup-linux-loong64-gnu': 4.59.0
'@rollup/rollup-linux-loong64-musl': 4.59.0
'@rollup/rollup-linux-ppc64-gnu': 4.59.0
'@rollup/rollup-linux-ppc64-musl': 4.59.0
'@rollup/rollup-linux-riscv64-gnu': 4.59.0
'@rollup/rollup-linux-riscv64-musl': 4.59.0
'@rollup/rollup-linux-s390x-gnu': 4.59.0
'@rollup/rollup-linux-x64-gnu': 4.59.0
'@rollup/rollup-linux-x64-musl': 4.59.0
'@rollup/rollup-openbsd-x64': 4.59.0
'@rollup/rollup-openharmony-arm64': 4.59.0
'@rollup/rollup-win32-arm64-msvc': 4.59.0
'@rollup/rollup-win32-ia32-msvc': 4.59.0
'@rollup/rollup-win32-x64-gnu': 4.59.0
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
source-map@0.7.6: {}
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
commander: 4.1.1
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.7
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
thenify@3.3.1:
dependencies:
any-promise: 1.3.0
tinyexec@0.3.2: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
tsup@8.5.1(typescript@5.9.3):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.3)
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.2
debug: 4.4.3
esbuild: 0.27.3
fix-dts-default-cjs-exports: 1.0.1
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1
resolve-from: 5.0.0
rollup: 4.59.0
source-map: 0.7.6
sucrase: 3.35.1
tinyexec: 0.3.2
tinyglobby: 0.2.15
tree-kill: 1.2.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- jiti
- supports-color
- tsx
- yaml
typescript@5.9.3: {}
ufo@1.6.3: {}
undici-types@6.21.0: {}

View file

@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
ephem>=4.1
pandas>=2.0
numpy>=1.25
requests>=2.31
matplotlib>=3.7
scikit-learn>=1.3
jupyter>=1.0

30
research/README.md Normal file
View file

@ -0,0 +1,30 @@
# Research Archive
This folder contains summaries of academic and institutional research on Islamic twilight
angles. These papers describe other researchers' conclusions about the solar depression angle
at Fajr and Isha. They are useful for understanding the scientific landscape but are **not**
used as ML training data — only raw per-date observations with explicit timestamps feed
the `data/processed/` datasets.
## Summary: What the research says
Most peer-reviewed naked-eye studies find Fajr (true dawn / Subh Sadiq) corresponds to a
solar depression of roughly **13°16°** depending on site, season, and atmospheric conditions.
Isha (Shafaq al-Abyad, white dusk twilight) corresponds to roughly **14°18°**.
The classic convention of 18° Fajr (used by ISNA, MWL, and others) is based on astronomical
twilight (the sky becoming fully dark), not the first appearance of dawn light. Observations
consistently show true dawn appears while the sun is 12°15° below the horizon, not 18°.
## Papers Summarized
| File | Authors | Year | Site | Finding |
| --- | --- | --- | --- | --- |
| `nriag-egypt-1984-2014.md` | Hassan et al. | 1984-2014 | Egypt (6 sites) | 13°-15° Fajr |
| `nriag-egypt-2022-2025.md` | Rashed et al. | 2018-2025 | Egypt (Fayum, Alex) | 13°-14° Fajr |
| `hail-saudi-2018.md` | Khalifa | 2014-2015 | Hail, Saudi Arabia | 14° mean Fajr |
| `malaysia-indonesia-2018.md` | Kassim Bahali | 2017 | KL + Indonesia | 16.67° Fajr |
| `depok-indonesia-2020.md` | Saksono | 2015 | Depok, Java | ~16° Fajr |
| `uk-observations.md` | Yusuf, Hizbul Ulama | 1987-2017 | UK (3 sites) | 12°-14° Fajr |
| `birmingham-openfajr.md` | OpenFajr project | 2016-2026 | Birmingham | 12.5°-14° Fajr |
| `moonsighting-global.md` | Khalid Shaukat | 2000s | Multiple global | 15°-18° Fajr |

View file

@ -0,0 +1,56 @@
# OpenFajr Project (Birmingham, UK)
## Overview
OpenFajr is an ongoing community astrophotography project in Birmingham, UK, where a panel
of scholars and community members review daily sky photographs and vote on the moment of
true dawn. This is the largest dataset of per-date human-verified Fajr sightings in the world,
with over 4,000 records spanning 2016 to the present.
**URL:** https://openfajr.org
**Data format:** Google Calendar iCal feed (UTC)
**Location:** Birmingham (52.4862°N, 1.8904°W, 141m)
## Method
Each morning, a wide-field camera captures a continuous series of sky photographs from the
eastern horizon. After each session, an independent panel reviews and votes on the photographs
to identify the moment when the horizon first becomes distinguishable (true dawn / Subh Sadiq).
The voted time is published to the iCal feed.
## Findings
Birmingham is at 52°N, which produces interesting seasonal variation. The dataset covers over
10 years and shows:
- **Mean depression angle:** approximately 12.5°-13.5° (computed in this project via back-calculation)
- **Seasonal variation:** lower angles in summer (shorter, shallower twilight arc), higher in winter
- **Winter range:** typically 13.5°-15.0°
- **Summer range:** typically 11.5°-13.5° (sun moves at a shallower angle through the horizon zone)
- **Inter-year consistency:** very stable year-to-year
## Why Birmingham
Birmingham's latitude (52°N) is notable because it experiences significant seasonal variation in
both the angle and duration of twilight. At this latitude in summer, nights are very short and
twilight begins to overlap with morning civil dawn — the solar arc through the horizon zone is
much shallower, meaning false dawn and true dawn phenomena behave differently than at tropical
latitudes.
This makes Birmingham a particularly useful calibration anchor because:
1. It covers an extreme of the northern temperate zone
2. 10+ years of consistent data provides excellent statistical confidence
3. The scholarly review process provides strong validity guarantees
## DST Artifacts
The iCal feed contains a small number of records around British Summer Time transition dates
(last Sunday of March, last Sunday of October) with anomalous UTC timestamps. These appear to
result from timezone confusion in calendar software and produce depression angles of 3°-7°,
which are physically impossible for genuine Fajr sightings. The pipeline filters these out.
## Use in This Project
OpenFajr provides ~98% of the Fajr training data. The raw iCal is fetched at runtime by
`src/collect/openfajr.py`. No local caching is performed; run the pipeline with network access
to get the latest feed.

View file

@ -0,0 +1,39 @@
# Hail, Saudi Arabia — Khalifa 2018
## Paper
Khalifa, A.S. "Astronomical determination of Fajr and Isha prayer times at Hail, Saudi Arabia."
*NRIAG Journal of Astronomy and Geophysics*, 7: 22-28, 2018.
## Location
Hail (27.52°N, 41.70°E, ~1020m elevation) — a city on the Najd plateau in central Saudi Arabia.
The high elevation and desert conditions produce excellent sky transparency.
## Method
80 total observation nights in 2014-2015. 32 nights selected for excellent atmospheric
visibility (no clouds, no dust). Naked-eye observation by trained observers.
## Results
- **Mean Fajr depression:** 14.4° (range 12.8°-16.1°)
- **Mean Isha depression (Shafaq Abyad):** 14.8° (range 13.2°-16.4°)
- **Seasonal variation:** Higher angles in winter, lower in summer (consistent with other studies)
## Significance
At 1020m elevation, Hail is the highest-elevation site in the Saudi/Gulf region with published
Fajr observations. The results show a slightly higher mean angle than sea-level desert sites
in Egypt (13.5°-14.5°), consistent with the hypothesis that elevation increases the apparent
depression angle at true dawn (the observer is above more of the atmosphere, so the first light
of dawn appears at a slightly steeper angle).
The Hail dataset is particularly useful for the elevation variable in the ML model — it is one
of the few high-altitude desert sites with per-season data.
## Note for ML Training
The per-season records in `verified_sightings.py` for Hail are constructed from the paper's
reported seasonal means, with observation times estimated from sunrise data. They are marked
as "time inferred" in `data/raw/sources.md`.

View file

@ -0,0 +1,75 @@
# Malaysia and Indonesia Studies
## Kassim Bahali et al. 2018 (DSLR Study)
**Paper:** Kassim Bahali, N.F., et al. "Determination of Fajr and Isha prayer times based on
astronomical observation at low latitude." *Sains Malaysia*, 47(11): 2797-2805, 2018.
**Location:** Kuala Lumpur and surrounding Malaysia/Indonesia sites (2°N-7°S range)
**Method:** DSLR astrophotography + SQM (Sky Quality Meter) sky brightness
**Data:** 64 observation days, February-December 2017
**Key result:** Mean Fajr depression = **16.67°** (range 13.9°-19.8°)
The DSLR + SQM combination is methodologically stronger than naked-eye only. The SQM
provides an objective measure of sky brightness that eliminates observer subjectivity.
At near-equatorial latitudes (2°-7°), the Sun rises and sets at a steep angle through the
horizon, producing very short, sharp twilight transitions.
---
## Saksono 2020 (Depok, Indonesia)
**Paper:** Saksono, T. "Fajr prayer time determination using the Sky Quality Meter."
*NRIAG Journal of Astronomy and Geophysics*, 9(1): 238-244, 2020.
**Location:** Depok, West Java, Indonesia (6.4°S, 106.83°E, 65m)
**Method:** SQM sky brightness monitoring; 26 nights June-July 2015
**Result:** ~16° mean Fajr depression
---
## Hamidi 2007-2008 (Malaysia Isha Study)
**Source:** Zety Sharizat Hamidi, *Determination of Isha prayer time based on shafaq abyad in
Malaysia.* Academia.edu, 2008.
**Location:** Two sites:
- Kuala Lipis: 4.183°N, 102.040°E, 76m (east coast)
- Port Klang: 3.004°N, 101.403°E, 5m (west coast)
**Method:** Naked-eye observation of Shafaq al-Abyad disappearance; May 2007 - April 2008
**Result:** Isha (Shafaq Abyad) consistently at approximately **16°-17°** depression
---
## OIF UMSU (Medan, North Sumatra 2017-2020)
**Location:** OIF (Observatory of Islamic Fajr) at University of Muhammadiyah North Sumatra,
Medan (3.595°N, 98.672°E, 22m)
**Method:** SQM photometry; hundreds of observation days
**Proposed angle:** 16.48° for Indonesian national standard
---
## Key Pattern: Equatorial Sites Yield Higher Angles
All Malaysia/Indonesia studies find Fajr at ~16°-17°, compared to ~13°-14° in Birmingham
and the UK. This is a systematic and significant pattern:
| Latitude | Representative Site | Mean Fajr Angle |
| --- | --- | --- |
| 52°N | Birmingham, UK | ~13° |
| 40°N | Ankara, Turkey | ~15° |
| 30°N | Egypt (desert) | ~13.5°-14.5° |
| 27°N | Hail, Saudi Arabia (1020m) | ~14.4° |
| 3°-7°N | Malaysia/Indonesia | ~16°-17° |
| 3°-7°S | Indonesia (Java) | ~16°-17° |
| 33°-37°S | Cape Town / Auckland | ~15°-16° |
This counter-intuitive result (equatorial sites have *higher* angles than mid-latitude sites)
is likely caused by the geometry of the Earth's atmosphere: at equatorial latitudes, the Sun
rises at a steeper angle through the horizon, so morning twilight is briefer and more intense.
The same absolute angle corresponds to a later moment relative to sunrise at lower latitudes.
The ML model should capture this latitude-dependent pattern.

View file

@ -0,0 +1,59 @@
# NRIAG Egypt Studies: 1984-2014
## Summary
The National Research Institute of Astronomy and Geophysics (NRIAG, Egypt) conducted the
most extensive body of peer-reviewed naked-eye Fajr observations anywhere in the world.
Multiple campaigns from 1984 to 2014 covered six Egyptian sites across a wide range of
latitudes, elevations, and atmospheric conditions.
## Key Paper
Hassan, A.H., et al. "Astronomical determination of the proper time for Fajr prayer."
*NRIAG Journal of Astronomy and Geophysics*, 3(1): 23-26, 2014.
DOI: S2090997714000054
## Sites and Findings
| Site | Lat/Lng | Elevation | Years | Mean Fajr Angle | Conditions |
| --- | --- | --- | --- | --- | --- |
| Kottamia Observatory | 30.03°N, 31.83°E | 477m | 1984-1987 | 13.5° | Elevated desert observatory; photoelectric + naked eye |
| Helwan | 29.86°N, 31.34°E | 114m | 1984-1987 | 13.1° | Peri-urban; slight light pollution |
| Aswan | 24.09°N, 32.90°E | 92m | 1984-1987 | 14.0° | Near-equatorial desert; clearest conditions |
| Siwa Oasis | 29.20°N, 25.52°E | -18m | 2005-2007 | 14.8° | Below sea level; very dry; exceptional clarity |
| Mersa Matrouh | 31.36°N, 27.24°E | 26m | 2005-2007 | 13.7° | Mediterranean coast |
| Assiut | 27.17°N, 31.17°E | 55m | 2010-2013 | 13.7° | Nile Valley; agricultural; slightly lower than desert |
## Second NRIAG Paper
Hassan, A.H., et al. "Determination of Fajr twilight at five Egyptian sites."
*NRIAG Journal of Astronomy and Geophysics*, 5: 9-15, 2016.
Sites: Sinai (31.07°N, 30m), Assiut (27.17°N, 55m), Kharga Oasis (25.45°N, 74m), Qena
(26.16°N, 96m), and others. Results consistent with earlier campaign: 13°-15°.
Sinai desert specifically: 14.84° mean angle (n=47 nights).
Nile Valley sites: systematically lower by ~1° (agricultural aerosols reducing sky clarity).
## Isha Findings
The same papers cover Isha (Shafaq al-Abyad, white dusk twilight):
- Mean Isha angles: 14.3°-15.8° across Egyptian sites
- Shafaq al-Ahmar (red dusk) disappears earlier: ~10°-12°
## Key Conclusions
1. The 18° convention overstates true dawn by a significant margin. At most Egyptian sites, the
sky begins to lighten at 13°-15° depression.
2. Desert sites consistently yield slightly higher angles than agricultural or coastal sites,
likely due to atmospheric aerosol differences.
3. Results are consistent across a 30-year span (1984-2014) and multiple independent teams.
4. No systematic seasonal trend is reported — but Egypt spans only 22°-31°N, limiting latitude
range for seasonal analysis.
## Data Note
These papers report mean angles and statistical distributions, not per-date timestamps with
explicit times. The per-date ML training records for Egypt in this project are derived from
the published means using estimated observation times, and are marked accordingly in
`data/raw/sources.md`.

View file

@ -0,0 +1,65 @@
# UK Fajr and Isha Observations
## Hizbul Ulama UK — Blackburn (1987-1989)
**Location:** Rural outskirts of Blackburn, Lancashire (53.748°N, 2.48°W, ~120m)
**Source:** http://www.hizbululama.org.uk/files/salat_timing.html
A systematic observation program conducted over two years with 21 successful Fajr sightings
and corresponding Isha (Shafaq al-Abyad) records. Observations were made from a dark rural
site in northwest England.
At 53.7°N, this is among the highest-latitude systematic Fajr studies on record. Key findings:
- Summer solstice observations are particularly important — at 54°N in June, true dawn appears
at a very early local time and the sun's arc through the horizon zone is extremely shallow
- Winter Fajr requires very long darkness periods to observe
- Shafaq al-Abyad (Isha) does not fully disappear in summer at this latitude — a controversial
finding with significant fiqh implications
The 21 observations are spread across all seasons. Times are published to the nearest minute
in the Hizbul Ulama account.
---
## Asim Yusuf — "Shedding Light on the Dawn" (2013-2016)
**Location:** Exmoor National Park (51.15°N, 3.65°W, ~430m); Exmoor is an International Dark
Sky Reserve — one of the darkest locations in southern England.
**Source:** ISBN 978-0-9934979-1-9 (2017)
This is the most scholarly treatment of the UK Fajr controversy. Asim Yusuf conducted 18
multi-observer sighting sessions at three dark-sky UK sites between 2013 and 2016, covering
all four seasons at each site. His methodology:
- Multiple independent observers present simultaneously
- Detailed photographic documentation
- Explicit recording of "awwal al-tulu'" (first detectable light) vs "Fajr Sadiq" (true dawn)
- Elevation correction (Exmoor at 430m is significantly above the surrounding valley)
**Key results:** Consistent findings of true dawn at 12°-14° depression at Exmoor. The summer
solstice observation is critical — at 51°N in June, the sun barely reaches 15° below the
horizon, making the summer Fajr time very late (after midnight local time).
The Exmoor site at 430m is relevant to the elevation variable: being significantly elevated
above the surrounding valley and atmosphere extends the observability of twilight phenomena.
**Isha findings:** Shafaq al-Abyad (white twilight) disappearance observed at 15°-17° depression
in winter and autumn, with later times in summer (longer twilight persistence at 51°N).
### Fiqh Implications
Both the Blackburn and Exmoor studies raise challenging questions for high-latitude UK communities:
- The 18° convention places Fajr unrealistically early in summer (before astronomical midnight)
- The observed 12°-14° is more consistent with what the human eye actually perceives as dawn
- Yusuf's work contributed to the ongoing academic discussion about whether high-latitude
communities should use a different calculation method
---
## Impact on pray-calc
These UK observations at 51°N-54°N are critical anchor points for the latitude variable in
the ML model. They demonstrate that depression angle varies with latitude (52°N Birmingham
at ~13°, versus tropical sites at ~16°-18°) and suggest a latitude-dependent correction term
may be needed in any generalized algorithm.

86
src/angle_calc.py Normal file
View file

@ -0,0 +1,86 @@
"""
Back-calculate the solar depression angle at the moment of a verified sighting.
Given: date, lat, lng, elevation_m, observed_utc_time
Returns: depression angle in degrees (positive = sun below horizon)
Uses PyEphem for accurate solar position. Atmospheric refraction is included
because human observers see the sky with refraction the angle we compute
matches what the sun physically was doing at that horizon.
"""
from datetime import datetime, timezone
import ephem
import math
def depression_angle(
utc_dt: datetime,
lat_deg: float,
lng_deg: float,
elevation_m: float = 0.0,
) -> float:
"""
Return the solar depression angle in degrees at the given UTC datetime
and location.
Depression angle is positive when the sun is below the horizon.
Returns a negative value if the sun is somehow above the horizon
(which would indicate a data entry error in the sighting record).
Parameters
----------
utc_dt : datetime
Observation datetime in UTC (timezone-aware or naive UTC).
lat_deg : float
Observer latitude in decimal degrees (north positive).
lng_deg : float
Observer longitude in decimal degrees (east positive).
elevation_m : float
Observer elevation above sea level in metres.
Returns
-------
float
Solar depression angle in degrees. Positive = sun below horizon.
"""
obs = ephem.Observer()
obs.lat = str(lat_deg)
obs.lon = str(lng_deg)
obs.elevation = elevation_m
obs.pressure = 1013.25 # standard atmosphere — include refraction
obs.temp = 15.0 # standard temperature
# ephem expects UTC as a naive datetime
if utc_dt.tzinfo is not None:
utc_dt = utc_dt.replace(tzinfo=None)
obs.date = ephem.Date(utc_dt)
sun = ephem.Sun(obs)
altitude_rad = float(sun.alt)
altitude_deg = math.degrees(altitude_rad)
return -altitude_deg # depression = negative altitude
def depression_angles_batch(records: list[dict]) -> list[float]:
"""
Compute depression angles for a list of sighting records.
Each record must have:
utc_dt : datetime (UTC)
lat : float (decimal degrees)
lng : float (decimal degrees)
elevation_m : float (metres, default 0)
Returns a list of depression angles in the same order.
"""
return [
depression_angle(
r["utc_dt"],
r["lat"],
r["lng"],
r.get("elevation_m", 0.0),
)
for r in records
]

View file

@ -1,150 +0,0 @@
/**
* Angle calibration via iterative weighted least-squares.
*
* Given a set of observed Fajr and/or Isha times, finds the depression angles
* (fajrAngle, ishaAngle) that minimize the weighted sum of squared residuals
* between predicted and observed times.
*
* The solver decouples the two parameters: Fajr observations constrain fajrAngle
* independently from Isha observations, which constrain ishaAngle. Each is solved
* via a 1D golden-section search over the allowed angle range.
*/
import { predictFajr, predictIsha } from './predict.js';
import type { Observation, CalibratedAngles, CalibrationResult, CalibrationOptions } from './types.js';
/** Golden-section search for the minimum of f(x) on [a, b]. */
function goldenSection(
f: (x: number) => number,
a: number,
b: number,
tol: number,
maxIter: number,
): number {
const phi = (Math.sqrt(5) - 1) / 2; // 1/φ ≈ 0.618
let x1 = b - phi * (b - a);
let x2 = a + phi * (b - a);
let f1 = f(x1);
let f2 = f(x2);
for (let i = 0; i < maxIter && b - a > tol; i++) {
if (f1 < f2) {
b = x2; x2 = x1; f2 = f1;
x1 = b - phi * (b - a); f1 = f(x1);
} else {
a = x1; x1 = x2; f1 = f2;
x2 = a + phi * (b - a); f2 = f(x2);
}
}
return (a + b) / 2;
}
/**
* Calibrate Fajr and Isha depression angles to fit observed prayer time data.
*
* @param observations - Array of observed prayer times. Each entry may supply
* a Fajr time, an Isha time, or both. Entries missing a field are ignored for
* the corresponding angle.
* @param options - Solver configuration and angle bounds.
* @returns Calibrated angles and diagnostic information.
*
* @throws If fewer than 2 Fajr or 2 Isha observations are provided.
* (Two is the minimum for a meaningful fit.)
*/
export function calibrateAngles(
observations: Observation[],
options: CalibrationOptions = {},
): CalibrationResult {
const {
fajrMin = 10,
fajrMax = 22,
ishaMin = 10,
ishaMax = 22,
maxIter = 200,
tol = 1e-5,
} = options;
const fajrObs = observations.filter(o => o.fajr !== undefined);
const ishaObs = observations.filter(o => o.isha !== undefined);
if (fajrObs.length < 2 && ishaObs.length < 2) {
throw new Error(
`calibrateAngles: need at least 2 Fajr or 2 Isha observations ` +
`(got Fajr=${fajrObs.length}, Isha=${ishaObs.length})`
);
}
const { fajrAngle0 = 15.0, ishaAngle0 = 15.0 } = options;
// Weighted sum-of-squares for Fajr at a given angle
function fajrLoss(angle: number): number {
let wss = 0;
for (const o of fajrObs) {
const pred = predictFajr(o.date, o.lat, o.lng, o.tz, angle);
if (!isFinite(pred)) continue; // polar day/night — skip
const w = o.weight ?? 1;
const diff = (pred - o.fajr!) * 60; // minutes
wss += w * diff * diff;
}
return wss;
}
// Weighted sum-of-squares for Isha at a given angle
function ishaLoss(angle: number): number {
let wss = 0;
for (const o of ishaObs) {
const pred = predictIsha(o.date, o.lat, o.lng, o.tz, angle);
if (!isFinite(pred)) continue;
const w = o.weight ?? 1;
const diff = (pred - o.isha!) * 60;
wss += w * diff * diff;
}
return wss;
}
// Calibrate each angle independently. If there are fewer than 2 observations
// for one angle, fall back to the initial guess (no data to fit against).
const fajrAngle = fajrObs.length >= 2
? goldenSection(fajrLoss, fajrMin, fajrMax, tol, maxIter)
: fajrAngle0;
const ishaAngle = ishaObs.length >= 2
? goldenSection(ishaLoss, ishaMin, ishaMax, tol, maxIter)
: ishaAngle0;
// Compute residuals and RMS
let totalWeightedSS = 0;
let totalWeight = 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;
}
}
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;
}
}
return { fajrMin, ishaMin };
});
const rmsMinutes = totalWeight > 0 ? Math.sqrt(totalWeightedSS / totalWeight) : 0;
const observationCount = (fajrObs.length + ishaObs.length) / 2;
return {
angles: { fajrAngle, ishaAngle },
rmsMinutes,
observationCount,
residuals,
};
}

0
src/collect/__init__.py Normal file
View file

88
src/collect/openfajr.py Normal file
View file

@ -0,0 +1,88 @@
"""
Collect verified Fajr sightings from the OpenFajr project (Birmingham, UK).
OpenFajr is a year-long (and ongoing) astrophotography project in which
scholars and community members reviewed ~25,000 sky photographs and voted
on when true dawn appeared each day. The resulting Fajr times are published
as a Google Calendar iCal feed.
Source: https://openfajr.org/
Location: Birmingham, UK 52.4862°N, 1.8904°W, elevation 141 m
"""
import io
from datetime import datetime, timezone
import pandas as pd
import requests
ICAL_URL = (
"https://calendar.google.com/calendar/ical/"
"qmho4v5896ki2mc5supv0ikgfs%40group.calendar.google.com/public/basic.ics"
)
# Fixed location for all Birmingham observations
BIRMINGHAM_LAT = 52.4862
BIRMINGHAM_LNG = -1.8904
BIRMINGHAM_ELEV_M = 141.0
BIRMINGHAM_SOURCE = "OpenFajr (openfajr.org)"
def fetch_openfajr(url: str = ICAL_URL) -> pd.DataFrame:
"""
Download the OpenFajr iCal feed and return a DataFrame of confirmed Fajr
sightings.
Columns:
date : date (local calendar date in Europe/London)
utc_dt : datetime (UTC, timezone-aware)
lat : float
lng : float
elevation_m : float
prayer : str ("fajr")
source : str
notes : str
"""
resp = requests.get(url, timeout=30)
resp.raise_for_status()
text = resp.text
records = []
for block in text.split("BEGIN:VEVENT")[1:]:
data: dict[str, str] = {}
for line in block.splitlines():
if ":" in line:
k, _, v = line.partition(":")
data[k.strip()] = v.strip()
summary = data.get("SUMMARY", "")
if "Fajr" not in summary:
continue
dt_raw = data.get("DTSTART", "")
if not dt_raw.endswith("Z"):
continue # skip local-time entries (there are none, but guard anyway)
try:
utc_dt = datetime.strptime(dt_raw, "%Y%m%dT%H%M%SZ").replace(
tzinfo=timezone.utc
)
except ValueError:
continue
records.append(
{
"date": utc_dt.date(),
"utc_dt": utc_dt,
"lat": BIRMINGHAM_LAT,
"lng": BIRMINGHAM_LNG,
"elevation_m": BIRMINGHAM_ELEV_M,
"prayer": "fajr",
"source": BIRMINGHAM_SOURCE,
"notes": "",
}
)
df = pd.DataFrame(records)
df = df.sort_values("utc_dt").reset_index(drop=True)
return df

File diff suppressed because it is too large Load diff

56
src/elevation.py Normal file
View file

@ -0,0 +1,56 @@
"""
Elevation lookup from the Open-Elevation API (free, no key required).
Falls back to zero if the API is unreachable.
"""
import time
import requests
OPEN_ELEVATION_URL = "https://api.open-elevation.com/api/v1/lookup"
def get_elevation(lat: float, lng: float, retries: int = 3) -> float:
"""
Look up elevation in metres at (lat, lng) via Open-Elevation.
Returns 0.0 on failure after `retries` attempts.
"""
payload = {"locations": [{"latitude": lat, "longitude": lng}]}
for attempt in range(retries):
try:
resp = requests.post(OPEN_ELEVATION_URL, json=payload, timeout=10)
resp.raise_for_status()
data = resp.json()
return float(data["results"][0]["elevation"])
except Exception:
if attempt < retries - 1:
time.sleep(1.5 * (attempt + 1))
return 0.0
def get_elevations_batch(
locations: list[tuple[float, float]],
chunk_size: int = 100,
) -> list[float]:
"""
Look up elevations for a list of (lat, lng) tuples.
Sends up to `chunk_size` locations per request to stay within API limits.
Returns a list of elevations in the same order as input.
"""
results = []
for i in range(0, len(locations), chunk_size):
chunk = locations[i : i + chunk_size]
payload = {
"locations": [{"latitude": lat, "longitude": lng} for lat, lng in chunk]
}
try:
resp = requests.post(OPEN_ELEVATION_URL, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
results.extend(float(r["elevation"]) for r in data["results"])
except Exception:
results.extend(0.0 for _ in chunk)
time.sleep(0.2) # polite rate limit
return results

View file

@ -1,25 +0,0 @@
/**
* pray-calc-ml machine learning calibration for Islamic prayer times.
*
* Fits optimal Fajr/Isha depression angles to observed mosque announcement data
* using weighted least-squares regression. Zero runtime dependencies.
*
* Main exports:
* calibrateAngles - Fit depression angles to observed prayer times
* scoreAngles - Evaluate fixed angles against observations
* predictFajr - Predict Fajr time for a given angle
* predictIsha - Predict Isha time for a given angle
*/
export { calibrateAngles } from './calibrate.js';
export { scoreAngles } from './score.js';
export { predictFajr, predictIsha } from './predict.js';
export type {
Observation,
CalibratedAngles,
CalibrationResult,
CalibrationOptions,
} from './types.js';
export type { ScoreResult } from './score.js';

183
src/pipeline.py Normal file
View file

@ -0,0 +1,183 @@
"""
Master data pipeline.
Runs all collectors, back-calculates solar depression angles for each verified
sighting, optionally looks up missing elevations, and writes two clean CSVs:
data/processed/fajr_angles.csv
data/processed/isha_angles.csv
Each row represents ONE confirmed human-verified sighting.
Columns:
date - YYYY-MM-DD (local calendar date)
utc_dt - ISO 8601 UTC datetime of the sighting
lat - decimal degrees (north positive)
lng - decimal degrees (east positive)
elevation_m - metres above sea level
fajr_angle - solar depression angle at moment of Fajr sighting (degrees)
isha_angle - solar depression angle at moment of Isha sighting (degrees)
day_of_year - 1-366 (for seasonality / TOY analysis)
source - citation string
notes - observer notes
Usage:
python -m src.pipeline [--no-elevation-lookup]
--no-elevation-lookup : skip Open-Elevation API calls (use 0 for unknowns)
"""
import argparse
import sys
import os
from pathlib import Path
from datetime import timezone
import pandas as pd
# Add project root to path
ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT))
from src.angle_calc import depression_angle
from src.collect.openfajr import fetch_openfajr
from src.collect.verified_sightings import load_verified_sightings
from src.elevation import get_elevations_batch
PROCESSED_DIR = ROOT / "data" / "processed"
def build_dataset(
lookup_elevation: bool = True,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
Run all collectors, compute depression angles, return (fajr_df, isha_df).
"""
print("Loading OpenFajr Birmingham iCal feed...")
openfajr_df = fetch_openfajr()
print(f" {len(openfajr_df)} Fajr records from OpenFajr")
print("Loading manually verified sightings...")
manual_df = load_verified_sightings()
print(f" {len(manual_df)} manually compiled records")
all_df = pd.concat([openfajr_df, manual_df], ignore_index=True)
# Elevation lookup for records with elevation_m == 0
if lookup_elevation:
missing_mask = all_df["elevation_m"] == 0.0
n_missing = missing_mask.sum()
if n_missing > 0:
print(f"Looking up elevations for {n_missing} records...")
locs = list(zip(
all_df.loc[missing_mask, "lat"],
all_df.loc[missing_mask, "lng"],
))
elevations = get_elevations_batch(locs)
all_df.loc[missing_mask, "elevation_m"] = elevations
print(f" Elevation lookup complete")
else:
print("Skipping elevation lookup (--no-elevation-lookup)")
# Back-calculate depression angle for each sighting
print("Computing solar depression angles...")
angles = []
for _, row in all_df.iterrows():
try:
angle = depression_angle(
row["utc_dt"],
row["lat"],
row["lng"],
row["elevation_m"],
)
except Exception as e:
angle = float("nan")
angles.append(angle)
all_df["angle"] = angles
# Drop records with implausible depression angles — data entry / timing errors.
# Floor thresholds based on the full body of peer-reviewed sighting research:
# Fajr: no confirmed genuine sighting below 7° depression
# Isha: no confirmed genuine sighting below 10° depression
# These also catch: sun-above-horizon (negative), DST clock-change artifacts,
# and mis-estimated observation times that ended up too close to sunrise/sunset.
FAJR_MIN_DEG = 7.0
ISHA_MIN_DEG = 10.0
fajr_bad = (all_df["prayer"] == "fajr") & (all_df["angle"] < FAJR_MIN_DEG)
isha_bad = (all_df["prayer"] == "isha") & (all_df["angle"] < ISHA_MIN_DEG)
bad = fajr_bad | isha_bad | all_df["angle"].isna()
if bad.any():
print(f" Dropping {bad.sum()} record(s) with implausible angles "
f"(< {FAJR_MIN_DEG}° Fajr / < {ISHA_MIN_DEG}° Isha):")
for _, row in all_df[bad].iterrows():
print(f" {row['prayer'].upper()} {row['date']} {row['utc_dt']} "
f"lat={row['lat']:.2f} angle={row['angle']:.2f}° — {row['source']}")
all_df = all_df[~bad].copy()
# Add seasonality feature
all_df["day_of_year"] = all_df["utc_dt"].apply(
lambda dt: dt.timetuple().tm_yday
)
# Split into Fajr and Isha datasets
fajr_df = all_df[all_df["prayer"] == "fajr"].copy()
isha_df = all_df[all_df["prayer"] == "isha"].copy()
fajr_df = fajr_df.rename(columns={"angle": "fajr_angle"})
isha_df = isha_df.rename(columns={"angle": "isha_angle"})
# Final column order for ML
fajr_cols = ["date", "utc_dt", "lat", "lng", "elevation_m",
"day_of_year", "fajr_angle", "source", "notes"]
isha_cols = ["date", "utc_dt", "lat", "lng", "elevation_m",
"day_of_year", "isha_angle", "source", "notes"]
fajr_df = fajr_df[fajr_cols].sort_values(["lat", "day_of_year"])
isha_df = isha_df[isha_cols].sort_values(["lat", "day_of_year"])
return fajr_df, isha_df
def main():
parser = argparse.ArgumentParser(description="Build Fajr/Isha angle datasets")
parser.add_argument(
"--no-elevation-lookup",
action="store_true",
help="Skip Open-Elevation API calls",
)
args = parser.parse_args()
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
fajr_df, isha_df = build_dataset(
lookup_elevation=not args.no_elevation_lookup,
)
fajr_path = PROCESSED_DIR / "fajr_angles.csv"
isha_path = PROCESSED_DIR / "isha_angles.csv"
fajr_df.to_csv(fajr_path, index=False)
isha_df.to_csv(isha_path, index=False)
print(f"\nFajr dataset: {len(fajr_df)} records → {fajr_path}")
print(f"Isha dataset: {len(isha_df)} records → {isha_path}")
print("\nFajr angle stats:")
print(fajr_df["fajr_angle"].describe().to_string())
print("\nIsha angle stats:")
if len(isha_df) > 0:
print(isha_df["isha_angle"].describe().to_string())
print("\nFajr geographic coverage:")
print(f" Latitude range: {fajr_df['lat'].min():.1f}° to {fajr_df['lat'].max():.1f}°")
print(f" Unique locations: {len(fajr_df.groupby(['lat','lng']))}")
print(f" Date range: {fajr_df['date'].min()} to {fajr_df['date'].max()}")
if __name__ == "__main__":
main()

View file

@ -1,43 +0,0 @@
/**
* Predict Fajr and Isha times given depression angles.
*
* Uses the internal solar module to avoid requiring pray-calc at calibration time.
* Accuracy matches pray-calc within ~0.5 min for most locations (no atmospheric
* refraction correction here the calibration absorbs that into the fitted angle).
*/
import { jdn, horizonCrossing } from './solar.js';
/**
* Predict Fajr time (fractional hours, local) for a given depression angle.
* Returns NaN if the sun never reaches that depth below the horizon.
*/
export function predictFajr(
date: Date,
lat: number,
lng: number,
tz: number,
fajrAngle: number,
): number {
const jd = jdn(new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12)));
const altDeg = -(fajrAngle); // depression → negative altitude
const [rise] = horizonCrossing(jd, lat, lng, tz, altDeg);
return rise;
}
/**
* Predict Isha time (fractional hours, local) for a given depression angle.
* Returns NaN if the sun never reaches that depth below the horizon.
*/
export function predictIsha(
date: Date,
lat: number,
lng: number,
tz: number,
ishaAngle: number,
): number {
const jd = jdn(new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12)));
const altDeg = -(ishaAngle);
const [, set] = horizonCrossing(jd, lat, lng, tz, altDeg);
return set;
}

View file

@ -1,73 +0,0 @@
/**
* 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,
};
}

View file

@ -1,95 +0,0 @@
/**
* Minimal solar ephemeris used internally by pray-calc-ml.
*
* Provides the solar declination and equation-of-time needed to compute
* sunrise/sunset/twilight times without requiring pray-calc at build time.
* The same Jean Meeus formulas used in pray-calc's getSolarEphemeris.ts.
*/
/** Julian Day Number for a Date at UT noon. */
export function jdn(date: Date): number {
const y = date.getUTCFullYear();
const m = date.getUTCMonth() + 1;
const d = date.getUTCDate();
const a = Math.floor((14 - m) / 12);
const yr = y + 4800 - a;
const mo = m + 12 * a - 3;
return d + Math.floor((153 * mo + 2) / 5) + 365 * yr + Math.floor(yr / 4) - Math.floor(yr / 100) + Math.floor(yr / 400) - 32045;
}
interface SolarData {
/** Solar declination in radians. */
declRad: number;
/** Equation of time in hours (positive = sun transits early). */
eqtHours: number;
}
/** Jean Meeus solar declination and equation of time for a given JDN. */
export function solar(jd: number): SolarData {
const T = (jd - 2451545.0) / 36525.0;
const L0 = (280.46646 + T * (36000.76983 + T * 0.0003032)) % 360;
const M = (357.52911 + T * (35999.05029 - T * 0.0001537)) % 360;
const Mrad = (M * Math.PI) / 180;
const C = Math.sin(Mrad) * (1.914602 - T * (0.004817 + 0.000014 * T))
+ Math.sin(2 * Mrad) * (0.019993 - 0.000101 * T)
+ Math.sin(3 * Mrad) * 0.000289;
const sunLon = L0 + C;
const omega = 125.04 - 1934.136 * T;
const lambda = sunLon - 0.00569 - 0.00478 * Math.sin((omega * Math.PI) / 180);
const eps0 = 23 + 26 / 60 + 21.448 / 3600 - T * (46.8150 / 3600 + T * (0.00059 / 3600 - T * 0.001813 / 3600));
const eps = eps0 + 0.00256 * Math.cos((omega * Math.PI) / 180);
const epsRad = (eps * Math.PI) / 180;
const lambdaRad = (lambda * Math.PI) / 180;
const declRad = Math.asin(Math.sin(epsRad) * Math.sin(lambdaRad));
// Equation of time (minutes)
const y = Math.tan(epsRad / 2) ** 2;
const L0rad = (L0 * Math.PI) / 180;
const eqtMin = (4 / Math.PI) * (
y * Math.sin(2 * L0rad)
- 2 * Math.sin(Mrad)
+ 4 * Math.sin(Mrad) * y * Math.cos(2 * L0rad)
- 0.5 * y * y * Math.sin(4 * L0rad)
- 1.25 * Math.sin(2 * Mrad)
) * (180 / Math.PI);
const eqtHours = eqtMin / 60;
return { declRad, eqtHours };
}
/**
* Compute the time (fractional hours, local time) when the sun reaches a given
* altitude (degrees) before/after solar noon.
*
* @param jd Julian Day Number (UT noon)
* @param lat Latitude in decimal degrees
* @param lng Longitude in decimal degrees
* @param tz UTC offset in hours
* @param altDeg Target altitude in degrees (negative for depression below horizon)
* @returns [rise, set] as fractional hours in local time, or NaN if sun never reaches alt
*/
export function horizonCrossing(
jd: number,
lat: number,
lng: number,
tz: number,
altDeg: number,
): [number, number] {
const { declRad, eqtHours } = solar(jd);
const latRad = (lat * Math.PI) / 180;
const altRad = (altDeg * Math.PI) / 180;
const cosH = (Math.sin(altRad) - Math.sin(latRad) * Math.sin(declRad))
/ (Math.cos(latRad) * Math.cos(declRad));
if (cosH < -1 || cosH > 1) return [NaN, NaN]; // polar day/night
const H = (Math.acos(cosH) * 180) / Math.PI; // hour angle in degrees
// Solar noon in local time
const noon = 12 - eqtHours - lng / 15 + tz;
const rise = noon - H / 15;
const set = noon + H / 15;
return [rise, set];
}

View file

@ -1,110 +0,0 @@
/**
* Types for pray-calc-ml machine learning calibration for Islamic prayer times.
*/
/**
* A single observed prayer time announcement.
*
* All times are fractional hours in local time (same convention as pray-calc's getTimes).
* For example, 5.5 = 5:30 AM, 20.25 = 8:15 PM.
*/
export interface Observation {
/** Observer's local date for this observation. */
date: Date;
/** Latitude in decimal degrees (south = negative). */
lat: number;
/** Longitude in decimal degrees (west = negative). */
lng: number;
/** UTC offset in hours (e.g. -5 for EST). */
tz: number;
/**
* Observed Fajr time as fractional hours in local time.
* Omit if this observation does not constrain Fajr.
*/
fajr?: number;
/**
* Observed Isha time as fractional hours in local time.
* Omit if this observation does not constrain Isha.
*/
isha?: number;
/**
* Relative weight of this observation (default: 1.0).
* Higher weight makes the calibration prioritize this data point.
* Useful for down-weighting older or less reliable records.
*/
weight?: number;
}
/**
* Calibrated Fajr and Isha depression angles that best fit the observations.
*/
export interface CalibratedAngles {
/** Calibrated Fajr depression angle in degrees (positive, measured below horizon). */
fajrAngle: number;
/** Calibrated Isha depression angle in degrees (positive, measured below horizon). */
ishaAngle: number;
}
/**
* Detailed report from a calibration run.
*/
export interface CalibrationResult {
/** The calibrated angles. */
angles: CalibratedAngles;
/**
* Root-mean-square error of the fit in minutes.
* Lower is better. A value under 2 min is excellent for mosque data.
*/
rmsMinutes: number;
/**
* Number of observations used in the fit.
* Fajr-only and Isha-only observations each count as 0.5.
*/
observationCount: number;
/**
* Per-observation residuals in minutes (positive = predicted later than observed).
* Index matches the input observations array.
*/
residuals: Array<{ fajrMin: number | null; ishaMin: number | null }>;
}
/**
* Options for calibrateAngles().
*/
export interface CalibrationOptions {
/**
* Initial Fajr angle guess in degrees (default: 15.0).
* The solver starts here and iterates toward the minimum.
*/
fajrAngle0?: number;
/**
* Initial Isha angle guess in degrees (default: 15.0).
*/
ishaAngle0?: number;
/**
* Minimum allowed Fajr angle (default: 10.0).
* Physically, angles below 10° cannot produce astronomical twilight.
*/
fajrMin?: number;
/**
* Maximum allowed Fajr angle (default: 22.0).
*/
fajrMax?: number;
/**
* Minimum allowed Isha angle (default: 10.0).
*/
ishaMin?: number;
/**
* Maximum allowed Isha angle (default: 22.0).
*/
ishaMax?: number;
/**
* Maximum number of solver iterations (default: 100).
*/
maxIter?: number;
/**
* Convergence tolerance in degrees (default: 1e-4).
* The solver stops when the angle update is smaller than this.
*/
tol?: number;
}

View file

@ -1,78 +0,0 @@
/**
* pray-calc-ml CJS test suite
* Focused subset verifying CommonJS imports work correctly.
*/
'use strict';
const assert = require('assert');
const { calibrateAngles, scoreAngles, predictFajr, predictIsha } = require('./dist/index.cjs');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (e) {
console.error(` ${name}... FAIL: ${e.message}`);
failed++;
}
}
console.log('\n[CJS] Exports and basic functionality');
test('all exports are functions', () => {
assert.strictEqual(typeof calibrateAngles, 'function');
assert.strictEqual(typeof scoreAngles, 'function');
assert.strictEqual(typeof predictFajr, 'function');
assert.strictEqual(typeof predictIsha, 'function');
});
// Makkah, summer, UTC+3, ISNA 15°
const MK = { date: new Date('2024-06-21'), lat: 21.4225, lng: 39.8262, tz: 3 };
test('predictFajr returns finite number', () => {
const t = predictFajr(MK.date, MK.lat, MK.lng, MK.tz, 15);
assert(isFinite(t), `got ${t}`);
});
test('predictIsha returns finite number', () => {
const t = predictIsha(MK.date, MK.lat, MK.lng, MK.tz, 15);
assert(isFinite(t), `got ${t}`);
});
// Build 4 synthetic observations for NY at 15°
const NY = { lat: 40.7128, lng: -74.006, tz: -4 };
const dates = [
new Date('2024-01-15'),
new Date('2024-04-15'),
new Date('2024-07-15'),
new Date('2024-10-15'),
];
const obs = dates.map(d => ({
date: d, lat: NY.lat, lng: NY.lng, tz: NY.tz,
fajr: predictFajr(d, NY.lat, NY.lng, NY.tz, 15),
isha: predictIsha(d, NY.lat, NY.lng, NY.tz, 15),
}));
test('calibrateAngles recovers 15° angles', () => {
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.fajrAngle - 15) < 0.1, `Fajr: ${r.angles.fajrAngle}°`);
assert(Math.abs(r.angles.ishaAngle - 15) < 0.1, `Isha: ${r.angles.ishaAngle}°`);
});
test('scoreAngles perfect score < 0.01 min RMS', () => {
const s = scoreAngles(obs, 15, 15);
assert(s.rmsMinutes < 0.01, `rms=${s.rmsMinutes}`);
});
test('calibrateAngles result has all expected fields', () => {
const r = calibrateAngles(obs);
assert('angles' in r && 'rmsMinutes' in r && 'observationCount' in r && 'residuals' in r);
});
console.log('\n' + '─'.repeat(50));
console.log(`${passed}/${passed + failed} CJS tests passed`);
if (failed > 0) process.exit(1);

345
test.mjs
View file

@ -1,345 +0,0 @@
/**
* pray-calc-ml ESM test suite
* Plain Node.js assert, no test framework.
*/
import assert from 'assert';
import {
calibrateAngles,
scoreAngles,
predictFajr,
predictIsha,
} from './dist/index.mjs';
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` ${name}... PASS`);
passed++;
} catch (e) {
console.error(` ${name}... FAIL: ${e.message}`);
failed++;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Section 1: predictFajr / predictIsha
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[1] predictFajr / predictIsha');
// Makkah, summer solstice, tz=+3. ISNA uses 15°/15°.
const MK_DATE = new Date('2024-06-21');
const MK_LAT = 21.4225;
const MK_LNG = 39.8262;
const MK_TZ = 3;
test('predictFajr returns finite value for Makkah summer', () => {
const t = predictFajr(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
assert(isFinite(t), `got ${t}`);
});
test('predictIsha returns finite value for Makkah summer', () => {
const t = predictIsha(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
assert(isFinite(t), `got ${t}`);
});
test('predictFajr < predictIsha (Fajr before Isha)', () => {
const f = predictFajr(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
const i = predictIsha(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
assert(f < i, `Fajr(${f}) < Isha(${i})`);
});
test('predictFajr Makkah 15° in range 46 h', () => {
const t = predictFajr(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
assert(t > 4 && t < 6, `got ${t}`);
});
test('predictIsha Makkah 15° in range 2022 h', () => {
const t = predictIsha(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
assert(t > 20 && t < 22.5, `got ${t}`);
});
test('larger angle gives earlier Fajr', () => {
const f15 = predictFajr(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
const f18 = predictFajr(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 18);
assert(f18 < f15, `18°(${f18}) should be < 15°(${f15})`);
});
test('larger angle gives later Isha', () => {
const i15 = predictIsha(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 15);
const i18 = predictIsha(MK_DATE, MK_LAT, MK_LNG, MK_TZ, 18);
assert(i18 > i15, `18°(${i18}) should be > 15°(${i15})`);
});
test('predictFajr polar summer returns NaN when sun never sets deep enough', () => {
// Oslo (59.9°N) in deep summer at 22° should still be finite, but 30° is impossible
const t = predictFajr(new Date('2024-06-21'), 69.0, 18.0, 1, 30);
assert(isNaN(t), `expected NaN for impossible angle, got ${t}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 2: scoreAngles
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[2] scoreAngles');
// Build observations from a single location (NY, UTC-4) across multiple dates.
// We'll pretend the "true" angle is 15° and add a small ±1min noise to test scoring.
const NY_LAT = 40.7128;
const NY_LNG = -74.006;
const NY_TZ = -4;
const nyDates = [
new Date('2024-01-15'),
new Date('2024-04-15'),
new Date('2024-07-15'),
new Date('2024-10-15'),
];
const trueAngle = 15;
const nyObs = nyDates.map(d => ({
date: d, lat: NY_LAT, lng: NY_LNG, tz: NY_TZ,
fajr: predictFajr(d, NY_LAT, NY_LNG, NY_TZ, trueAngle),
isha: predictIsha(d, NY_LAT, NY_LNG, NY_TZ, trueAngle),
}));
test('scoreAngles returns object with expected keys', () => {
const s = scoreAngles(nyObs, 15, 15);
assert('rmsMinutes' in s);
assert('fajrBiasMinutes' in s);
assert('ishaBiasMinutes' in s);
assert('residuals' in s);
assert(Array.isArray(s.residuals));
});
test('scoreAngles perfect angles → rms near 0', () => {
const s = scoreAngles(nyObs, 15, 15);
assert(s.rmsMinutes < 0.01, `rms=${s.rmsMinutes}`);
});
test('scoreAngles wrong angles → rms > 0', () => {
const s = scoreAngles(nyObs, 18, 12);
assert(s.rmsMinutes > 1, `rms=${s.rmsMinutes}`);
});
test('scoreAngles residuals array length matches observations', () => {
const s = scoreAngles(nyObs, 15, 15);
assert.strictEqual(s.residuals.length, nyObs.length);
});
test('scoreAngles fajrBias near 0 for exact angles', () => {
const s = scoreAngles(nyObs, 15, 15);
assert(Math.abs(s.fajrBiasMinutes) < 0.01, `bias=${s.fajrBiasMinutes}`);
});
test('scoreAngles fajrBias negative when angle is too small (predicted too late)', () => {
// 12° → Fajr predicted later than 15° observations → bias < 0
const s = scoreAngles(nyObs, 12, 15);
assert(s.fajrBiasMinutes > 0, `expected positive bias, got ${s.fajrBiasMinutes}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 3: calibrateAngles — basic recovery
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[3] calibrateAngles — basic angle recovery');
test('calibrateAngles returns object with angles, rmsMinutes, observationCount, residuals', () => {
const r = calibrateAngles(nyObs);
assert('angles' in r);
assert('fajrAngle' in r.angles);
assert('ishaAngle' in r.angles);
assert('rmsMinutes' in r);
assert('observationCount' in r);
assert('residuals' in r);
});
test('calibrateAngles recovers 15° Fajr angle within 0.1°', () => {
const r = calibrateAngles(nyObs);
assert(
Math.abs(r.angles.fajrAngle - 15) < 0.1,
`got ${r.angles.fajrAngle}°, expected ~15°`
);
});
test('calibrateAngles recovers 15° Isha angle within 0.1°', () => {
const r = calibrateAngles(nyObs);
assert(
Math.abs(r.angles.ishaAngle - 15) < 0.1,
`got ${r.angles.ishaAngle}°, expected ~15°`
);
});
test('calibrateAngles RMS < 0.1 min for synthetic clean data', () => {
const r = calibrateAngles(nyObs);
assert(r.rmsMinutes < 0.1, `rms=${r.rmsMinutes}`);
});
test('calibrateAngles observationCount = 4 for 4 dual observations', () => {
const r = calibrateAngles(nyObs);
assert.strictEqual(r.observationCount, 4);
});
test('calibrateAngles residuals length matches input', () => {
const r = calibrateAngles(nyObs);
assert.strictEqual(r.residuals.length, nyObs.length);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 4: calibrateAngles — different target angles
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[4] calibrateAngles — recovering non-default angles');
function makeObs(angle, fajrOverride, ishaOverride) {
return nyDates.map(d => ({
date: d, lat: NY_LAT, lng: NY_LNG, tz: NY_TZ,
fajr: fajrOverride !== undefined
? predictFajr(d, NY_LAT, NY_LNG, NY_TZ, fajrOverride)
: undefined,
isha: ishaOverride !== undefined
? predictIsha(d, NY_LAT, NY_LNG, NY_TZ, ishaOverride)
: undefined,
}));
}
test('calibrateAngles recovers 18° Fajr', () => {
const obs = makeObs(null, 18, 18);
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.fajrAngle - 18) < 0.1, `got ${r.angles.fajrAngle}°`);
});
test('calibrateAngles recovers 12° Isha', () => {
const obs = makeObs(null, 12, 12);
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.ishaAngle - 12) < 0.1, `got ${r.angles.ishaAngle}°`);
});
test('calibrateAngles recovers asymmetric Fajr=17, Isha=13', () => {
const obs = makeObs(null, 17, 13);
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.fajrAngle - 17) < 0.1, `Fajr: got ${r.angles.fajrAngle}°`);
assert(Math.abs(r.angles.ishaAngle - 13) < 0.1, `Isha: got ${r.angles.ishaAngle}°`);
});
test('calibrateAngles works with Fajr-only observations', () => {
const obs = makeObs(null, 15, undefined);
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.fajrAngle - 15) < 0.1, `Fajr: got ${r.angles.fajrAngle}°`);
});
test('calibrateAngles works with Isha-only observations', () => {
const obs = makeObs(null, undefined, 15);
const r = calibrateAngles(obs);
assert(Math.abs(r.angles.ishaAngle - 15) < 0.1, `Isha: got ${r.angles.ishaAngle}°`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 5: calibrateAngles — weighted observations
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[5] calibrateAngles — weighted regression');
test('high-weight observation pulls calibrated angle toward its implied angle', () => {
// Mix: 3 obs from 15° angle (weight 1) + 1 obs from 20° angle (weight 20)
// The high-weight 20° obs should dominate, pulling result toward 20°.
const obs15 = nyDates.slice(0, 3).map(d => ({
date: d, lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, weight: 1,
fajr: predictFajr(d, NY_LAT, NY_LNG, NY_TZ, 15),
isha: predictIsha(d, NY_LAT, NY_LNG, NY_TZ, 15),
}));
const obs20 = [{
date: nyDates[3], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, weight: 20,
fajr: predictFajr(nyDates[3], NY_LAT, NY_LNG, NY_TZ, 20),
isha: predictIsha(nyDates[3], NY_LAT, NY_LNG, NY_TZ, 20),
}];
const r = calibrateAngles([...obs15, ...obs20]);
assert(r.angles.fajrAngle > 17, `expected > 17°, got ${r.angles.fajrAngle}°`);
});
test('equal weights produce result between two angle sets', () => {
const obs15 = nyDates.slice(0, 2).map(d => ({
date: d, lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, weight: 1,
fajr: predictFajr(d, NY_LAT, NY_LNG, NY_TZ, 15),
isha: predictIsha(d, NY_LAT, NY_LNG, NY_TZ, 15),
}));
const obs18 = nyDates.slice(2).map(d => ({
date: d, lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, weight: 1,
fajr: predictFajr(d, NY_LAT, NY_LNG, NY_TZ, 18),
isha: predictIsha(d, NY_LAT, NY_LNG, NY_TZ, 18),
}));
const r = calibrateAngles([...obs15, ...obs18]);
assert(r.angles.fajrAngle > 15 && r.angles.fajrAngle < 18,
`expected 1518°, got ${r.angles.fajrAngle}°`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 6: calibrateAngles — multi-location
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[6] calibrateAngles — multi-location dataset');
const locations = [
{ lat: 21.4225, lng: 39.8262, tz: 3 }, // Makkah
{ lat: 40.7128, lng: -74.0060, tz: -4 }, // New York
{ lat: 51.5074, lng: -0.1278, tz: 1 }, // London
{ lat: 1.3521, lng: 103.8198, tz: 8 }, // Singapore
];
const multiDates = [new Date('2024-01-15'), new Date('2024-07-15')];
const TARGET_ANGLE = 16.5;
const multiObs = locations.flatMap(loc =>
multiDates.map(d => ({
date: d, lat: loc.lat, lng: loc.lng, tz: loc.tz,
fajr: predictFajr(d, loc.lat, loc.lng, loc.tz, TARGET_ANGLE),
isha: predictIsha(d, loc.lat, loc.lng, loc.tz, TARGET_ANGLE),
}))
);
test('calibrateAngles recovers 16.5° from multi-location data within 0.2°', () => {
const r = calibrateAngles(multiObs);
assert(Math.abs(r.angles.fajrAngle - TARGET_ANGLE) < 0.2, `got ${r.angles.fajrAngle}°`);
assert(Math.abs(r.angles.ishaAngle - TARGET_ANGLE) < 0.2, `got ${r.angles.ishaAngle}°`);
});
test('multi-location RMS < 0.1 min for clean synthetic data', () => {
const r = calibrateAngles(multiObs);
assert(r.rmsMinutes < 0.1, `rms=${r.rmsMinutes}`);
});
// ─────────────────────────────────────────────────────────────────────────────
// Section 7: error handling
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n[7] error handling');
test('calibrateAngles throws when both Fajr and Isha have fewer than 2 observations', () => {
// 1 Fajr + 1 Isha — neither can be calibrated, so this must throw.
const obs = [{ date: nyDates[0], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, fajr: 5.5, isha: 21.0 }];
let threw = false;
try { calibrateAngles(obs); } catch { threw = true; }
assert(threw, 'expected error when both have <2 observations');
});
test('calibrateAngles does NOT throw with 4 Fajr + 1 Isha (Fajr calibrated, Isha gets default)', () => {
// 4 Fajr-only + 1 dual: Fajr gets calibrated, Isha falls back to fajrAngle0 default.
const obs = [
{ date: nyDates[0], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, fajr: 5.0 },
{ date: nyDates[1], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, fajr: 5.2 },
{ date: nyDates[2], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, fajr: 4.8 },
{ date: nyDates[3], lat: NY_LAT, lng: NY_LNG, tz: NY_TZ, fajr: 5.1, isha: 21.0 },
];
const r = calibrateAngles(obs);
assert(isFinite(r.angles.fajrAngle), `Fajr angle should be finite, got ${r.angles.fajrAngle}`);
assert(isFinite(r.angles.ishaAngle), `Isha angle should be finite (default), got ${r.angles.ishaAngle}`);
});
test('scoreAngles handles empty array gracefully', () => {
const s = scoreAngles([], 15, 15);
assert.strictEqual(s.rmsMinutes, 0);
assert.strictEqual(s.residuals.length, 0);
});
// ─────────────────────────────────────────────────────────────────────────────
// Summary
// ─────────────────────────────────────────────────────────────────────────────
console.log('\n' + '─'.repeat(50));
console.log(`${passed}/${passed + failed} tests passed`);
if (failed > 0) process.exit(1);

View file

@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,16 +0,0 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
clean: true,
outDir: 'dist',
splitting: false,
sourcemap: true,
target: 'es2020',
platform: 'neutral',
outExtension({ format }) {
return { js: format === 'cjs' ? '.cjs' : '.mjs' };
},
});