praytime 0.1.0__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.
- praytime/__init__.py +21 -0
- praytime/__main__.py +122 -0
- praytime/astro.py +114 -0
- praytime/constants.py +168 -0
- praytime/formatting.py +141 -0
- praytime/highlat.py +86 -0
- praytime/methods.py +41 -0
- praytime/praytime.py +292 -0
- praytime/py.typed +0 -0
- praytime/trig.py +102 -0
- praytime-0.1.0.dist-info/METADATA +348 -0
- praytime-0.1.0.dist-info/RECORD +16 -0
- praytime-0.1.0.dist-info/WHEEL +5 -0
- praytime-0.1.0.dist-info/entry_points.txt +2 -0
- praytime-0.1.0.dist-info/licenses/LICENSE +21 -0
- praytime-0.1.0.dist-info/top_level.txt +1 -0
praytime/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""praytime — zero-dependency Islamic prayer times calculator.
|
|
2
|
+
|
|
3
|
+
Python port of ``zarrabi/praytime``. Public surface: :class:`PrayTime`,
|
|
4
|
+
:data:`METHODS`, and :data:`__version__`.
|
|
5
|
+
|
|
6
|
+
.. warning::
|
|
7
|
+
This package was ported from the original ``zarrabi/praytime`` library using
|
|
8
|
+
AI-agent code-generation tooling. Despite extensive testing against the
|
|
9
|
+
upstream implementation, it may contain bugs in prayer-time calculations.
|
|
10
|
+
Verify the times before relying on them, and use it at your own
|
|
11
|
+
responsibility (provided "as is", without warranty of any kind).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .constants import METHODS
|
|
17
|
+
from .praytime import PrayTime
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["PrayTime", "METHODS", "__version__"]
|
praytime/__main__.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command-line interface for :mod:`praytime` (plan.md Phase 6).
|
|
2
|
+
|
|
3
|
+
Runnable as ``python -m praytime`` (or as the ``praytime`` console script once
|
|
4
|
+
installed). Computes the eight daily prayer times for a location, date, and
|
|
5
|
+
calculation method and prints them with aligned labels.
|
|
6
|
+
|
|
7
|
+
Standard library only; no third-party runtime dependencies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
from typing import List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from .constants import METHODS
|
|
17
|
+
from .praytime import PrayTime
|
|
18
|
+
|
|
19
|
+
#: Selectable method names, excluding the internal ``defaults`` entry
|
|
20
|
+
#: (specs.md §4).
|
|
21
|
+
METHOD_NAMES: List[str] = [name for name in METHODS if name != "defaults"]
|
|
22
|
+
|
|
23
|
+
#: Allowed ``--format`` values (specs.md §9.1).
|
|
24
|
+
FORMATS: List[str] = ["24h", "12h", "12H", "x", "X"]
|
|
25
|
+
|
|
26
|
+
#: Allowed ``--asr`` values (specs.md §5).
|
|
27
|
+
ASR_CHOICES: List[str] = ["Standard", "Hanafi"]
|
|
28
|
+
|
|
29
|
+
#: Allowed ``--high-lats`` values (specs.md §6).
|
|
30
|
+
HIGH_LATS_CHOICES: List[str] = ["None", "NightMiddle", "OneSeventh", "AngleBased"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_date(value: str) -> Tuple[int, int, int]:
|
|
34
|
+
"""Parse an ISO ``YYYY-MM-DD`` date into a 1-based ``(year, month, day)`` tuple."""
|
|
35
|
+
try:
|
|
36
|
+
parsed = _dt.date.fromisoformat(value)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise argparse.ArgumentTypeError(
|
|
39
|
+
f"invalid date {value!r}; expected YYYY-MM-DD"
|
|
40
|
+
) from exc
|
|
41
|
+
return parsed.year, parsed.month, parsed.day
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
"""Create the :mod:`argparse` argument parser for the CLI."""
|
|
46
|
+
parser = argparse.ArgumentParser(
|
|
47
|
+
prog="praytime",
|
|
48
|
+
description=(
|
|
49
|
+
"Compute the eight daily Islamic prayer times for a location and "
|
|
50
|
+
"date (Python port of zarrabi/praytime)."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--method",
|
|
55
|
+
default="MWL",
|
|
56
|
+
choices=METHOD_NAMES,
|
|
57
|
+
help="calculation method (default: MWL)",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument("--lat", type=float, required=True, help="latitude in degrees")
|
|
60
|
+
parser.add_argument("--lng", type=float, required=True, help="longitude in degrees")
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--timezone",
|
|
63
|
+
default="UTC",
|
|
64
|
+
help="IANA timezone name used for display (default: UTC)",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--date",
|
|
68
|
+
type=_parse_date,
|
|
69
|
+
default=None,
|
|
70
|
+
help="date as YYYY-MM-DD (default: today)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--format",
|
|
74
|
+
default="24h",
|
|
75
|
+
choices=FORMATS,
|
|
76
|
+
help="output format (default: 24h)",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--asr",
|
|
80
|
+
default="Standard",
|
|
81
|
+
choices=ASR_CHOICES,
|
|
82
|
+
help="Asr madhab (default: Standard)",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--high-lats",
|
|
86
|
+
dest="high_lats",
|
|
87
|
+
default="NightMiddle",
|
|
88
|
+
choices=HIGH_LATS_CHOICES,
|
|
89
|
+
help="high-latitude adjustment rule (default: NightMiddle)",
|
|
90
|
+
)
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
95
|
+
"""Run the ``praytime`` command line and print the eight prayer times."""
|
|
96
|
+
args = build_parser().parse_args(argv)
|
|
97
|
+
|
|
98
|
+
date = args.date if args.date is not None else _today()
|
|
99
|
+
|
|
100
|
+
praytime = (
|
|
101
|
+
PrayTime(args.method)
|
|
102
|
+
.location([args.lat, args.lng])
|
|
103
|
+
.timezone(args.timezone)
|
|
104
|
+
.adjust({"asr": args.asr, "highLats": args.high_lats})
|
|
105
|
+
.format(args.format)
|
|
106
|
+
)
|
|
107
|
+
times = praytime.times(date)
|
|
108
|
+
|
|
109
|
+
width = max(len(label) for label in praytime.labels)
|
|
110
|
+
for label, key in zip(praytime.labels, times):
|
|
111
|
+
print(f"{label:<{width}} {times[key]}")
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _today() -> Tuple[int, int, int]:
|
|
116
|
+
"""Return today's host-local date as a ``(year, month, day)`` tuple."""
|
|
117
|
+
today = _dt.date.today()
|
|
118
|
+
return today.year, today.month, today.day
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
raise SystemExit(main())
|
praytime/astro.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Pure astronomical core (specs.md §8.2–§8.6).
|
|
2
|
+
|
|
3
|
+
Implements the approximate solar-coordinate algorithm and the hour-angle
|
|
4
|
+
trigonometry used by the prayer-time pipeline:
|
|
5
|
+
|
|
6
|
+
* :func:`julian_offset` — the Julian-day offset term ``D`` (specs.md §8.2);
|
|
7
|
+
* :func:`sun_position` — solar declination and equation of time (specs.md §8.2);
|
|
8
|
+
* :func:`mid_day` — local solar noon / Dhuhr (specs.md §8.3);
|
|
9
|
+
* :func:`angle_time` — time the sun reaches a given altitude (specs.md §8.4);
|
|
10
|
+
* :func:`asr_angle` — Asr shadow-angle (specs.md §8.6, §5).
|
|
11
|
+
|
|
12
|
+
Every function is **pure**: it takes the inputs it needs explicitly (the UTC
|
|
13
|
+
midnight timestamp ``utc_ms`` and the longitude ``lng`` are threaded through
|
|
14
|
+
rather than read from instance state) and holds no global state. All angles are
|
|
15
|
+
in degrees and all times are local solar hours. Operation order mirrors the JS
|
|
16
|
+
source exactly so the double-precision results agree to the last ULP.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any, Dict, NamedTuple, Union
|
|
22
|
+
|
|
23
|
+
from . import constants as C
|
|
24
|
+
from .formatting import parse_value
|
|
25
|
+
from .trig import acos, acot, asin, atan2, cos, mod, sin, tan
|
|
26
|
+
|
|
27
|
+
#: Type accepted for the ``time`` seed: a number or a JS-style string such as
|
|
28
|
+
#: ``'90 min'``. Non-numeric strings coerce to ``nan`` (specs.md §8.1).
|
|
29
|
+
TimeValue = Union[int, float, str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SunPosition(NamedTuple):
|
|
33
|
+
"""Solar declination (degrees) and equation of time (hours)."""
|
|
34
|
+
|
|
35
|
+
declination: float
|
|
36
|
+
equation: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
#: Asr shadow-length factors for the named madhabs (specs.md §5).
|
|
40
|
+
_SHADOW_FACTORS: Dict[str, int] = {
|
|
41
|
+
"Standard": C.SHADOW_STANDARD,
|
|
42
|
+
"Hanafi": C.SHADOW_HANAFI,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def julian_offset(utc_ms: float, time: TimeValue, lng: float) -> float:
|
|
47
|
+
"""Return the Julian-day offset ``D`` from the J2000.0 epoch (specs.md §8.2).
|
|
48
|
+
|
|
49
|
+
Mirrors ``utcTime / 864e5 - 10957.5 + value(time) / 24 - lng / 360``.
|
|
50
|
+
"""
|
|
51
|
+
return (
|
|
52
|
+
utc_ms / C.MS_PER_DAY
|
|
53
|
+
- C.J2000_OFFSET_DAYS
|
|
54
|
+
+ parse_value(time) / C.HOURS_PER_DAY
|
|
55
|
+
- lng / 360
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sun_position(time: TimeValue, utc_ms: float, lng: float) -> SunPosition:
|
|
60
|
+
"""Return the sun's declination and equation of time (specs.md §8.2).
|
|
61
|
+
|
|
62
|
+
Uses the USNO approximate solar-coordinate algorithm; the Earth-Sun
|
|
63
|
+
distance term is not used. ``time`` seeds the fractional solar day.
|
|
64
|
+
"""
|
|
65
|
+
d = julian_offset(utc_ms, time, lng)
|
|
66
|
+
|
|
67
|
+
g = mod(C.MEAN_ANOMALY_BASE + C.MEAN_ANOMALY_RATE * d, 360)
|
|
68
|
+
q = mod(C.MEAN_LONGITUDE_BASE + C.MEAN_LONGITUDE_RATE * d, 360)
|
|
69
|
+
L = mod(q + C.EQ_CENTER_1 * sin(g) + C.EQ_CENTER_2 * sin(2 * g), 360)
|
|
70
|
+
e = C.OBLIQUITY_J2000 - C.OBLIQUITY_RATE * d
|
|
71
|
+
ra = mod(atan2(cos(e) * sin(L), cos(L)) / C.DEG_PER_HOUR, 24)
|
|
72
|
+
|
|
73
|
+
declination = asin(sin(e) * sin(L))
|
|
74
|
+
equation = q / 15 - ra
|
|
75
|
+
return SunPosition(declination, equation)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def mid_day(time: TimeValue, utc_ms: float, lng: float) -> float:
|
|
79
|
+
"""Return local solar noon as ``mod(12 - EqT, 24)`` hours (specs.md §8.3)."""
|
|
80
|
+
eqt = sun_position(time, utc_ms, lng).equation
|
|
81
|
+
return mod(C.NOON_OFFSET - eqt, C.HOURS_PER_DAY)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def angle_time(
|
|
85
|
+
angle: Any,
|
|
86
|
+
time: TimeValue,
|
|
87
|
+
lat: float,
|
|
88
|
+
utc_ms: float,
|
|
89
|
+
lng: float,
|
|
90
|
+
direction: int = 1,
|
|
91
|
+
) -> float:
|
|
92
|
+
"""Return the hour the sun reaches ``angle`` below the horizon (specs.md §8.4).
|
|
93
|
+
|
|
94
|
+
``direction`` is ``-1`` for morning prayers and ``+1`` (default) for
|
|
95
|
+
afternoon. When ``cos(H)`` leaves ``[-1, 1]`` the ``acos`` helper returns
|
|
96
|
+
``nan`` (never clamped), and a non-numeric ``angle`` string coerces to
|
|
97
|
+
``nan`` via the trig helpers.
|
|
98
|
+
"""
|
|
99
|
+
decl = sun_position(time, utc_ms, lng).declination
|
|
100
|
+
numerator = -sin(angle) - sin(lat) * sin(decl)
|
|
101
|
+
diff = acos(numerator / (cos(lat) * cos(decl))) / C.DEG_PER_HOUR
|
|
102
|
+
return mid_day(time, utc_ms, lng) + diff * direction
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def asr_angle(asr_param: Any, time: TimeValue, lat: float, utc_ms: float, lng: float) -> float:
|
|
106
|
+
"""Return the Asr shadow angle ``-acot(t + tan|lat - decl|)`` (specs.md §8.6).
|
|
107
|
+
|
|
108
|
+
The shadow factor ``t`` is ``1`` for ``'Standard'``, ``2`` for ``'Hanafi'``,
|
|
109
|
+
otherwise ``parse_value(asr_param)`` (a custom factor). The ``or`` mirrors
|
|
110
|
+
the JS ``||`` short-circuit (specs.md §5).
|
|
111
|
+
"""
|
|
112
|
+
shadow_factor = _SHADOW_FACTORS.get(asr_param) or parse_value(asr_param)
|
|
113
|
+
decl = sun_position(time, utc_ms, lng).declination
|
|
114
|
+
return -acot(shadow_factor + tan(abs(lat - decl)))
|
praytime/constants.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Static data tables and numeric constants for :mod:`praytime`.
|
|
2
|
+
|
|
3
|
+
This module is a leaf: it imports nothing from the rest of the package. It holds
|
|
4
|
+
|
|
5
|
+
* :data:`METHODS` — the calculation-method table (specs.md §4);
|
|
6
|
+
* :data:`LABELS` — the prayer labels (specs.md §11);
|
|
7
|
+
* :data:`DEFAULTS` — the static settings template (specs.md §7);
|
|
8
|
+
* :func:`default_settings` — a fresh settings mapping with host timezone/location
|
|
9
|
+
resolved (specs.md §7.1);
|
|
10
|
+
* every numeric constant and seed hour used by the astronomy pipeline
|
|
11
|
+
(specs.md §11).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as _dt
|
|
17
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
18
|
+
|
|
19
|
+
Number = Union[int, float]
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Calculation methods (specs.md §4)
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
#: Calculation-method table, transcribed verbatim from the JS source.
|
|
26
|
+
#:
|
|
27
|
+
#: ``defaults`` is applied first by ``PrayTime.method`` and then overridden by
|
|
28
|
+
#: the named method, so methods such as ``Tehran``/``Jafari`` inherit
|
|
29
|
+
#: ``isha: 14`` and ``maghrib: '1 min'``.
|
|
30
|
+
METHODS: Dict[str, Dict[str, Any]] = {
|
|
31
|
+
"MWL": {"fajr": 18, "isha": 17},
|
|
32
|
+
"ISNA": {"fajr": 15, "isha": 15},
|
|
33
|
+
"Egypt": {"fajr": 19.5, "isha": 17.5},
|
|
34
|
+
"Makkah": {"fajr": 18.5, "isha": "90 min"},
|
|
35
|
+
"Karachi": {"fajr": 18, "isha": 18},
|
|
36
|
+
"Tehran": {"fajr": 17.7, "maghrib": 4.5, "midnight": "Jafari"},
|
|
37
|
+
"Jafari": {"fajr": 16, "maghrib": 4, "midnight": "Jafari"},
|
|
38
|
+
"France": {"fajr": 12, "isha": 12},
|
|
39
|
+
"Russia": {"fajr": 16, "isha": 15},
|
|
40
|
+
"Malaysia": {"fajr": 20, "isha": 18}, # JAKIM
|
|
41
|
+
"Singapore": {"fajr": 20, "isha": 18},
|
|
42
|
+
"defaults": {"isha": 14, "maghrib": "1 min", "midnight": "Standard"},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#: Human-readable prayer labels, in returned-dict order (specs.md §11).
|
|
46
|
+
LABELS: List[str] = [
|
|
47
|
+
"Fajr",
|
|
48
|
+
"Sunrise",
|
|
49
|
+
"Dhuhr",
|
|
50
|
+
"Asr",
|
|
51
|
+
"Sunset",
|
|
52
|
+
"Maghrib",
|
|
53
|
+
"Isha",
|
|
54
|
+
"Midnight",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Numeric constants (specs.md §11)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
#: Sunrise/Sunset horizon angle in degrees (refraction + solar radius).
|
|
62
|
+
HORIZON = 0.833
|
|
63
|
+
|
|
64
|
+
#: Days from the Unix epoch to J2000.0 (JD 2451545.0). Computed, not hardcoded,
|
|
65
|
+
#: per the research recommendation: ``2451545.0 - 2440587.5 == 10957.5``.
|
|
66
|
+
J2000_OFFSET_DAYS = 2451545.0 - 2440587.5
|
|
67
|
+
|
|
68
|
+
MS_PER_DAY = 86_400_000
|
|
69
|
+
MS_PER_HOUR = 3_600_000
|
|
70
|
+
MS_PER_MINUTE = 60_000
|
|
71
|
+
|
|
72
|
+
# Sun-position coefficients (USNO approximate solar coordinates).
|
|
73
|
+
MEAN_ANOMALY_BASE = 357.529
|
|
74
|
+
MEAN_ANOMALY_RATE = 0.98560028
|
|
75
|
+
MEAN_LONGITUDE_BASE = 280.459
|
|
76
|
+
MEAN_LONGITUDE_RATE = 0.98564736
|
|
77
|
+
EQ_CENTER_1 = 1.915
|
|
78
|
+
EQ_CENTER_2 = 0.020
|
|
79
|
+
OBLIQUITY_J2000 = 23.439
|
|
80
|
+
OBLIQUITY_RATE = 0.00000036
|
|
81
|
+
|
|
82
|
+
# Asr shadow factors.
|
|
83
|
+
SHADOW_STANDARD = 1
|
|
84
|
+
SHADOW_HANAFI = 2
|
|
85
|
+
|
|
86
|
+
# High-latitude portion factors.
|
|
87
|
+
HL_NIGHT_MIDDLE = 1 / 2
|
|
88
|
+
HL_ONE_SEVENTH = 1 / 7
|
|
89
|
+
#: Divisor for the AngleBased factor ``(1 / HL_ANGLE_DIVISOR) * parse_value(angle)``.
|
|
90
|
+
HL_ANGLE_DIVISOR = 60
|
|
91
|
+
|
|
92
|
+
#: Fixed local-solar-time seed (hours) used to estimate next-day Jafari Fajr.
|
|
93
|
+
JAFARI_SEED_HOUR = 29
|
|
94
|
+
|
|
95
|
+
NOON_OFFSET = 12
|
|
96
|
+
HOURS_PER_DAY = 24
|
|
97
|
+
DEG_PER_HOUR = 15
|
|
98
|
+
|
|
99
|
+
#: Placeholder returned for an invalid (NaN) timestamp.
|
|
100
|
+
INVALID_TIME = "-----"
|
|
101
|
+
|
|
102
|
+
#: Initial local-solar-time seed hours for ``compute_times`` (specs.md §8.9).
|
|
103
|
+
SEED_TIMES: Dict[str, Number] = {
|
|
104
|
+
"fajr": 5,
|
|
105
|
+
"sunrise": 6,
|
|
106
|
+
"dhuhr": 12,
|
|
107
|
+
"asr": 13,
|
|
108
|
+
"sunset": 18,
|
|
109
|
+
"maghrib": 18,
|
|
110
|
+
"isha": 18,
|
|
111
|
+
"midnight": 24,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Settings defaults (specs.md §7)
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
#: Static settings template. ``timezone`` and ``location`` are host-dependent
|
|
119
|
+
#: and filled in by :func:`default_settings` at runtime.
|
|
120
|
+
DEFAULTS: Dict[str, Any] = {
|
|
121
|
+
"dhuhr": "0 min",
|
|
122
|
+
"asr": "Standard",
|
|
123
|
+
"highLats": "NightMiddle",
|
|
124
|
+
"tune": {},
|
|
125
|
+
"format": "24h",
|
|
126
|
+
"rounding": "nearest",
|
|
127
|
+
"utcOffset": "auto",
|
|
128
|
+
"iterations": 1,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _host_timezone_and_longitude() -> Tuple[str, float]:
|
|
133
|
+
"""Return the host IANA zone (or ``"UTC"``) and its default longitude.
|
|
134
|
+
|
|
135
|
+
Mirrors ``Intl.DateTimeFormat().resolvedOptions().timeZone`` and
|
|
136
|
+
``-(new Date()).getTimezoneOffset() / 4`` (specs.md §7.1). Fixed-offset
|
|
137
|
+
tzinfos that lack a ``.key`` fall back to ``"UTC"``.
|
|
138
|
+
"""
|
|
139
|
+
now = _dt.datetime.now().astimezone()
|
|
140
|
+
tzinfo = now.tzinfo
|
|
141
|
+
default_tz = getattr(tzinfo, "key", "UTC")
|
|
142
|
+
offset = now.utcoffset()
|
|
143
|
+
offset_min = 0.0 if offset is None else offset.total_seconds() / 60
|
|
144
|
+
default_lng = -offset_min / 4
|
|
145
|
+
return default_tz, default_lng
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def default_settings() -> Dict[str, Any]:
|
|
149
|
+
"""Build a fresh settings mapping with host timezone/location resolved.
|
|
150
|
+
|
|
151
|
+
Returns a new ``dict`` each call (including a new ``tune`` mapping) so
|
|
152
|
+
callers can freely mutate it without affecting :data:`DEFAULTS` or other
|
|
153
|
+
instances.
|
|
154
|
+
"""
|
|
155
|
+
settings: Dict[str, Any] = {
|
|
156
|
+
"dhuhr": DEFAULTS["dhuhr"],
|
|
157
|
+
"asr": DEFAULTS["asr"],
|
|
158
|
+
"highLats": DEFAULTS["highLats"],
|
|
159
|
+
"tune": dict(DEFAULTS["tune"]),
|
|
160
|
+
"format": DEFAULTS["format"],
|
|
161
|
+
"rounding": DEFAULTS["rounding"],
|
|
162
|
+
"utcOffset": DEFAULTS["utcOffset"],
|
|
163
|
+
"iterations": DEFAULTS["iterations"],
|
|
164
|
+
}
|
|
165
|
+
timezone, longitude = _host_timezone_and_longitude()
|
|
166
|
+
settings["timezone"] = timezone
|
|
167
|
+
settings["location"] = [0, longitude]
|
|
168
|
+
return settings
|
praytime/formatting.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Output formatting, rounding, and interval parsing (specs.md §8.13, §9).
|
|
2
|
+
|
|
3
|
+
This module holds the pure helpers used to turn computed timestamps into
|
|
4
|
+
strings (and interval strings into numbers):
|
|
5
|
+
|
|
6
|
+
* :func:`parse_value` — the JS ``value`` leading-number parse (specs.md §9.4);
|
|
7
|
+
* :func:`is_min` — the JS ``isMin`` ``'min'`` substring test (specs.md §9.4);
|
|
8
|
+
* :func:`js_round` — half-up rounding matching JS ``Math.round`` (specs.md §8.13);
|
|
9
|
+
* :func:`round_time` — minute rounding on the millisecond scale (specs.md §8.13);
|
|
10
|
+
* :func:`time_to_string` — ``24h``/``12h``/``12H`` rendering (specs.md §9.1, §9.3);
|
|
11
|
+
* :func:`format_time` — ``x``/``X``/callable/``'-----'`` dispatch (specs.md §9.1).
|
|
12
|
+
|
|
13
|
+
Standard library only (``math``, ``re``, ``datetime``, ``zoneinfo``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import datetime as _dt
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
from zoneinfo import ZoneInfo
|
|
24
|
+
|
|
25
|
+
from .constants import INVALID_TIME, MS_PER_MINUTE
|
|
26
|
+
|
|
27
|
+
#: Split pattern matching JS ``/[^0-9.+-]/``: everything that is not a digit,
|
|
28
|
+
#: dot, plus, or minus.
|
|
29
|
+
_NUM_RE = re.compile(r"[^0-9.+-]")
|
|
30
|
+
|
|
31
|
+
#: Pattern stripping the ``" AM"``/``" PM"`` suffix for the ``'12H'`` format,
|
|
32
|
+
#: matching JS ``/ ?[AP]M/`` (specs.md §8.13/§9.1).
|
|
33
|
+
_AMPM_RE = re.compile(r" ?[AP]M")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_value(x: Any) -> float:
|
|
37
|
+
"""Parse the leading number of ``x`` like the JS ``value`` helper.
|
|
38
|
+
|
|
39
|
+
Numbers pass straight through as ``float(x)``. For any other value the
|
|
40
|
+
string form is split on the first non-numeric character and the head is
|
|
41
|
+
converted; an empty or unparseable head yields ``0.0`` (mirroring
|
|
42
|
+
``+'' === 0`` / the JS fallback). See specs.md §9.4.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(x, (int, float)):
|
|
45
|
+
return float(x)
|
|
46
|
+
head = _NUM_RE.split(str(x), maxsplit=1)[0]
|
|
47
|
+
try:
|
|
48
|
+
return float(head)
|
|
49
|
+
except ValueError:
|
|
50
|
+
return 0.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_min(x: Any) -> bool:
|
|
54
|
+
"""Return whether the string form of ``x`` contains ``'min'`` (specs.md §9.4)."""
|
|
55
|
+
return "min" in str(x)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def js_round(x: float) -> int:
|
|
59
|
+
"""Round half-up, matching JS ``Math.round`` (specs.md §8.13).
|
|
60
|
+
|
|
61
|
+
JS ``Math.round`` rounds ``.5`` toward ``+`` infinity; Python's built-in
|
|
62
|
+
:func:`round` uses banker's rounding and must not be used. For the positive
|
|
63
|
+
timestamps handled here ``floor(x + 0.5)`` reproduces JS exactly.
|
|
64
|
+
"""
|
|
65
|
+
return math.floor(x + 0.5)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def round_time(timestamp: float, rounding: str) -> float:
|
|
69
|
+
"""Round ``timestamp`` (ms) to the nearest minute per ``rounding`` (specs.md §8.13).
|
|
70
|
+
|
|
71
|
+
``'up'`` uses :func:`math.ceil`, ``'down'`` uses :func:`math.floor`,
|
|
72
|
+
``'nearest'`` uses :func:`js_round` (half-up). Any other value (including
|
|
73
|
+
``'none'``) leaves the timestamp unchanged, mirroring the JS lookup miss.
|
|
74
|
+
"""
|
|
75
|
+
fn = {"up": math.ceil, "down": math.floor, "nearest": js_round}.get(rounding)
|
|
76
|
+
if fn is None:
|
|
77
|
+
return timestamp
|
|
78
|
+
if isinstance(timestamp, float) and not math.isfinite(timestamp):
|
|
79
|
+
return timestamp
|
|
80
|
+
return fn(timestamp / MS_PER_MINUTE) * MS_PER_MINUTE
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _zone(name: str) -> _dt.tzinfo:
|
|
84
|
+
"""Return the IANA zone ``name``, falling back to UTC if data is missing.
|
|
85
|
+
|
|
86
|
+
``ZoneInfo`` raises when the tz database has no matching key (e.g. minimal
|
|
87
|
+
images without ``tzdata``); the port degrades gracefully to UTC rather than
|
|
88
|
+
failing (specs.md §7.1, §9.2).
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
return ZoneInfo(name)
|
|
92
|
+
except Exception:
|
|
93
|
+
return _dt.timezone.utc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def time_to_string(timestamp: float, fmt: str, settings: Mapping[str, Any]) -> str:
|
|
97
|
+
"""Render ``timestamp`` (ms) as a clock string in the configured zone.
|
|
98
|
+
|
|
99
|
+
The instant is shifted by a numeric ``settings['utcOffset']`` (minutes)
|
|
100
|
+
before rendering; ``'auto'`` leaves it untouched. JS **always** renders with
|
|
101
|
+
``settings['timezone']`` (specs.md §9.2, §9.3):
|
|
102
|
+
|
|
103
|
+
* ``'24h'`` → ``HH:MM`` (zero-padded);
|
|
104
|
+
* otherwise ``H:MM AM/PM`` (no leading zero), with the suffix stripped for
|
|
105
|
+
``'12H'`` and kept for ``'12h'``.
|
|
106
|
+
"""
|
|
107
|
+
utc_offset = settings["utcOffset"]
|
|
108
|
+
zone = _zone(settings["timezone"])
|
|
109
|
+
if utc_offset == "auto":
|
|
110
|
+
dt = _dt.datetime.fromtimestamp(timestamp / 1000, tz=zone)
|
|
111
|
+
else:
|
|
112
|
+
dt = _dt.datetime.fromtimestamp((timestamp + utc_offset * 60000) / 1000, tz=zone)
|
|
113
|
+
|
|
114
|
+
if fmt == "24h":
|
|
115
|
+
return f"{dt.hour:02d}:{dt.minute:02d}"
|
|
116
|
+
|
|
117
|
+
hour = dt.hour % 12 or 12
|
|
118
|
+
ampm = "AM" if dt.hour < 12 else "PM"
|
|
119
|
+
rendered = f"{hour}:{dt.minute:02d} {ampm}"
|
|
120
|
+
if fmt == "12H":
|
|
121
|
+
return _AMPM_RE.sub("", rendered)
|
|
122
|
+
return rendered
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_time(timestamp: float, settings: Mapping[str, Any]) -> Any:
|
|
126
|
+
"""Dispatch ``timestamp`` (ms) through ``settings['format']`` (specs.md §9.1).
|
|
127
|
+
|
|
128
|
+
A ``float('nan')`` timestamp renders as :data:`INVALID_TIME` (``'-----'``).
|
|
129
|
+
A callable format receives the (already rounded) millisecond number. A
|
|
130
|
+
``'x'``/``'X'`` format returns ``floor`` of milliseconds/seconds (the
|
|
131
|
+
seconds branch is case-sensitive on ``'X'``). Anything else is rendered via
|
|
132
|
+
:func:`time_to_string`.
|
|
133
|
+
"""
|
|
134
|
+
fmt = settings["format"]
|
|
135
|
+
if isinstance(timestamp, float) and math.isnan(timestamp):
|
|
136
|
+
return INVALID_TIME
|
|
137
|
+
if callable(fmt):
|
|
138
|
+
return fmt(timestamp)
|
|
139
|
+
if isinstance(fmt, str) and fmt.lower() == "x":
|
|
140
|
+
return math.floor(timestamp / (1000 if fmt == "X" else 1))
|
|
141
|
+
return time_to_string(timestamp, fmt, settings)
|
praytime/highlat.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""High-latitude adjustment rules (specs.md §6, §10.5).
|
|
2
|
+
|
|
3
|
+
Implements the JS ``adjustHighLats`` / ``adjustTime`` logic for the ``None`` /
|
|
4
|
+
``NightMiddle`` / ``OneSeventh`` / ``AngleBased`` rules:
|
|
5
|
+
|
|
6
|
+
* :func:`adjust_high_lats` — clamp Fajr/Isha/Maghrib for abnormal night lengths,
|
|
7
|
+
returning the ``adjusted`` flag;
|
|
8
|
+
* :func:`adjust_time` — clamp one time against ``base ± portion × night``.
|
|
9
|
+
|
|
10
|
+
The stateful quirk is preserved: the ``'None'`` rule returns ``prior_adjusted``
|
|
11
|
+
untouched and mutates nothing (specs.md §10.5). ``NaN`` is **never** clamped
|
|
12
|
+
away silently — it is replaced by the clamp value and flips ``adjusted``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import Any, Callable, Dict, Mapping, MutableMapping, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import constants as C
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def adjust_high_lats(
|
|
24
|
+
times: MutableMapping[str, float],
|
|
25
|
+
settings: Mapping[str, Any],
|
|
26
|
+
value_fn: Callable[[Any], float],
|
|
27
|
+
prior_adjusted: Optional[bool] = None,
|
|
28
|
+
) -> Optional[bool]:
|
|
29
|
+
"""Adjust Fajr/Isha/Maghrib for high latitudes (specs.md §6).
|
|
30
|
+
|
|
31
|
+
Returns the ``adjusted`` flag: ``prior_adjusted`` unchanged when
|
|
32
|
+
``settings['highLats'] == 'None'`` (and touches nothing — specs.md §10.5),
|
|
33
|
+
otherwise ``True`` if any of the three prayers was clamped. ``times`` is
|
|
34
|
+
mutated in place.
|
|
35
|
+
"""
|
|
36
|
+
if settings["highLats"] == "None":
|
|
37
|
+
return prior_adjusted
|
|
38
|
+
|
|
39
|
+
adjusted = False
|
|
40
|
+
night = 24 + times["sunrise"] - times["sunset"]
|
|
41
|
+
|
|
42
|
+
times["fajr"], adjusted = adjust_time(
|
|
43
|
+
times["fajr"], times["sunrise"], settings["fajr"], night, -1,
|
|
44
|
+
settings, value_fn, adjusted,
|
|
45
|
+
)
|
|
46
|
+
times["isha"], adjusted = adjust_time(
|
|
47
|
+
times["isha"], times["sunset"], settings["isha"], night, 1,
|
|
48
|
+
settings, value_fn, adjusted,
|
|
49
|
+
)
|
|
50
|
+
times["maghrib"], adjusted = adjust_time(
|
|
51
|
+
times["maghrib"], times["sunset"], settings["maghrib"], night, 1,
|
|
52
|
+
settings, value_fn, adjusted,
|
|
53
|
+
)
|
|
54
|
+
return adjusted
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def adjust_time(
|
|
58
|
+
time: float,
|
|
59
|
+
base: float,
|
|
60
|
+
angle: Any,
|
|
61
|
+
night: float,
|
|
62
|
+
direction: int,
|
|
63
|
+
settings: Mapping[str, Any],
|
|
64
|
+
value_fn: Callable[[Any], float],
|
|
65
|
+
adjusted: bool,
|
|
66
|
+
) -> Tuple[float, bool]:
|
|
67
|
+
"""Clamp a single ``time`` against ``base ± portion × night`` (specs.md §6.1).
|
|
68
|
+
|
|
69
|
+
``portion`` depends on the rule in ``settings['highLats']``:
|
|
70
|
+
``NightMiddle`` → ``1/2``, ``OneSeventh`` → ``1/7``, ``AngleBased`` →
|
|
71
|
+
``(1/60) × value_fn(angle)`` (the **raw** setting value, e.g. ``'90 min'``
|
|
72
|
+
→ ``1.5``). A ``NaN`` time or one whose ``(time − base) × direction``
|
|
73
|
+
exceeds ``portion`` is clamped to ``base + portion × direction`` and sets
|
|
74
|
+
``adjusted`` to ``True`` (strict ``>`` — an exact boundary is not clamped).
|
|
75
|
+
"""
|
|
76
|
+
factors: Dict[str, float] = {
|
|
77
|
+
"NightMiddle": C.HL_NIGHT_MIDDLE,
|
|
78
|
+
"OneSeventh": C.HL_ONE_SEVENTH,
|
|
79
|
+
"AngleBased": (1 / C.HL_ANGLE_DIVISOR) * value_fn(angle),
|
|
80
|
+
}
|
|
81
|
+
portion = factors[settings["highLats"]] * night
|
|
82
|
+
time_diff = (time - base) * direction
|
|
83
|
+
if math.isnan(time) or time_diff > portion:
|
|
84
|
+
time = base + portion * direction
|
|
85
|
+
adjusted = True
|
|
86
|
+
return time, adjusted
|