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 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