mihrab-methods 1.3.2__tar.gz

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,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: mihrab-methods
3
+ Version: 1.3.2
4
+ Summary: The prayer-time authorities' own calculation methods, each reproducing its published timetable to the minute: a port of the mihrab-methods library.
5
+ Author: Hassan El Ghamri
6
+ License: MPL-2.0
7
+ Project-URL: Homepage, https://github.com/MihrabHQ/mihrab-methods
8
+ Project-URL: Documentation, https://github.com/MihrabHQ/mihrab-methods/tree/main/ports/python
9
+ Project-URL: Changelog, https://github.com/MihrabHQ/mihrab-methods/blob/main/CHANGELOG.md
10
+ Keywords: prayer-times,salah,adhan,islam,diyanet,mihrab
11
+ Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Religion
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: tzdata; platform_system == "Windows"
17
+
18
+ # mihrab-methods in Python
19
+
20
+ The methods of [mihrab-methods](../../README.md) — Sweden, Diyanet,
21
+ Morocco, Algeria, Indonesia and Türkiye Takvimi — in plain Python, with no dependencies
22
+ beyond the standard library (Python 3.9 or later, for `zoneinfo`).
23
+
24
+ The TypeScript library is the reference. This port follows it line for
25
+ line, is held to `test/vectors.json`, and names the method revisions it
26
+ reproduces in `mihrab_methods.REVISIONS`. A correction list belongs to one
27
+ revision: when a method's revision is raised in the library, this port is
28
+ behind until it is raised here too.
29
+
30
+ ```sh
31
+ pip install mihrab-methods
32
+ ```
33
+
34
+ The package carries the correction lists of its release; a version has the
35
+ same number as the npm package it was built with.
36
+
37
+ ```python
38
+ from mihrab_methods import load_corrections, times_for
39
+
40
+ lists = load_corrections() # the package's own; or a directory laid out as data/corrections
41
+ stockholm = {'authority': 'sweden', 'lat': 59.33, 'lon': 18.034, 'tz': 'Europe/Stockholm'}
42
+ day = times_for(stockholm, '2026-11-12', lists['sweden'])
43
+ print(day['printed']) # the six times as 'HH:MM' on the clock the table prints
44
+ print(day['instants']) # the same six times as timezone-aware datetimes
45
+ ```
46
+
47
+ A place is a dict shaped like the library's `Place`: `authority`, `lat`,
48
+ `lon`, `tz`, and the fields a list gives some of its places (`ishaDeg`,
49
+ `clock`, `prayerPoints`, `dipDeg`, `dipMaghribDeg`, `highland`,
50
+ `twilightDip`, `afternoon`, `temkin`). The places themselves are in `data/places/`;
51
+ use the coordinates exactly as the list has them, since the correction
52
+ lists are looked up by them.
53
+
54
+ Without a correction list `times_for` gives the bare method: within a
55
+ minute of the full answer on the days a list has an entry for, the same on
56
+ every other day.
57
+
58
+ ## Check
59
+
60
+ From the repository root:
61
+
62
+ ```sh
63
+ python3 ports/python/test_vectors.py
64
+ ```
65
+
66
+ The zone offsets come from the machine's time-zone database, as they do in
67
+ the library; two machines with different editions of it can differ on a
68
+ zone whose rules changed between them.
69
+
70
+ ## What is where
71
+
72
+ | File | What |
73
+ | --- | --- |
74
+ | `mihrab_methods/sun.py` | The three positions of the sun the methods use |
75
+ | `mihrab_methods/methods.py` | The methods: the Congress method (Sweden, Diyanet), Morocco, Algeria, Indonesia, Türkiye Takvimi |
76
+ | `mihrab_methods/api.py` | Place and date in, the day out: clock, correction list, printing |
77
+ | `test_vectors.py` | Holds the port to `test/vectors.json` |
@@ -0,0 +1,60 @@
1
+ # mihrab-methods in Python
2
+
3
+ The methods of [mihrab-methods](../../README.md) — Sweden, Diyanet,
4
+ Morocco, Algeria, Indonesia and Türkiye Takvimi — in plain Python, with no dependencies
5
+ beyond the standard library (Python 3.9 or later, for `zoneinfo`).
6
+
7
+ The TypeScript library is the reference. This port follows it line for
8
+ line, is held to `test/vectors.json`, and names the method revisions it
9
+ reproduces in `mihrab_methods.REVISIONS`. A correction list belongs to one
10
+ revision: when a method's revision is raised in the library, this port is
11
+ behind until it is raised here too.
12
+
13
+ ```sh
14
+ pip install mihrab-methods
15
+ ```
16
+
17
+ The package carries the correction lists of its release; a version has the
18
+ same number as the npm package it was built with.
19
+
20
+ ```python
21
+ from mihrab_methods import load_corrections, times_for
22
+
23
+ lists = load_corrections() # the package's own; or a directory laid out as data/corrections
24
+ stockholm = {'authority': 'sweden', 'lat': 59.33, 'lon': 18.034, 'tz': 'Europe/Stockholm'}
25
+ day = times_for(stockholm, '2026-11-12', lists['sweden'])
26
+ print(day['printed']) # the six times as 'HH:MM' on the clock the table prints
27
+ print(day['instants']) # the same six times as timezone-aware datetimes
28
+ ```
29
+
30
+ A place is a dict shaped like the library's `Place`: `authority`, `lat`,
31
+ `lon`, `tz`, and the fields a list gives some of its places (`ishaDeg`,
32
+ `clock`, `prayerPoints`, `dipDeg`, `dipMaghribDeg`, `highland`,
33
+ `twilightDip`, `afternoon`, `temkin`). The places themselves are in `data/places/`;
34
+ use the coordinates exactly as the list has them, since the correction
35
+ lists are looked up by them.
36
+
37
+ Without a correction list `times_for` gives the bare method: within a
38
+ minute of the full answer on the days a list has an entry for, the same on
39
+ every other day.
40
+
41
+ ## Check
42
+
43
+ From the repository root:
44
+
45
+ ```sh
46
+ python3 ports/python/test_vectors.py
47
+ ```
48
+
49
+ The zone offsets come from the machine's time-zone database, as they do in
50
+ the library; two machines with different editions of it can differ on a
51
+ zone whose rules changed between them.
52
+
53
+ ## What is where
54
+
55
+ | File | What |
56
+ | --- | --- |
57
+ | `mihrab_methods/sun.py` | The three positions of the sun the methods use |
58
+ | `mihrab_methods/methods.py` | The methods: the Congress method (Sweden, Diyanet), Morocco, Algeria, Indonesia, Türkiye Takvimi |
59
+ | `mihrab_methods/api.py` | Place and date in, the day out: clock, correction list, printing |
60
+ | `test_vectors.py` | Holds the port to `test/vectors.json` |
@@ -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']
@@ -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
+ }