celestial-calendar 0.6.0__py3-none-win_amd64.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,631 @@
1
+ # CelestialCalendar:
2
+ # A C++23-style library that performs astronomical calculations and date conversions among various calendars,
3
+ # including Gregorian, Lunar, and Chinese Ganzhi calendars.
4
+ #
5
+ # Copyright (C) 2026 Ningqi Wang (0xf3cd)
6
+ # Email: nq.maigre@gmail.com
7
+ # Repo : https://github.com/0xf3cd/celestial-calendar
8
+ #
9
+ # This project is free software: you can redistribute it and/or modify
10
+ # it under the terms of the GNU General Public License as published by
11
+ # the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # This project is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with this project. If not, see <https://www.gnu.org/licenses/>.
21
+
22
+ """Astronomical calculations and Chinese calendar conversion."""
23
+
24
+ import ctypes as _ctypes
25
+ import math as _math
26
+ from dataclasses import dataclass as _dataclass
27
+ from enum import IntEnum as _IntEnum
28
+ from enum import StrEnum as _StrEnum
29
+ from typing import TypeVar as _TypeVar
30
+
31
+ from . import _binding
32
+ from ._version import VERSION as __version__
33
+
34
+
35
+ class LogVerbosity(_StrEnum):
36
+ """Native logging verbosity."""
37
+
38
+ NONE = "none"
39
+ INFO = "info"
40
+ DEBUG = "debug"
41
+
42
+
43
+ class MoonPhase(_StrEnum):
44
+ """One of the four principal Moon phases."""
45
+
46
+ NEW = "new"
47
+ FIRST_QUARTER = "first_quarter"
48
+ FULL = "full"
49
+ LAST_QUARTER = "last_quarter"
50
+
51
+
52
+ class DeltaTModel(_StrEnum):
53
+ """A selectable ΔT model."""
54
+
55
+ DEFAULT = "default"
56
+ ALGO1 = "algo1"
57
+ ALGO2 = "algo2"
58
+ ALGO3 = "algo3"
59
+ ALGO4 = "algo4"
60
+ ALGO5 = "algo5"
61
+
62
+
63
+ class LunarAlgorithm(_StrEnum):
64
+ """A Chinese Lunar calendar conversion algorithm."""
65
+
66
+ ALGO1 = "algo1"
67
+ ALGO2 = "algo2"
68
+ ALGO3 = "algo3"
69
+
70
+
71
+ class Jieqi(_IntEnum):
72
+ """The 24 Chinese Jieqi (节气), indexed from Lichun (立春)."""
73
+
74
+ LICHUN = 0
75
+ YUSHUI = 1
76
+ JINGZHE = 2
77
+ CHUNFEN = 3
78
+ QINGMING = 4
79
+ GUYU = 5
80
+ LIXIA = 6
81
+ XIAOMAN = 7
82
+ MANGZHONG = 8
83
+ XIAZHI = 9
84
+ XIAOSHU = 10
85
+ DASHU = 11
86
+ LIQIU = 12
87
+ CHUSHU = 13
88
+ BAILU = 14
89
+ QIUFEN = 15
90
+ HANLU = 16
91
+ SHUANGJIANG = 17
92
+ LIDONG = 18
93
+ XIAOXUE = 19
94
+ DAXUE = 20
95
+ DONGZHI = 21
96
+ XIAOHAN = 22
97
+ DAHAN = 23
98
+
99
+
100
+ @_dataclass(frozen=True)
101
+ class CivilDateTime:
102
+ """A civil date and fraction of its day; the function names identify its time scale."""
103
+
104
+ year: int
105
+ month: int
106
+ day: int
107
+ fraction: float
108
+
109
+
110
+ @_dataclass(frozen=True)
111
+ class GregorianDate:
112
+ """A proleptic Gregorian calendar date."""
113
+
114
+ year: int
115
+ month: int
116
+ day: int
117
+
118
+
119
+ @_dataclass(frozen=True)
120
+ class LunarDate:
121
+ """A Chinese Lunar date using traditional month numbering."""
122
+
123
+ year: int
124
+ month: int
125
+ day: int
126
+ is_leap: bool
127
+
128
+
129
+ @_dataclass(frozen=True)
130
+ class SunCoordinate:
131
+ """Apparent geocentric ecliptic coordinates of the Sun."""
132
+
133
+ longitude_deg: float
134
+ latitude_deg: float
135
+ radius_au: float
136
+
137
+
138
+ @_dataclass(frozen=True)
139
+ class MoonCoordinate:
140
+ """Apparent geocentric ecliptic coordinates of the Moon."""
141
+
142
+ longitude_deg: float
143
+ latitude_deg: float
144
+ distance_km: float
145
+
146
+
147
+ @_dataclass(frozen=True)
148
+ class MoonIllumination:
149
+ """The illuminated fraction and Moon-Sun elongation."""
150
+
151
+ fraction: float
152
+ elongation_deg: float
153
+
154
+
155
+ @_dataclass(frozen=True)
156
+ class JieqiMoment:
157
+ """The UT1 civil moment of a Jieqi."""
158
+
159
+ jieqi: Jieqi
160
+ moment_ut1: CivilDateTime
161
+
162
+
163
+ @_dataclass(frozen=True)
164
+ class LunarYearRange:
165
+ """Inclusive Lunar year range supported by an algorithm."""
166
+
167
+ start: int
168
+ end: int
169
+
170
+
171
+ @_dataclass(frozen=True)
172
+ class LunarYearInfo:
173
+ """The Gregorian first day and month structure of a Lunar year."""
174
+
175
+ first_day: GregorianDate
176
+ leap_month: int | None
177
+ month_lengths: tuple[int, ...]
178
+
179
+
180
+ class CelestialError(RuntimeError):
181
+ """A failure reported by the native CelestialCalendar boundary.
182
+
183
+ Attributes:
184
+ operation: The public function that failed.
185
+ recorded: Whether the message came from the native error channel.
186
+ """
187
+
188
+ def __init__(self, operation: str, message: str, *, recorded: bool) -> None:
189
+ super().__init__(message)
190
+ self.operation = operation
191
+ self.recorded = recorded
192
+
193
+
194
+ _EnumT = _TypeVar("_EnumT", bound=_IntEnum | _StrEnum)
195
+ _MAX_CIVIL_YEAR = 32_767
196
+ _MAX_CALENDAR_YEAR = 32_766
197
+ _MIN_JIEQI_YEAR = 401
198
+ _MAX_NEW_MOON_COUNT = 4_096
199
+ _JIEQI_NAME_BYTES = 16
200
+ _LOG_VERBOSITY = {LogVerbosity.NONE: 0, LogVerbosity.INFO: 1, LogVerbosity.DEBUG: 2}
201
+ _MOON_PHASE = {MoonPhase.NEW: 0, MoonPhase.FIRST_QUARTER: 1, MoonPhase.FULL: 2, MoonPhase.LAST_QUARTER: 3}
202
+ _LUNAR_ALGORITHM = {LunarAlgorithm.ALGO1: 1, LunarAlgorithm.ALGO2: 2, LunarAlgorithm.ALGO3: 3}
203
+ _LUNAR_YEAR_RANGE = {
204
+ LunarAlgorithm.ALGO1: (1901, 2099),
205
+ LunarAlgorithm.ALGO2: (410, 2500),
206
+ LunarAlgorithm.ALGO3: (1600, 2199),
207
+ }
208
+ _DELTA_T_EXPORT = {
209
+ DeltaTModel.DEFAULT: "delta_t",
210
+ DeltaTModel.ALGO1: "delta_t_algo1",
211
+ DeltaTModel.ALGO2: "delta_t_algo2",
212
+ DeltaTModel.ALGO3: "delta_t_algo3",
213
+ DeltaTModel.ALGO4: "delta_t_algo4",
214
+ DeltaTModel.ALGO5: "delta_t_algo5",
215
+ }
216
+
217
+
218
+ def _enum(value: object, enum_type: type[_EnumT], name: str) -> _EnumT:
219
+ if not isinstance(value, enum_type):
220
+ raise TypeError(f"{name} must be a {enum_type.__name__} member")
221
+ return value
222
+
223
+
224
+ def _integer(value: object, name: str, minimum: int, maximum: int) -> int:
225
+ if not isinstance(value, int) or isinstance(value, (bool, _IntEnum)):
226
+ raise TypeError(f"{name} must be an integer")
227
+ if value < minimum or value > maximum:
228
+ raise ValueError(f"{name} must be in [{minimum}, {maximum}]")
229
+ return value
230
+
231
+
232
+ def _finite(value: object, name: str) -> float:
233
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
234
+ raise TypeError(f"{name} must be a real number")
235
+ result = float(value)
236
+ if not _math.isfinite(result):
237
+ raise ValueError(f"{name} must be finite")
238
+ return result
239
+
240
+
241
+ def _ranged_float(value: object, name: str, minimum: float, maximum: float, *, include_maximum: bool = True) -> float:
242
+ result = _finite(value, name)
243
+ outside = result < minimum or (result > maximum if include_maximum else result >= maximum)
244
+ if outside:
245
+ closing = "]" if include_maximum else ")"
246
+ raise ValueError(f"{name} must be in [{minimum}, {maximum}{closing}")
247
+ return result
248
+
249
+
250
+ def _gregorian_month_length(year: int, month: int) -> int:
251
+ if month == 2:
252
+ return 29 if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else 28
253
+ return 30 if month in (4, 6, 9, 11) else 31
254
+
255
+
256
+ def _civil_date(value: object, name: str, *, maximum_year: int = _MAX_CIVIL_YEAR) -> tuple[int, int, int]:
257
+ if not isinstance(value, GregorianDate):
258
+ raise TypeError(f"{name} must be a GregorianDate")
259
+ year = _integer(value.year, f"{name}.year", 1, maximum_year)
260
+ month = _integer(value.month, f"{name}.month", 1, 12)
261
+ day = _integer(value.day, f"{name}.day", 1, _gregorian_month_length(year, month))
262
+ return year, month, day
263
+
264
+
265
+ def _civil_datetime(value: object, name: str) -> tuple[int, int, int, float]:
266
+ if not isinstance(value, CivilDateTime):
267
+ raise TypeError(f"{name} must be a CivilDateTime")
268
+ year = _integer(value.year, f"{name}.year", 1, _MAX_CIVIL_YEAR)
269
+ month = _integer(value.month, f"{name}.month", 1, 12)
270
+ day = _integer(value.day, f"{name}.day", 1, _gregorian_month_length(year, month))
271
+ fraction = _ranged_float(value.fraction, f"{name}.fraction", 0.0, 1.0, include_maximum=False)
272
+ return year, month, day, fraction
273
+
274
+
275
+ def _lunar_date(value: object, algorithm: LunarAlgorithm, name: str) -> tuple[int, int, bool, int]:
276
+ if not isinstance(value, LunarDate):
277
+ raise TypeError(f"{name} must be a LunarDate")
278
+ start, end = _LUNAR_YEAR_RANGE[algorithm]
279
+ year = _integer(value.year, f"{name}.year", start, end)
280
+ month = _integer(value.month, f"{name}.month", 1, 12)
281
+ day = _integer(value.day, f"{name}.day", 1, 30)
282
+ if not isinstance(value.is_leap, bool):
283
+ raise TypeError(f"{name}.is_leap must be a bool")
284
+ return year, month, value.is_leap, day
285
+
286
+
287
+ def _failure(operation: str, *, recording: bool) -> CelestialError:
288
+ detail = _binding.last_error_text() if recording else ""
289
+ message = detail or f"{operation} failed"
290
+ return CelestialError(operation, message, recorded=bool(detail))
291
+
292
+
293
+ def _valid(result: object, operation: str, *, recording: bool = True) -> object:
294
+ if not result.valid:
295
+ raise _failure(operation, recording=recording)
296
+ return result
297
+
298
+
299
+ def _civil_result(result: object) -> CivilDateTime:
300
+ return CivilDateTime(result.year, result.month, result.day, result.fraction)
301
+
302
+
303
+ def set_log_verbosity(level: LogVerbosity) -> None:
304
+ """Set process-wide native log verbosity."""
305
+ checked = _enum(level, LogVerbosity, "level")
306
+ if not _binding.call("set_log_verbosity", _LOG_VERBOSITY[checked]):
307
+ raise _failure("set_log_verbosity", recording=True)
308
+
309
+
310
+ def ut1_to_jd(ut1: CivilDateTime) -> float:
311
+ """Convert a UT1 civil moment to Julian Day."""
312
+ value = _civil_datetime(ut1, "ut1")
313
+ result = _valid(_binding.call("ut1_to_jd", *value), "ut1_to_jd", recording=True)
314
+ return result.value
315
+
316
+
317
+ def ut1_to_jde(ut1: CivilDateTime) -> float:
318
+ """Convert a UT1 civil moment to TT-based Julian Ephemeris Day."""
319
+ value = _civil_datetime(ut1, "ut1")
320
+ result = _valid(_binding.call("ut1_to_jde", *value), "ut1_to_jde", recording=True)
321
+ return result.value
322
+
323
+
324
+ def jde_to_ut1(jde: float) -> CivilDateTime:
325
+ """Convert TT-based Julian Ephemeris Day to a UT1 civil moment."""
326
+ result = _valid(_binding.call("jde_to_ut1", _finite(jde, "jde")), "jde_to_ut1", recording=True)
327
+ return _civil_result(result)
328
+
329
+
330
+ def sun_apparent_geocentric_coordinate(jde: float) -> SunCoordinate:
331
+ """Return the Sun's apparent geocentric ecliptic coordinate at JDE."""
332
+ result = _valid(
333
+ _binding.call("sun_apparent_geocentric_coord", _finite(jde, "jde")),
334
+ "sun_apparent_geocentric_coordinate",
335
+ )
336
+ return SunCoordinate(result.lon, result.lat, result.r)
337
+
338
+
339
+ def moon_apparent_geocentric_coordinate(jde: float) -> MoonCoordinate:
340
+ """Return the Moon's apparent geocentric ecliptic coordinate at JDE."""
341
+ result = _valid(
342
+ _binding.call("moon_apparent_geocentric_coord", _finite(jde, "jde")),
343
+ "moon_apparent_geocentric_coordinate",
344
+ )
345
+ return MoonCoordinate(result.lon, result.lat, result.r)
346
+
347
+
348
+ def moon_illumination(jde: float) -> MoonIllumination:
349
+ """Return the Moon's illuminated fraction and elongation at JDE."""
350
+ result = _valid(
351
+ _binding.call("moon_illumination", _finite(jde, "jde")),
352
+ "moon_illumination",
353
+ recording=True,
354
+ )
355
+ return MoonIllumination(result.illumination, result.elongation_deg)
356
+
357
+
358
+ def moon_bright_limb_position_angle(jde: float) -> float:
359
+ """Return the Moon's bright-limb position angle in degrees."""
360
+ result = _valid(
361
+ _binding.call("moon_position_angle", _finite(jde, "jde")),
362
+ "moon_bright_limb_position_angle",
363
+ recording=True,
364
+ )
365
+ return result.angle_deg
366
+
367
+
368
+ def moon_phase_moments(year: int, phase: MoonPhase) -> tuple[float, ...]:
369
+ """Return the JDE moments of a principal Moon phase in a Gregorian year.
370
+
371
+ Raises:
372
+ ValueError: If year is outside [1, 32766].
373
+ CelestialError: If the native calculation cannot produce the phase moments.
374
+ """
375
+ checked_year = _integer(year, "year", 1, _MAX_CALENDAR_YEAR)
376
+ checked_phase = _enum(phase, MoonPhase, "phase")
377
+ total = _ctypes.c_uint32()
378
+ written = _binding.call(
379
+ "moon_phase_moments",
380
+ checked_year,
381
+ _MOON_PHASE[checked_phase],
382
+ _ctypes.byref(total),
383
+ None,
384
+ 0,
385
+ )
386
+ if written != 0 or total.value == 0:
387
+ raise _failure("moon_phase_moments", recording=True)
388
+ expected = total.value
389
+ slots = (_ctypes.c_double * expected)()
390
+ written = _binding.call(
391
+ "moon_phase_moments",
392
+ checked_year,
393
+ _MOON_PHASE[checked_phase],
394
+ _ctypes.byref(total),
395
+ slots,
396
+ expected,
397
+ )
398
+ if written != expected:
399
+ raise _failure("moon_phase_moments", recording=True)
400
+ return tuple(slots[:written])
401
+
402
+
403
+ def solar_longitude_roots(year: int, longitude_deg: float) -> tuple[float, ...]:
404
+ """Return JDEs when the Sun reaches an apparent geocentric longitude.
405
+
406
+ Raises:
407
+ ValueError: If year is outside [1, 32766].
408
+ CelestialError: If the native calculation cannot produce the roots.
409
+ """
410
+ checked_year = _integer(year, "year", 1, _MAX_CALENDAR_YEAR)
411
+ longitude = _ranged_float(longitude_deg, "longitude_deg", 0.0, 360.0, include_maximum=False)
412
+ discriminant = _valid(
413
+ _binding.call("solar_lon_root_discriminant", checked_year, longitude),
414
+ "solar_longitude_roots",
415
+ )
416
+ if discriminant.count == 0:
417
+ return ()
418
+ slots = (_ctypes.c_double * discriminant.count)()
419
+ written = _binding.call("solar_lon_roots", checked_year, longitude, slots, discriminant.count)
420
+ if written != discriminant.count:
421
+ raise _failure("solar_longitude_roots", recording=True)
422
+ return tuple(slots[:written])
423
+
424
+
425
+ def new_moons_after(jde: float, count: int) -> tuple[float, ...]:
426
+ """Return the requested number of new-moon JDEs after a starting JDE.
427
+
428
+ Raises:
429
+ ValueError: If count is outside [0, 4096].
430
+ CelestialError: If the native calculation cannot produce the requested moments.
431
+ """
432
+ start = _finite(jde, "jde")
433
+ checked_count = _integer(count, "count", 0, _MAX_NEW_MOON_COUNT)
434
+ if checked_count == 0:
435
+ return ()
436
+ slots = (_ctypes.c_double * checked_count)()
437
+ written = _binding.call("new_moons_after_jde", start, slots, checked_count)
438
+ if written != checked_count:
439
+ raise _failure("new_moons_after", recording=True)
440
+ return tuple(slots[:written])
441
+
442
+
443
+ def new_moons_in_year(year: int) -> tuple[float, ...]:
444
+ """Return all new-moon JDEs in a Gregorian year.
445
+
446
+ Raises:
447
+ ValueError: If year is outside [1, 32766].
448
+ CelestialError: If the native calculation cannot produce the moments.
449
+ """
450
+ checked_year = _integer(year, "year", 1, _MAX_CALENDAR_YEAR)
451
+ total = _ctypes.c_uint32()
452
+ written = _binding.call("new_moons_in_year", checked_year, _ctypes.byref(total), None, 0)
453
+ if written != 0 or total.value == 0:
454
+ raise _failure("new_moons_in_year", recording=True)
455
+ expected = total.value
456
+ slots = (_ctypes.c_double * expected)()
457
+ written = _binding.call("new_moons_in_year", checked_year, _ctypes.byref(total), slots, expected)
458
+ if written != expected:
459
+ raise _failure("new_moons_in_year", recording=True)
460
+ return tuple(slots[:written])
461
+
462
+
463
+ def equation_of_time(jde: float) -> float:
464
+ """Return apparent minus mean solar time in degrees of hour angle."""
465
+ result = _valid(_binding.call("equation_of_time", _finite(jde, "jde")), "equation_of_time")
466
+ return result.value
467
+
468
+
469
+ def apparent_solar_time(utc: CivilDateTime, longitude_deg: float) -> CivilDateTime:
470
+ """Convert civil UTC to local apparent solar time at an east-positive longitude in degrees."""
471
+ value = _civil_datetime(utc, "utc")
472
+ longitude = _ranged_float(longitude_deg, "longitude_deg", -180.0, 180.0)
473
+ result = _valid(_binding.call("apparent_solar_time", *value, longitude), "apparent_solar_time")
474
+ return _civil_result(result)
475
+
476
+
477
+ def local_apparent_sidereal_time(jd_ut1: float, longitude_deg: float) -> float:
478
+ """Return local apparent sidereal time in degrees at an east-positive longitude.
479
+
480
+ Raises:
481
+ CelestialError: If the native calculation cannot produce sidereal time.
482
+ """
483
+ jd = _finite(jd_ut1, "jd_ut1")
484
+ longitude = _ranged_float(longitude_deg, "longitude_deg", -180.0, 180.0)
485
+ result = _valid(
486
+ _binding.call("local_apparent_sidereal_time", jd, longitude),
487
+ "local_apparent_sidereal_time",
488
+ recording=True,
489
+ )
490
+ return result.value
491
+
492
+
493
+ def jieqi_moment(year: int, jieqi: Jieqi) -> JieqiMoment:
494
+ """Return the UT1 civil moment of a Jieqi in a Gregorian year.
495
+
496
+ Raises:
497
+ ValueError: If year is outside [401, 32766].
498
+ CelestialError: If the native calculation cannot produce the moment.
499
+ """
500
+ checked_year = _integer(year, "year", _MIN_JIEQI_YEAR, _MAX_CALENDAR_YEAR)
501
+ checked_jieqi = _enum(jieqi, Jieqi, "jieqi")
502
+ result = _valid(_binding.call("query_jieqi_moment", checked_year, checked_jieqi.value), "jieqi_moment")
503
+ return JieqiMoment(Jieqi(result.jq_idx), CivilDateTime(result.y, result.m, result.d, result.frac))
504
+
505
+
506
+ def jieqi_name(jieqi: Jieqi) -> str:
507
+ """Return the Chinese name of a Jieqi."""
508
+ checked_jieqi = _enum(jieqi, Jieqi, "jieqi")
509
+ buffer = _ctypes.create_string_buffer(_JIEQI_NAME_BYTES)
510
+ if not _binding.call("get_jieqi_name", checked_jieqi.value, buffer, len(buffer)):
511
+ raise _failure("jieqi_name", recording=True)
512
+ return buffer.value.decode("utf-8")
513
+
514
+
515
+ def supported_lunar_year_range(algorithm: LunarAlgorithm) -> LunarYearRange:
516
+ """Return the inclusive Lunar year range supported by an algorithm."""
517
+ checked = _enum(algorithm, LunarAlgorithm, "algorithm")
518
+ result = _valid(
519
+ _binding.call("get_supported_lunar_year_range", _LUNAR_ALGORITHM[checked]),
520
+ "supported_lunar_year_range",
521
+ )
522
+ return LunarYearRange(result.start, result.end)
523
+
524
+
525
+ def lunar_year_info(algorithm: LunarAlgorithm, year: int) -> LunarYearInfo:
526
+ """Return the first day and month lengths of a Lunar year.
527
+
528
+ Raises:
529
+ ValueError: If year is outside the selected algorithm's supported range.
530
+ CelestialError: If the native calculation cannot produce the year information.
531
+ """
532
+ checked = _enum(algorithm, LunarAlgorithm, "algorithm")
533
+ start, end = _LUNAR_YEAR_RANGE[checked]
534
+ checked_year = _integer(year, "year", start, end)
535
+ result = _valid(_binding.call("get_lunar_year_info", _LUNAR_ALGORITHM[checked], checked_year), "lunar_year_info")
536
+ month_count = 12 if result.leap_month == 0 else 13
537
+ month_lengths = tuple(30 if result.month_len & (1 << index) else 29 for index in range(month_count))
538
+ leap_month = None if result.leap_month == 0 else result.leap_month
539
+ return LunarYearInfo(GregorianDate(result.year, result.month, result.day), leap_month, month_lengths)
540
+
541
+
542
+ def gregorian_to_lunar(algorithm: LunarAlgorithm, date: GregorianDate) -> LunarDate:
543
+ """Convert a Gregorian date to a Chinese Lunar date.
544
+
545
+ Raises:
546
+ CelestialError: If date cannot be represented by the selected algorithm.
547
+ """
548
+ checked = _enum(algorithm, LunarAlgorithm, "algorithm")
549
+ value = _civil_date(date, "date")
550
+ result = _valid(_binding.call("gregorian_to_lunar", _LUNAR_ALGORITHM[checked], *value), "gregorian_to_lunar")
551
+ return LunarDate(result.year, result.month, result.day, result.is_leap)
552
+
553
+
554
+ def lunar_to_gregorian(algorithm: LunarAlgorithm, date: LunarDate) -> GregorianDate:
555
+ """Convert a Chinese Lunar date to a Gregorian date.
556
+
557
+ Raises:
558
+ ValueError: If date.year is outside the selected algorithm's supported range.
559
+ CelestialError: If date does not exist in the selected algorithm.
560
+ """
561
+ checked = _enum(algorithm, LunarAlgorithm, "algorithm")
562
+ year, month, is_leap, day = _lunar_date(date, checked, "date")
563
+ result = _valid(
564
+ _binding.call("lunar_to_gregorian", _LUNAR_ALGORITHM[checked], year, month, is_leap, day),
565
+ "lunar_to_gregorian",
566
+ )
567
+ return GregorianDate(result.year, result.month, result.day)
568
+
569
+
570
+ def delta_t(year: float, model: DeltaTModel = DeltaTModel.DEFAULT) -> float:
571
+ """Return ΔT in seconds for a decimal Gregorian year.
572
+
573
+ ALGO1 requires year >= -4000, ALGO3 requires year < 3000, and ALGO4 requires year < 2035. The other models have no
574
+ model-specific year bound.
575
+
576
+ Raises:
577
+ ValueError: If year is outside the selected model's bounds above.
578
+ CelestialError: If the native calculation fails.
579
+ """
580
+ decimal_year = _finite(year, "year")
581
+ checked_model = _enum(model, DeltaTModel, "model")
582
+ if checked_model is DeltaTModel.ALGO1 and decimal_year < -4000.0:
583
+ raise ValueError("year must be at least -4000 for DeltaTModel.ALGO1")
584
+ if checked_model is DeltaTModel.ALGO3 and decimal_year >= 3000.0:
585
+ raise ValueError("year must be less than 3000 for DeltaTModel.ALGO3")
586
+ if checked_model is DeltaTModel.ALGO4 and decimal_year >= 2035.0:
587
+ raise ValueError("year must be less than 2035 for DeltaTModel.ALGO4")
588
+ result = _valid(_binding.call(_DELTA_T_EXPORT[checked_model], decimal_year), "delta_t")
589
+ return result.value
590
+
591
+
592
+ __all__ = [
593
+ "CelestialError",
594
+ "CivilDateTime",
595
+ "DeltaTModel",
596
+ "GregorianDate",
597
+ "Jieqi",
598
+ "JieqiMoment",
599
+ "LogVerbosity",
600
+ "LunarAlgorithm",
601
+ "LunarDate",
602
+ "LunarYearInfo",
603
+ "LunarYearRange",
604
+ "MoonCoordinate",
605
+ "MoonIllumination",
606
+ "MoonPhase",
607
+ "SunCoordinate",
608
+ "__version__",
609
+ "apparent_solar_time",
610
+ "delta_t",
611
+ "equation_of_time",
612
+ "gregorian_to_lunar",
613
+ "jde_to_ut1",
614
+ "jieqi_moment",
615
+ "jieqi_name",
616
+ "local_apparent_sidereal_time",
617
+ "lunar_to_gregorian",
618
+ "lunar_year_info",
619
+ "moon_apparent_geocentric_coordinate",
620
+ "moon_bright_limb_position_angle",
621
+ "moon_illumination",
622
+ "moon_phase_moments",
623
+ "new_moons_after",
624
+ "new_moons_in_year",
625
+ "set_log_verbosity",
626
+ "solar_longitude_roots",
627
+ "sun_apparent_geocentric_coordinate",
628
+ "supported_lunar_year_range",
629
+ "ut1_to_jd",
630
+ "ut1_to_jde",
631
+ ]