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

Potentially problematic release.


This version of solarmoonpy might be problematic. Click here for more details.

@@ -0,0 +1,23 @@
1
+ """SOLARMOON CALCULATIONS.
2
+
3
+ Python Package to provide sun and moon calculations to interact with Meteocat Home Assistant Integration
4
+ SPDX-License-Identifier: Apache-2.0
5
+
6
+ For more details about this package, please refer to the documentation at
7
+ https://github.com/figorr/solarmoonpy
8
+ """
9
+
10
+ # solarmoonpy/__init__.py
11
+ from .version import __version__
12
+ from .moon import moon_phase, moon_rise_set, illuminated_percentage, moon_distance, moon_angular_diameter
13
+ from .location import Location, LocationInfo
14
+
15
+ __all__ = [
16
+ "moon_phase",
17
+ "moon_rise_set",
18
+ "illuminated_percentage",
19
+ "moon_distance",
20
+ "moon_angular_diameter",
21
+ "Location",
22
+ "LocationInfo"
23
+ ]
@@ -0,0 +1,312 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date, datetime, timezone
3
+ from zoneinfo import ZoneInfo
4
+ from typing import Optional, Union
5
+
6
+ from .sun import sunrise_sunset, noon # Importar funciones de sun.py
7
+
8
+ @dataclass
9
+ class LocationInfo:
10
+ """Clase para almacenar información básica de una ubicación."""
11
+ name: str
12
+ region: str
13
+ timezone: str
14
+ latitude: float
15
+ longitude: float
16
+ elevation: float = 0.0
17
+
18
+ class Location:
19
+ """Proporciona acceso a información y cálculos para una ubicación específica."""
20
+
21
+ def __init__(self, info: Optional[LocationInfo] = None):
22
+ """
23
+ Inicializa la ubicación con un objeto LocationInfo.
24
+
25
+ Args:
26
+ info: Objeto LocationInfo con los datos de la ubicación. Si es None,
27
+ se usa una ubicación por defecto (Greenwich).
28
+ """
29
+ if not info:
30
+ self._location_info = LocationInfo(
31
+ name="Greenwich",
32
+ region="England",
33
+ timezone="Europe/London",
34
+ latitude=51.4733,
35
+ longitude=-0.0008333,
36
+ elevation=0.0
37
+ )
38
+ else:
39
+ self._location_info = info
40
+
41
+ def __repr__(self) -> str:
42
+ """Representación en string de la ubicación."""
43
+ if self.region:
44
+ _repr = f"{self.name}/{self.region}"
45
+ else:
46
+ _repr = self.name
47
+ return (
48
+ f"{_repr}, tz={self.timezone}, "
49
+ f"lat={self.latitude:0.02f}, "
50
+ f"lon={self.longitude:0.02f}, "
51
+ f"elev={self.elevation:0.02f}"
52
+ )
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ """Nombre de la ubicación."""
57
+ return self._location_info.name
58
+
59
+ @name.setter
60
+ def name(self, name: str) -> None:
61
+ self._location_info = LocationInfo(
62
+ name=name,
63
+ region=self.region,
64
+ timezone=self.timezone,
65
+ latitude=self.latitude,
66
+ longitude=self.longitude,
67
+ elevation=self.elevation
68
+ )
69
+
70
+ @property
71
+ def region(self) -> str:
72
+ """Región de la ubicación."""
73
+ return self._location_info.region
74
+
75
+ @region.setter
76
+ def region(self, region: str) -> None:
77
+ self._location_info = LocationInfo(
78
+ name=self.name,
79
+ region=region,
80
+ timezone=self.timezone,
81
+ latitude=self.latitude,
82
+ longitude=self.longitude,
83
+ elevation=self.elevation
84
+ )
85
+
86
+ @property
87
+ def latitude(self) -> float:
88
+ """Latitud de la ubicación (grados, positivo para Norte)."""
89
+ return self._location_info.latitude
90
+
91
+ @latitude.setter
92
+ def latitude(self, latitude: Union[float, str]) -> None:
93
+ if isinstance(latitude, str):
94
+ latitude = float(latitude) # Simplificado, asumir formato decimal
95
+ self._location_info = LocationInfo(
96
+ name=self.name,
97
+ region=self.region,
98
+ timezone=self.timezone,
99
+ latitude=latitude,
100
+ longitude=self.longitude,
101
+ elevation=self.elevation
102
+ )
103
+
104
+ @property
105
+ def longitude(self) -> float:
106
+ """Longitud de la ubicación (grados, positivo para Este)."""
107
+ return self._location_info.longitude
108
+
109
+ @longitude.setter
110
+ def longitude(self, longitude: Union[float, str]) -> None:
111
+ if isinstance(longitude, str):
112
+ longitude = float(longitude) # Simplificado, asumir formato decimal
113
+ self._location_info = LocationInfo(
114
+ name=self.name,
115
+ region=self.region,
116
+ timezone=self.timezone,
117
+ latitude=self.latitude,
118
+ longitude=longitude,
119
+ elevation=self.elevation
120
+ )
121
+
122
+ @property
123
+ def elevation(self) -> float:
124
+ """Elevación de la ubicación (metros sobre el nivel del mar)."""
125
+ return self._location_info.elevation
126
+
127
+ @elevation.setter
128
+ def elevation(self, elevation: float) -> None:
129
+ self._location_info = LocationInfo(
130
+ name=self.name,
131
+ region=self.region,
132
+ timezone=self.timezone,
133
+ latitude=self.latitude,
134
+ longitude=self.longitude,
135
+ elevation=float(elevation)
136
+ )
137
+
138
+ @property
139
+ def timezone(self) -> str:
140
+ """Nombre de la zona horaria."""
141
+ return self._location_info.timezone
142
+
143
+ @timezone.setter
144
+ def timezone(self, name: str) -> None:
145
+ try:
146
+ ZoneInfo(name) # Validar que la zona horaria existe
147
+ self._location_info = LocationInfo(
148
+ name=self.name,
149
+ region=self.region,
150
+ timezone=name,
151
+ latitude=self.latitude,
152
+ longitude=self.longitude,
153
+ elevation=self.elevation
154
+ )
155
+ except Exception as exc:
156
+ raise ValueError(f"Zona horaria desconocida: {name}") from exc
157
+
158
+ @property
159
+ def tzinfo(self) -> ZoneInfo:
160
+ """Objeto ZoneInfo para la zona horaria."""
161
+ try:
162
+ return ZoneInfo(self.timezone)
163
+ except Exception as exc:
164
+ raise ValueError(f"Zona horaria desconocida: {self.timezone}") from exc
165
+
166
+ def sunrise(
167
+ self,
168
+ date: Optional[date] = None,
169
+ local: bool = True,
170
+ elevation: Optional[float] = None
171
+ ) -> datetime:
172
+ """
173
+ Calcula la hora del amanecer.
174
+
175
+ Args:
176
+ date: Fecha para la cual calcular el amanecer. Si es None, usa la fecha actual.
177
+ local: True para devolver la hora en la zona horaria local, False para UTC.
178
+ elevation: Elevación del observador en metros. Si es None, usa self.elevation.
179
+
180
+ Returns:
181
+ Objeto datetime con la hora del amanecer.
182
+ """
183
+ if local and self.timezone is None:
184
+ raise ValueError("Se solicitó hora local pero no se definió una zona horaria.")
185
+
186
+ if date is None:
187
+ date = datetime.now(self.tzinfo if local else timezone.utc).date()
188
+
189
+ elevation = elevation if elevation is not None else self.elevation
190
+ sunrise, _ = sunrise_sunset(
191
+ latitude=self.latitude,
192
+ longitude=self.longitude,
193
+ date=date,
194
+ elevation=elevation,
195
+ timezone=self.tzinfo if local else timezone.utc,
196
+ with_refraction=True
197
+ )
198
+ return sunrise
199
+
200
+ def sunset(
201
+ self,
202
+ date: Optional[date] = None,
203
+ local: bool = True,
204
+ elevation: Optional[float] = None
205
+ ) -> datetime:
206
+ """
207
+ Calcula la hora del atardecer.
208
+
209
+ Args:
210
+ date: Fecha para la cual calcular el atardecer. Si es None, usa la fecha actual.
211
+ local: True para devolver la hora en la zona horaria local, False para UTC.
212
+ elevation: Elevación del observador en metros. Si es None, usa self.elevation.
213
+
214
+ Returns:
215
+ Objeto datetime con la hora del atardecer.
216
+ """
217
+ if local and self.timezone is None:
218
+ raise ValueError("Se solicitó hora local pero no se definió una zona horaria.")
219
+
220
+ if date is None:
221
+ date = datetime.now(self.tzinfo if local else timezone.utc).date()
222
+
223
+ elevation = elevation if elevation is not None else self.elevation
224
+ _, sunset = sunrise_sunset(
225
+ latitude=self.latitude,
226
+ longitude=self.longitude,
227
+ date=date,
228
+ elevation=elevation,
229
+ timezone=self.tzinfo if local else timezone.utc,
230
+ with_refraction=True
231
+ )
232
+ return sunset
233
+
234
+ def noon(
235
+ self,
236
+ date: Optional[date] = None,
237
+ local: bool = True
238
+ ) -> datetime:
239
+ """
240
+ Calcula la hora del mediodía solar.
241
+
242
+ Args:
243
+ date: Fecha para la cual calcular el mediodía. Si es None, usa la fecha actual.
244
+ local: True para devolver la hora en la zona horaria local, False para UTC.
245
+
246
+ Returns:
247
+ Objeto datetime con la hora del mediodía solar.
248
+ """
249
+ if local and self.timezone is None:
250
+ raise ValueError("Se solicitó hora local pero no se definió una zona horaria.")
251
+
252
+ if date is None:
253
+ date = datetime.now(self.tzinfo if local else timezone.utc).date()
254
+
255
+ return noon(
256
+ latitude=self.latitude,
257
+ longitude=self.longitude,
258
+ date=date,
259
+ timezone=self.tzinfo if local else timezone.utc
260
+ )
261
+
262
+ def sun_events(
263
+ self,
264
+ date: Optional[date] = None,
265
+ local: bool = True,
266
+ elevation: Optional[float] = None
267
+ ) -> dict:
268
+ """
269
+ Devuelve un diccionario con sunrise, noon y sunset para una fecha dada.
270
+
271
+ Args:
272
+ date: Fecha para la cual calcular los eventos solares. Si es None, usa la fecha actual.
273
+ local: True para devolver las horas en la zona horaria local, False para UTC.
274
+ elevation: Elevación del observador en metros. Si es None, usa self.elevation.
275
+
276
+ Returns:
277
+ dict: {
278
+ "sunrise": datetime,
279
+ "noon": datetime,
280
+ "sunset": datetime
281
+ }
282
+ """
283
+ if local and self.timezone is None:
284
+ raise ValueError("Se solicitó hora local pero no se definió una zona horaria.")
285
+
286
+ tz = self.tzinfo if local else timezone.utc
287
+ date = date or datetime.now(tz).date()
288
+ elevation = elevation if elevation is not None else self.elevation
289
+
290
+ # Calcular amanecer y atardecer
291
+ sunrise, sunset = sunrise_sunset(
292
+ latitude=self.latitude,
293
+ longitude=self.longitude,
294
+ date=date,
295
+ elevation=elevation,
296
+ timezone=tz,
297
+ with_refraction=True,
298
+ )
299
+
300
+ # Calcular mediodía solar
301
+ noon_time = noon(
302
+ latitude=self.latitude,
303
+ longitude=self.longitude,
304
+ date=date,
305
+ timezone=tz,
306
+ )
307
+
308
+ return {
309
+ "sunrise": sunrise,
310
+ "noon": noon_time,
311
+ "sunset": sunset,
312
+ }
solarmoonpy/moon.py ADDED
@@ -0,0 +1,356 @@
1
+ from datetime import date, datetime, timedelta, timezone
2
+ import math
3
+ from typing import Optional, Tuple
4
+ from .sun import julianday, julianday_to_juliancentury, sun_apparent_long, sun_distance
5
+
6
+ _SYNODIC_MONTH = 29.530588853
7
+ AU = 149597870.7 # km por unidad astronómica (valor estándar)
8
+
9
+ def moon_phase(date_utc: date) -> float:
10
+ """
11
+ Devuelve la fase de la luna como valor fraccionario (0 a ~29.53, 0=nueva, ~14.77=llena).
12
+ Usa una luna nueva reciente (21 Sep 2025) para mayor precisión y opcionalmente elongación eclíptica.
13
+ Args:
14
+ date_utc (date): Fecha en UTC.
15
+ Returns:
16
+ float: Días desde la última luna nueva (0 a ~29.53).
17
+ """
18
+ # Luna nueva de referencia: 21 de septiembre de 2025, 19:54 UTC
19
+ known_new_moon = datetime(2025, 9, 21, 19, 54, tzinfo=timezone.utc)
20
+ dt = datetime(date_utc.year, date_utc.month, date_utc.day, tzinfo=timezone.utc)
21
+ diff = dt - known_new_moon
22
+ days = diff.days + (diff.seconds / 86400.0)
23
+
24
+ # Cálculo aproximado con mes sinódico
25
+ lunations = days / _SYNODIC_MONTH
26
+ approx_phase = (lunations % 1) * _SYNODIC_MONTH
27
+
28
+ # Cálculo preciso usando elongación eclíptica
29
+ jd = julianday(dt.date()) + 0.5 # Mediodía UTC
30
+ jc = julianday_to_juliancentury(jd)
31
+ lambda_m, _, _, _ = _moon_ecliptic_position(jd)
32
+ lambda_s = sun_apparent_long(jc)
33
+ elong = _normalize_angle(lambda_m - lambda_s)
34
+
35
+ # Convertir elongación a fase (0°=nueva, 180°=llena, 360°=nueva)
36
+ precise_phase = (elong / 360.0) * _SYNODIC_MONTH
37
+ if precise_phase > _SYNODIC_MONTH:
38
+ precise_phase -= _SYNODIC_MONTH
39
+
40
+ # Promedio ponderado: favorece cálculo preciso, pero usa aproximado como respaldo
41
+ return round(0.9 * precise_phase + 0.1 * approx_phase, 6)
42
+
43
+ def illuminated_percentage(date_utc: date) -> float:
44
+ """
45
+ Calcula el porcentaje de luna iluminada para una fecha UTC (versión precisa).
46
+ Usa posiciones eclípticas reales de Sol y Luna (Meeus) para la elongación y corrección por distancias.
47
+ Args:
48
+ date_utc (date): Fecha en UTC.
49
+ Returns:
50
+ float: Porcentaje de superficie lunar iluminada (0–100).
51
+ """
52
+ dt = datetime(date_utc.year, date_utc.month, date_utc.day, 12, 0, 0, tzinfo=timezone.utc) # Mediodía UTC
53
+ jd = julianday(dt.date()) + (dt.hour + dt.minute / 60 + dt.second / 3600) / 24
54
+ jc = julianday_to_juliancentury(jd)
55
+ lambda_m, beta_m, R, delta_psi = _moon_ecliptic_position(jd)
56
+ lambda_s = sun_apparent_long(jc)
57
+ r_sun = sun_distance(jc)
58
+ diff = _normalize_angle(lambda_m - lambda_s)
59
+ elong_rad = math.radians(diff)
60
+ beta_rad = math.radians(beta_m)
61
+ cos_E = math.cos(beta_rad) * math.cos(elong_rad)
62
+ d = r_sun * AU
63
+ s2 = d**2 + R**2 - 2 * d * R * cos_E
64
+ s = math.sqrt(s2)
65
+ cos_i = (s**2 + R**2 - d**2) / (2 * s * R)
66
+ illum_fraction = (1 + cos_i) / 2
67
+ return illum_fraction * 100.0
68
+
69
+ def _normalize_angle(angle: float) -> float:
70
+ """Normaliza ángulo a 0-360 grados."""
71
+ return angle % 360.0
72
+
73
+ # Tabla completa 47.A para sigma_l y sigma_r (de PyMeeus/Meeus)
74
+ SIGMA_LR_TABLE = [
75
+ [0, 0, 1, 0, 6288774.0, -20905355.0],
76
+ [2, 0, -1, 0, 1274027.0, -3699111.0],
77
+ [2, 0, 0, 0, 658314.0, -2955968.0],
78
+ [0, 0, 2, 0, 213618.0, -569925.0],
79
+ [0, 1, 0, 0, -185116.0, 48888.0],
80
+ [0, 0, 0, 2, -114332.0, -3149.0],
81
+ [2, 0, -2, 0, 58793.0, 246158.0],
82
+ [2, -1, -1, 0, 57066.0, -152138.0],
83
+ [2, 0, 1, 0, 53322.0, -170733.0],
84
+ [2, -1, 0, 0, 45758.0, -204586.0],
85
+ [0, 1, -1, 0, -40923.0, -129620.0],
86
+ [1, 0, 0, 0, -34720.0, 108743.0],
87
+ [0, 1, 1, 0, -30383.0, 104755.0],
88
+ [2, 0, 0, -2, 15327.0, 10321.0],
89
+ [0, 0, 1, 2, -12528.0, 0.0],
90
+ [0, 0, 1, -2, 10980.0, 79661.0],
91
+ [4, 0, -1, 0, 10675.0, -34782.0],
92
+ [0, 0, 3, 0, 10034.0, -23210.0],
93
+ [4, 0, -2, 0, 8548.0, -21636.0],
94
+ [2, 1, -1, 0, -7888.0, 24208.0],
95
+ [2, 1, 0, 0, -6766.0, 30824.0],
96
+ [1, 0, -1, 0, -5163.0, -8379.0],
97
+ [1, 1, 0, 0, 4987.0, -16675.0],
98
+ [2, -1, 1, 0, 4036.0, -12831.0],
99
+ [2, 0, 2, 0, 3994.0, -10445.0],
100
+ [4, 0, 0, 0, 3861.0, -11650.0],
101
+ [2, 0, -3, 0, 3665.0, 14403.0],
102
+ [0, 1, -2, 0, -2689.0, -7003.0],
103
+ [2, 0, -1, 2, -2602.0, 0.0],
104
+ [2, -1, -2, 0, 2390.0, 10056.0],
105
+ [1, 0, 1, 0, -2348.0, 6322.0],
106
+ [2, -2, 0, 0, 2236.0, -9884.0],
107
+ [0, 1, 2, 0, -2120.0, 5751.0],
108
+ [0, 2, 0, 0, -2069.0, 0.0],
109
+ [2, -2, -1, 0, 2048.0, -4950.0],
110
+ [2, 0, 1, -2, -1773.0, 4130.0],
111
+ [2, 0, 0, 2, -1595.0, 0.0],
112
+ [4, -1, -1, 0, 1215.0, -3958.0],
113
+ [0, 0, 2, 2, -1110.0, 0.0],
114
+ [3, 0, -1, 0, -892.0, 3258.0],
115
+ [2, 1, 1, 0, -810.0, 2616.0],
116
+ [4, -1, -2, 0, 759.0, -1897.0],
117
+ [0, 2, -1, 0, -713.0, -2117.0],
118
+ [2, 2, -1, 0, -700.0, 2354.0],
119
+ [2, 1, -2, 0, 691.0, 0.0],
120
+ [2, -1, 0, -2, 596.0, 0.0],
121
+ [4, 0, 1, 0, 549.0, -1423.0],
122
+ [0, 0, 4, 0, 537.0, -1117.0],
123
+ [4, -1, 0, 0, 520.0, -1571.0],
124
+ [1, 0, -2, 0, -487.0, -1739.0],
125
+ [2, 1, 0, -2, -399.0, 0.0],
126
+ [0, 0, 2, -2, -381.0, -4421.0],
127
+ [1, 1, 1, 0, 351.0, 0.0],
128
+ [3, 0, -2, 0, -340.0, 0.0],
129
+ [4, 0, -3, 0, 330.0, 0.0],
130
+ [2, -1, 2, 0, 327.0, 0.0],
131
+ [0, 2, 1, 0, -323.0, 1165.0],
132
+ [1, 1, -1, 0, 299.0, 0.0],
133
+ [2, 0, 3, 0, 294.0, 0.0],
134
+ [2, 0, -1, -2, 0.0, 8752.0]
135
+ ]
136
+
137
+ # Tabla completa 47.B para sigma_b (de PyMeeus/Meeus)
138
+ SIGMA_B_TABLE = [
139
+ [0, 0, 0, 1, 5128122.0],
140
+ [0, 0, 1, 1, 280602.0],
141
+ [0, 0, 1, -1, 277693.0],
142
+ [2, 0, 0, -1, 173237.0],
143
+ [2, 0, -1, 1, 55413.0],
144
+ [2, 0, -1, -1, 46271.0],
145
+ [2, 0, 0, 1, 32573.0],
146
+ [0, 0, 2, 1, 17198.0],
147
+ [2, 0, 1, -1, 9266.0],
148
+ [0, 0, 2, -1, 8822.0],
149
+ [2, -1, 0, -1, 8216.0],
150
+ [2, 0, -2, -1, 4324.0],
151
+ [2, 0, 1, 1, 4200.0],
152
+ [2, 1, 0, -1, -3359.0],
153
+ [2, -1, -1, 1, 2463.0],
154
+ [2, -1, 0, 1, 2211.0],
155
+ [2, -1, -1, -1, 2065.0],
156
+ [0, 1, -1, -1, -1870.0],
157
+ [4, 0, -1, -1, 1828.0],
158
+ [0, 1, 0, 1, -1794.0],
159
+ [0, 0, 0, 3, -1749.0],
160
+ [0, 1, -1, 1, -1565.0],
161
+ [1, 0, 0, 1, -1491.0],
162
+ [0, 1, 1, 1, -1475.0],
163
+ [0, 1, 1, -1, -1410.0],
164
+ [0, 1, 0, -1, -1344.0],
165
+ [1, 0, 0, -1, -1335.0],
166
+ [0, 0, 3, 1, 1107.0],
167
+ [4, 0, 0, -1, 1021.0],
168
+ [4, 0, -1, 1, 833.0],
169
+ [0, 0, 1, -3, 777.0],
170
+ [4, 0, -2, 1, 671.0],
171
+ [2, 0, 0, -3, 607.0],
172
+ [2, 0, 2, -1, 596.0],
173
+ [2, -1, 1, -1, 491.0],
174
+ [2, 0, -2, 1, -451.0],
175
+ [0, 0, 3, -1, 439.0],
176
+ [2, 0, 2, 1, 422.0],
177
+ [2, 0, -3, -1, 421.0],
178
+ [2, 1, -1, 1, -366.0],
179
+ [2, 1, 0, 1, -351.0],
180
+ [4, 0, 0, 1, 331.0],
181
+ [2, -1, 1, 1, 315.0],
182
+ [2, -2, 0, -1, 302.0],
183
+ [0, 0, 1, 3, -283.0],
184
+ [2, 1, 1, -1, -229.0],
185
+ [1, 1, 0, -1, 223.0],
186
+ [1, 1, 0, 1, 223.0],
187
+ [0, 1, -2, -1, -220.0],
188
+ [2, 1, -1, -1, -220.0],
189
+ [1, 0, 1, 1, -185.0],
190
+ [2, -1, -2, -1, 181.0],
191
+ [0, 1, 2, 1, -177.0],
192
+ [4, 0, -2, -1, 176.0],
193
+ [4, -1, -1, -1, 166.0],
194
+ [1, 0, 1, -1, -164.0],
195
+ [4, 0, 1, -1, 132.0],
196
+ [1, 0, -1, -1, -119.0],
197
+ [4, -1, 0, -1, 115.0],
198
+ [2, -2, 0, 1, 107.0]
199
+ ]
200
+
201
+ def _periodic_terms(D, M, Mp, F, T):
202
+ """Calcula sigma_l, sigma_r, sigma_b con tablas completas y factor E."""
203
+ E = 1 - 0.002516 * T - 0.0000074 * T**2
204
+ E2 = E * E
205
+ sigma_l = 0.0
206
+ sigma_r = 0.0
207
+ sigma_b = 0.0
208
+
209
+ for d_coef, m, mp, f, l, r in SIGMA_LR_TABLE:
210
+ factor = 1.0
211
+ if abs(m) == 1:
212
+ factor = E
213
+ elif abs(m) == 2:
214
+ factor = E2
215
+ arg = d_coef * D + m * M + mp * Mp + f * F
216
+ sigma_l += l * math.sin(math.radians(arg)) * factor
217
+ sigma_r += r * math.cos(math.radians(arg)) * factor
218
+
219
+ for d_coef, m, mp, f, b in SIGMA_B_TABLE:
220
+ factor = 1.0
221
+ if abs(m) == 1:
222
+ factor = E
223
+ elif abs(m) == 2:
224
+ factor = E2
225
+ arg = d_coef * D + m * M + mp * Mp + f * F
226
+ sigma_b += b * math.sin(math.radians(arg)) * factor
227
+
228
+ sigma_l /= 1000000.0 # a grados
229
+ sigma_r *= 0.001 # a km
230
+ sigma_b /= 1000000.0 # a grados
231
+
232
+ return sigma_l, sigma_r, sigma_b
233
+
234
+ def _moon_ecliptic_position(jd: float) -> Tuple[float, float, float, float]:
235
+ """Calcula longitud eclíptica aparente, latitud, distancia y delta_psi de la Luna."""
236
+ T = (jd - 2451545.0) / 36525.0
237
+ Lp = _normalize_angle(218.3164477 + 481267.88123421 * T - 0.0015786 * T**2 + T**3 / 538841.0 - T**4 / 65194000.0)
238
+ D = _normalize_angle(297.8501921 + 445267.1114034 * T - 0.0018819 * T**2 + T**3 / 545868.0 - T**4 / 113065000.0)
239
+ M = _normalize_angle(357.5291092 + 35999.0502909 * T - 0.0001536 * T**2 + T**3 / 24490000.0)
240
+ Mp = _normalize_angle(134.9633964 + 477198.8675055 * T + 0.0087414 * T**2 + T**3 / 69699.0 - T**4 / 14712000.0)
241
+ F = _normalize_angle(93.2720950 + 483202.0175238 * T - 0.0036539 * T**2 - T**3 / 3526000.0 + T**4 / 863310000.0)
242
+ Omega = _normalize_angle(125.04452 - 1934.136261 * T + 0.0020708 * T**2 + T**3 / 450000.0)
243
+
244
+ sigma_l, sigma_r, sigma_b = _periodic_terms(D, M, Mp, F, T)
245
+
246
+ lambda_ = Lp + sigma_l + 0.003958 * math.sin(math.radians(119.75 + 131.849 * T)) + 0.000319 * math.sin(math.radians(53.09 + 479264.290 * T)) + 0.000024 * math.sin(math.radians(313.45 + 481266.484 * T))
247
+ beta = sigma_b - 0.000024 * math.sin(math.radians(313.45 + 481266.484 * T - 2 * F))
248
+ R = 385000.529 + sigma_r
249
+ delta_psi = (-17.20 * math.sin(math.radians(Omega)) - 1.32 * math.sin(math.radians(2 * (Lp - F))) + 0.23 * math.sin(math.radians(2 * Lp)) + 0.21 * math.sin(math.radians(2 * Omega))) / 3600.0
250
+ lambda_ += delta_psi
251
+
252
+ return lambda_, beta, R, delta_psi
253
+
254
+ def _calculate_position_and_alt(t: datetime, lat_r: float, lon: float) -> tuple[float, float]:
255
+ """Calcula alt, h0 para un tiempo dado t (ra no usado)."""
256
+ jd = julianday(t.date()) + (t.hour + t.minute / 60 + t.second / 3600) / 24
257
+ T = (jd - 2451545.0) / 36525.0
258
+ lambda_, beta, R, delta_psi = _moon_ecliptic_position(jd)
259
+
260
+ par = math.asin(6378.14 / R)
261
+ eps = math.radians(23.439281 - 0.0000004 * T)
262
+ lambda_r = math.radians(lambda_)
263
+ beta_r = math.radians(beta)
264
+ ra = math.atan2(math.sin(lambda_r) * math.cos(eps) - math.tan(beta_r) * math.sin(eps), math.cos(lambda_r))
265
+ dec = math.asin(math.sin(beta_r) * math.cos(eps) + math.cos(beta_r) * math.sin(eps) * math.sin(lambda_r))
266
+ sd = 0.2725076 * par
267
+ ref = math.radians(0.5667)
268
+ h0 = par - sd - ref
269
+ gmst = (280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * T**2 - T**3 / 38710000.0) % 360.0
270
+ lst = math.radians((gmst + lon) % 360.0)
271
+ ha = lst - ra
272
+ if ha < -math.pi: ha += 2 * math.pi
273
+ elif ha > math.pi: ha -= 2 * math.pi
274
+ alt = math.asin(math.sin(lat_r) * math.sin(dec) + math.cos(lat_r) * math.cos(dec) * math.cos(ha))
275
+
276
+ return alt, h0
277
+
278
+ def moon_rise_set(lat: float, lon: float, date_utc: date) -> Tuple[Optional[datetime], Optional[datetime]]:
279
+ """
280
+ Cálculo preciso de moonrise y moonset con series completas de Meeus (precisión ~1-2 min).
281
+ Devuelve tuplas UTC (datetime tz-aware) o None si no ocurre en ese día.
282
+ """
283
+ lat_r = math.radians(lat)
284
+ rise = None
285
+ set_ = None
286
+ dt = datetime(date_utc.year, date_utc.month, date_utc.day, tzinfo=timezone.utc)
287
+ prev_t = None
288
+ prev_alt = None
289
+
290
+ step_grueso = 1.0
291
+ intervalos_rise = []
292
+ intervalos_set = []
293
+ for i in range(int(24 / step_grueso) + 1):
294
+ hour = i * step_grueso
295
+ t = dt + timedelta(hours=hour)
296
+ alt, h0 = _calculate_position_and_alt(t, lat_r, lon)
297
+ if prev_alt is not None:
298
+ den = alt - prev_alt
299
+ if abs(den) < 1e-12:
300
+ continue
301
+ if prev_alt < h0 and alt >= h0:
302
+ intervalos_rise.append((prev_t, t, prev_alt, alt))
303
+ elif prev_alt >= h0 and alt < h0:
304
+ intervalos_set.append((prev_t, t, prev_alt, alt))
305
+ prev_t = t
306
+ prev_alt = alt
307
+
308
+ def refine_event(start_t: datetime, end_t: datetime, start_alt: float, end_alt: float, is_rise: bool) -> datetime:
309
+ for _ in range(10):
310
+ mid_t = start_t + (end_t - start_t) / 2
311
+ mid_alt, mid_h0 = _calculate_position_and_alt(mid_t, lat_r, lon)
312
+ den = mid_alt - start_alt if is_rise else mid_alt - end_alt
313
+ if abs(den) < 1e-12:
314
+ return mid_t
315
+ if (is_rise and mid_alt < mid_h0) or (not is_rise and mid_alt >= mid_h0):
316
+ start_t, start_alt = mid_t, mid_alt
317
+ else:
318
+ end_t, end_alt = mid_t, mid_alt
319
+ return start_t + (end_t - start_t) / 2
320
+
321
+ if intervalos_rise and rise is None:
322
+ start_t, end_t, start_alt, end_alt = intervalos_rise[0]
323
+ rise = refine_event(start_t, end_t, start_alt, end_alt, True)
324
+ if intervalos_set and set_ is None:
325
+ start_t, end_t, start_alt, end_alt = intervalos_set[0]
326
+ set_ = refine_event(start_t, end_t, start_alt, end_alt, False)
327
+
328
+ return rise, set_
329
+
330
+ def moon_distance(date_utc: date) -> float:
331
+ """
332
+ Calcula la distancia Tierra-Luna en kilómetros para una fecha UTC.
333
+ Args:
334
+ date_utc (date): Fecha en UTC.
335
+ Returns:
336
+ float: Distancia en kilómetros.
337
+ """
338
+ dt = datetime(date_utc.year, date_utc.month, date_utc.day, 12, 0, 0, tzinfo=timezone.utc) # Mediodía UTC
339
+ jd = julianday(dt.date()) + (dt.hour + dt.minute / 60 + dt.second / 3600) / 24
340
+ _, _, R, _ = _moon_ecliptic_position(jd)
341
+ return R
342
+
343
+ def moon_angular_diameter(date_utc: date) -> float:
344
+ """
345
+ Calcula el diámetro angular de la Luna en arcosegundos para una fecha UTC.
346
+ Args:
347
+ date_utc (date): Fecha en UTC.
348
+ Returns:
349
+ float: Diámetro angular en arcosegundos.
350
+ """
351
+ dt = datetime(date_utc.year, date_utc.month, date_utc.day, 12, 0, 0, tzinfo=timezone.utc) # Mediodía UTC
352
+ jd = julianday(dt.date()) + (dt.hour + dt.minute / 60 + dt.second / 3600) / 24
353
+ _, _, R, _ = _moon_ecliptic_position(jd)
354
+ par = math.asin(6378.14 / R) # Paralaje horizontal
355
+ sd = 0.2725076 * par # Semi-diámetro
356
+ return 2 * math.degrees(sd) * 3600 # Diámetro angular en arcosegundos