mihrab-methods 1.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """mihrab-methods in Python: the prayer-time methods of five authorities,
2
+ ported from the TypeScript library and held to its test vectors."""
3
+ from .api import (PRAYERS, REVISIONS, CorrectionList, clock_offset_at, load_corrections, minutes_for, place_clock_at,
4
+ point_key, printed, times_for)
5
+
6
+ __all__ = ['PRAYERS', 'REVISIONS', 'CorrectionList', 'clock_offset_at', 'load_corrections', 'minutes_for',
7
+ 'place_clock_at', 'point_key', 'printed', 'times_for']
mihrab_methods/api.py ADDED
@@ -0,0 +1,162 @@
1
+ """A place and a date in, the day's six times out: the authority's method,
2
+ then its correction list — what `timesFor` does in the TypeScript library.
3
+
4
+ A place is a dict shaped like the library's `Place` (see docs/api.md):
5
+ `authority`, `lat`, `lon`, `tz`, and where the list has them `ishaDeg`,
6
+ `clock`, `prayerPoints`, `dipDeg`, `dipMaghribDeg`, `highland`,
7
+ `twilightDip`, `afternoon`, `temkin`.
8
+ """
9
+ import datetime
10
+ import json
11
+ import math
12
+ import os
13
+ from decimal import ROUND_HALF_UP, Decimal
14
+ from zoneinfo import ZoneInfo
15
+
16
+ from . import methods
17
+
18
+ PRAYERS = ('fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha')
19
+ #: The method revisions this port reproduces. A correction list belongs to one revision.
20
+ REVISIONS = {'sweden': 1, 'diyanet': 1, 'morocco': 2, 'algeria': 1, 'indonesia': 2, 'turkiyetakvimi': 1}
21
+
22
+ _UTC = datetime.timezone.utc
23
+
24
+
25
+ def _to_fixed(x):
26
+ """A coordinate to three decimals, as JavaScript's toFixed(3) writes it:
27
+ the binary value's exact decimal expansion, ties away from zero."""
28
+ return str(Decimal(float(x) + 0.0).quantize(Decimal('0.001'), rounding=ROUND_HALF_UP)) # + 0.0: -0 is written 0.000
29
+
30
+
31
+ def point_key(lat, lon):
32
+ return '%s,%s' % (_to_fixed(lat), _to_fixed(lon))
33
+
34
+
35
+ class CorrectionList:
36
+ """data/corrections/<authority>.json, indexed: point -> date -> {prayer: minutes}."""
37
+
38
+ def __init__(self, source):
39
+ self.source = source
40
+ self._index = {}
41
+ for point, entries in source.get('points', {}).items():
42
+ by_date = self._index.setdefault(point, {})
43
+ for e in entries:
44
+ date, key, step = e.split(' ')
45
+ by_date.setdefault(date, {})[key] = int(step)
46
+
47
+ @classmethod
48
+ def load(cls, path):
49
+ with open(path, encoding='utf-8') as f:
50
+ return cls(json.load(f))
51
+
52
+ def correction(self, lat, lon, date):
53
+ return self._index.get(point_key(lat, lon), {}).get(date)
54
+
55
+
56
+ def clock_offset_at(tz, year, month0, day):
57
+ """Minutes east of UTC that a zone keeps at noon UTC of a date."""
58
+ at = datetime.datetime(year, month0 + 1, 1, 12, tzinfo=_UTC) + datetime.timedelta(days=day - 1)
59
+ return round(at.astimezone(ZoneInfo(tz)).utcoffset().total_seconds() / 60)
60
+
61
+
62
+ def place_clock_at(place, year, month0, day):
63
+ """The clock a place's table prints a date in: its own where it has one, else its zone's."""
64
+ clock = place.get('clock')
65
+ if clock:
66
+ date = '%04d-%02d-%02d' % (year, month0 + 1, day)
67
+ off = clock[0][1]
68
+ for start, o in clock:
69
+ if start <= date:
70
+ off = o
71
+ return off
72
+ return clock_offset_at(place['tz'], year, month0, day)
73
+
74
+
75
+ def _congress(place, year, month0, day, asr_shadow):
76
+ clock = place_clock_at(place, year, month0, day)
77
+ isha_deg = place.get('ishaDeg', methods.CONGRESS_ISHA_DEG)
78
+ pts_by_year = place.get('prayerPoints')
79
+ if pts_by_year:
80
+ years = sorted(int(y) for y in pts_by_year)
81
+ best = years[0]
82
+ for y in years:
83
+ if abs(y - year) < abs(best - year):
84
+ best = y
85
+ pts = pts_by_year[str(best)]
86
+ raw = {k: methods.congress_minutes(pts[i][0], pts[i][1], year, month0, day, asr_shadow, isha_deg, clock)[k]
87
+ for i, k in enumerate(PRAYERS)}
88
+ else:
89
+ raw = methods.congress_minutes(place['lat'], place['lon'], year, month0, day, asr_shadow, isha_deg, clock)
90
+ # the library carries an instant in whole milliseconds, then rounds it to the minute
91
+ return {k: math.floor(math.trunc(v * 60000) / 60000 + 0.5) for k, v in raw.items()}
92
+
93
+
94
+ def minutes_for(place, date, corrections=None, asr_shadow=1):
95
+ """Minutes past 00:00 UT of `date` ('YYYY-MM-DD') for the six times, or None where the method gives none."""
96
+ year, month, day = (int(p) for p in date.split('-'))
97
+ month0 = month - 1
98
+ a = place['authority']
99
+ if a in ('sweden', 'diyanet'):
100
+ t = _congress(place, year, month0, day, asr_shadow)
101
+ elif a == 'morocco':
102
+ t = methods.morocco_minutes(place['lat'], place['lon'], year, month0, day, place.get('dipDeg'), asr_shadow,
103
+ place.get('dipMaghribDeg'))
104
+ elif a == 'algeria':
105
+ t = methods.algeria_minutes(place['lat'], place['lon'], year, month0, day, asr_shadow)
106
+ elif a == 'indonesia':
107
+ t = methods.kemenag_minutes(place['lat'], place['lon'], year, month0, day, bool(place.get('highland')),
108
+ bool(place.get('twilightDip')), asr_shadow, place.get('afternoon'))
109
+ elif a == 'turkiyetakvimi':
110
+ t = methods.takvim_minutes(place['lat'], place['lon'], year, month0, day, place.get('temkin'), asr_shadow)
111
+ else:
112
+ raise ValueError('unknown authority: %r' % (a,))
113
+ if t is None:
114
+ return None
115
+ c = corrections.correction(place['lat'], place['lon'], date) if corrections else None
116
+ if c:
117
+ for k, step in c.items():
118
+ if not (k == 'asr' and asr_shadow != 1): # the lists are of the standard Asr only
119
+ t[k] += step
120
+ return t
121
+
122
+
123
+ def printed(minutes, offset_minutes):
124
+ """'HH:MM' of a time on a clock, as a table prints it."""
125
+ m = (minutes + offset_minutes) % 1440
126
+ return '%02d:%02d' % (m // 60, m % 60)
127
+
128
+
129
+ def times_for(place, date, corrections=None, asr_shadow=1):
130
+ """The day as a table prints it: {'clock': minutes east of UTC, 'printed': {...}, 'instants': {...}} or None.
131
+
132
+ `corrections` is the authority's CorrectionList; without it the times are the bare method's,
133
+ which is the published table to within the list's few minutes, not on every one.
134
+ """
135
+ t = minutes_for(place, date, corrections, asr_shadow)
136
+ if t is None:
137
+ return None
138
+ year, month, day = (int(p) for p in date.split('-'))
139
+ off = place_clock_at(place, year, month - 1, day)
140
+ start = datetime.datetime(year, month, day, tzinfo=_UTC)
141
+ return {
142
+ 'clock': off,
143
+ 'printed': {k: printed(t[k], off) for k in PRAYERS},
144
+ 'instants': {k: start + datetime.timedelta(minutes=t[k]) for k in PRAYERS},
145
+ }
146
+
147
+
148
+ #: The lists the installed package carries (those of its release); absent in a source checkout.
149
+ BUNDLED_CORRECTIONS = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'corrections')
150
+
151
+
152
+ def load_corrections(directory=None):
153
+ """Every list in a directory laid out as the repository's data/corrections;
154
+ with no directory, the lists the installed package carries."""
155
+ if directory is None:
156
+ directory = BUNDLED_CORRECTIONS
157
+ out = {}
158
+ for a in REVISIONS:
159
+ p = os.path.join(directory, a + '.json')
160
+ if os.path.exists(p):
161
+ out[a] = CorrectionList.load(p)
162
+ return out
@@ -0,0 +1,154 @@
1
+ {
2
+ "sweden": {
3
+ "certified": true,
4
+ "checkedAt": "2026-11-09",
5
+ "places": 108,
6
+ "checks": [
7
+ {
8
+ "tables": "Sweden — Islamiska Förbundet’s tables",
9
+ "places": 107,
10
+ "times": 63114,
11
+ "off": 0,
12
+ "offWithoutCorrections": 20,
13
+ "from": "2026-10-11",
14
+ "to": "2027-12-14",
15
+ "setAside": {
16
+ "misplaced": [],
17
+ "sourceFaults": 35491,
18
+ "copiedRows": 696
19
+ }
20
+ },
21
+ {
22
+ "tables": "Sweden — Diyanet’s own tables",
23
+ "places": 159,
24
+ "times": 1393794,
25
+ "off": 0,
26
+ "offWithoutCorrections": 259,
27
+ "from": "2024-01-01",
28
+ "to": "2027-12-31"
29
+ }
30
+ ],
31
+ "corrections": {
32
+ "from": "2024-01-01",
33
+ "to": "2027-12-31",
34
+ "entries": 270,
35
+ "sha256": "173d7145b5ec917882f8ac9890dd1fa5f3143bd714eb540482861bf8a6bac660"
36
+ },
37
+ "revision": 1
38
+ },
39
+ "diyanet": {
40
+ "certified": true,
41
+ "checkedAt": "2026-11-11",
42
+ "places": 8318,
43
+ "checks": [
44
+ {
45
+ "tables": "Diyanet — its tables worldwide",
46
+ "places": 8318,
47
+ "times": 71630078,
48
+ "off": 0,
49
+ "offWithoutCorrections": 9368,
50
+ "from": "2024-01-01",
51
+ "to": "2027-12-31"
52
+ }
53
+ ],
54
+ "corrections": {
55
+ "from": "2024-01-01",
56
+ "to": "2027-12-31",
57
+ "entries": 9357,
58
+ "sha256": "a8c6a7305611f8dcedc04a64efb8dd150d9a588c2b3ac3391bfb776d70a2ec83"
59
+ },
60
+ "revision": 1
61
+ },
62
+ "morocco": {
63
+ "certified": true,
64
+ "checkedAt": "2026-11-11",
65
+ "places": 164,
66
+ "checks": [
67
+ {
68
+ "tables": "Morocco — Habous",
69
+ "places": 164,
70
+ "times": 94350,
71
+ "off": 0,
72
+ "offWithoutCorrections": 114,
73
+ "from": "2026-08-27",
74
+ "to": "2026-11-30"
75
+ }
76
+ ],
77
+ "corrections": {
78
+ "from": "2026-08-27",
79
+ "to": "2026-11-30",
80
+ "entries": 114,
81
+ "sha256": "2ae7161a7164f7ccebf1aa9f81628a810ce8fdd337e0645907e6c58d0be6aae9"
82
+ },
83
+ "revision": 2
84
+ },
85
+ "algeria": {
86
+ "certified": true,
87
+ "checkedAt": "2026-11-09",
88
+ "places": 68,
89
+ "checks": [
90
+ {
91
+ "tables": "Algeria — Ministry of Religious Affairs",
92
+ "places": 68,
93
+ "times": 101184,
94
+ "off": 0,
95
+ "offWithoutCorrections": 642,
96
+ "from": "2026-10-01",
97
+ "to": "2027-06-05"
98
+ }
99
+ ],
100
+ "corrections": {
101
+ "from": "2026-10-01",
102
+ "to": "2027-06-05",
103
+ "entries": 642,
104
+ "sha256": "f8d227cdd88aabe5bcd1775ec0324abfdae07a8a3986fdd19816337c2d4ab3ca"
105
+ },
106
+ "revision": 1
107
+ },
108
+ "indonesia": {
109
+ "certified": true,
110
+ "checkedAt": "2026-11-11",
111
+ "places": 517,
112
+ "checks": [
113
+ {
114
+ "tables": "Indonesia — Kemenag",
115
+ "places": 517,
116
+ "times": 362934,
117
+ "off": 0,
118
+ "offWithoutCorrections": 234,
119
+ "from": "2026-10-07",
120
+ "to": "2027-01-31"
121
+ }
122
+ ],
123
+ "corrections": {
124
+ "from": "2026-10-07",
125
+ "to": "2027-01-31",
126
+ "entries": 234,
127
+ "sha256": "a3f1c30576ddddbfda6cadabe442e1bcf3fb476b018b269d7d4783edd4e39d03"
128
+ },
129
+ "revision": 2
130
+ },
131
+ "turkiyetakvimi": {
132
+ "certified": true,
133
+ "checkedAt": "2026-11-11",
134
+ "places": 821,
135
+ "checks": [
136
+ {
137
+ "tables": "Türkiye Takvimi — its cities’ years",
138
+ "places": 821,
139
+ "times": 1812768,
140
+ "off": 0,
141
+ "offWithoutCorrections": 860,
142
+ "from": "2025-12-31",
143
+ "to": "2027-01-02"
144
+ }
145
+ ],
146
+ "corrections": {
147
+ "from": "2025-12-31",
148
+ "to": "2027-01-02",
149
+ "entries": 860,
150
+ "sha256": "edf5bd37ccd06dbcb87300a6c943353adb624a545ff6ea416a8f89715713d7bd"
151
+ },
152
+ "revision": 1
153
+ }
154
+ }