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.
- solarmoonpy/__init__.py +23 -0
- solarmoonpy/location.py +312 -0
- solarmoonpy/moon.py +356 -0
- solarmoonpy/sun.py +232 -0
- solarmoonpy/version.py +2 -0
- solarmoonpy-0.1.0.dist-info/METADATA +224 -0
- solarmoonpy-0.1.0.dist-info/RECORD +10 -0
- solarmoonpy-0.1.0.dist-info/WHEEL +5 -0
- solarmoonpy-0.1.0.dist-info/licenses/LICENSE +201 -0
- solarmoonpy-0.1.0.dist-info/top_level.txt +1 -0
solarmoonpy/__init__.py
ADDED
|
@@ -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
|
+
]
|
solarmoonpy/location.py
ADDED
|
@@ -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
|