aetherfield 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.
- aetherfield-0.1.0.dist-info/METADATA +45 -0
- aetherfield-0.1.0.dist-info/RECORD +11 -0
- aetherfield-0.1.0.dist-info/WHEEL +5 -0
- aetherfield-0.1.0.dist-info/entry_points.txt +4 -0
- aetherfield-0.1.0.dist-info/licenses/LICENSE +21 -0
- aetherfield-0.1.0.dist-info/top_level.txt +1 -0
- aetherfield_pkg/__init__.py +34 -0
- aetherfield_pkg/cli/benchmark.py +195 -0
- aetherfield_pkg/cli/calibrate_all.py +61 -0
- aetherfield_pkg/cli/compare.py +194 -0
- aetherfield_pkg/core.py +787 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aetherfield
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AetherField runtime ephemeris
|
|
5
|
+
Author: WitchMithras
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/WitchMithras/aetherfield
|
|
8
|
+
Keywords: time,calendar,lunar,astronomy
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: License :: Other/Proprietary License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pytz>=2023.3
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
AeTherField Staging
|
|
20
|
+
===================
|
|
21
|
+
|
|
22
|
+
This folder stages a future standalone package for AetherField — a Skyfield-free
|
|
23
|
+
runtime ephemeris model that uses learned calibration/piecewise drift and offers
|
|
24
|
+
simple APIs for signs, longitudes, alignments, phases, and (now) sunrise/sunset
|
|
25
|
+
estimation.
|
|
26
|
+
|
|
27
|
+
Plan
|
|
28
|
+
- Extract reusable runtime core from the root `aetherfield.py`.
|
|
29
|
+
- Preserve public APIs: `aether_longitude`, `aether_sign`, `aether_alignments`, `moon_phase`.
|
|
30
|
+
- Add `sunrise_sunset(zone, coords, date, depression_deg)` for Moontime temporal hours.
|
|
31
|
+
- Keep calibration loaders and CLI tools in a tools subpackage.
|
|
32
|
+
- Console scripts (staging): `aetherfield-compare`, `aetherfield-benchmark`, `aetherfield-calibrate-all`.
|
|
33
|
+
|
|
34
|
+
Local Layout (staging)
|
|
35
|
+
- `src/aetherfield_pkg/` — package source (to be populated)
|
|
36
|
+
- `pyproject.toml` — packaging config (setuptools)
|
|
37
|
+
|
|
38
|
+
Dev Tips
|
|
39
|
+
- Create a venv and install local deps from the project root as usual.
|
|
40
|
+
- In staging, the package may re-export from the root `aetherfield.py` to avoid duplication.
|
|
41
|
+
|
|
42
|
+
CLI Usage (staging)
|
|
43
|
+
- Compare a single timestamp: `python -m aetherfield_pkg.cli.compare --body mars --dt 2025-06-21T12:00:00Z`
|
|
44
|
+
- Benchmark a range: `python -m aetherfield_pkg.cli.benchmark --start 2001-01-01T00:00:00Z --end 2001-12-31T00:00:00Z`
|
|
45
|
+
- Calibrate all: `python -m aetherfield_pkg.cli.calibrate_all --out aetherfield_calibration.json`
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
aetherfield-0.1.0.dist-info/licenses/LICENSE,sha256=rKuW6ZbGyYtuytP6L9hGakV-X2kT02rldopiH9-ALXA,1095
|
|
2
|
+
aetherfield_pkg/__init__.py,sha256=qv_EFcbmEa9UWMagwcSnHUtLuV5Ao8bRce9M5iYEluA,744
|
|
3
|
+
aetherfield_pkg/core.py,sha256=58ETL6mgzy6NEmwAavEJrGu8GsBYd_nEElwxuPrZL7Y,29668
|
|
4
|
+
aetherfield_pkg/cli/benchmark.py,sha256=7nH6Mb6OXCbOHiFlyY4hNgGSLW2aFzx6exCnzeN5qUk,7253
|
|
5
|
+
aetherfield_pkg/cli/calibrate_all.py,sha256=_5IkoFHMyJ8cXRVfi4HqYiBzpr0SmaOtQcIX-QJKvD4,1648
|
|
6
|
+
aetherfield_pkg/cli/compare.py,sha256=1_1Hex2t85bDbqQ12K8PFta4-elKxHskPDjAt_KAdbs,6921
|
|
7
|
+
aetherfield-0.1.0.dist-info/METADATA,sha256=dx92ss7MjOB_Wqt-EFGgajQqHTHsQ-DCwXvhm7q4CNQ,1991
|
|
8
|
+
aetherfield-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
aetherfield-0.1.0.dist-info/entry_points.txt,sha256=jytJJqN9zdl5SA_8s7ijmAksxQX-6DqbKHTkLs1_C-w,199
|
|
10
|
+
aetherfield-0.1.0.dist-info/top_level.txt,sha256=UxWCdKteacp03lDaJXbgK63Tid1vmB19DcGmBaUItjA,16
|
|
11
|
+
aetherfield-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Heather Nightfall
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aetherfield_pkg
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""AetherField staging package shim.
|
|
2
|
+
|
|
3
|
+
For now, re-export selected APIs from the host project's `aetherfield.py` to
|
|
4
|
+
allow downstream packages to begin switching imports to `aetherfield_pkg`.
|
|
5
|
+
|
|
6
|
+
In a full extraction, we will move the implementation here and keep this API
|
|
7
|
+
surface stable.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .core import (
|
|
11
|
+
aether_longitude,
|
|
12
|
+
aether_sign,
|
|
13
|
+
aether_alignments,
|
|
14
|
+
moon_phase,
|
|
15
|
+
sunrise_sunset,
|
|
16
|
+
AetherField,
|
|
17
|
+
DE421_START,
|
|
18
|
+
DE421_END,
|
|
19
|
+
ae_is_up,
|
|
20
|
+
summarize_is_up,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"aether_longitude",
|
|
25
|
+
"aether_sign",
|
|
26
|
+
"aether_alignments",
|
|
27
|
+
"moon_phase",
|
|
28
|
+
"sunrise_sunset",
|
|
29
|
+
"AetherField",
|
|
30
|
+
"DE421_START",
|
|
31
|
+
"DE421_END",
|
|
32
|
+
"ae_is_up",
|
|
33
|
+
"summarize_is_up",
|
|
34
|
+
]
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from statistics import mean, median
|
|
7
|
+
from typing import Dict, List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
import pytz
|
|
10
|
+
import aetherfield as af
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
UTC = pytz.utc
|
|
14
|
+
DE421_START = af.DE421_START
|
|
15
|
+
DE421_END = af.DE421_END
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_dt(s: str) -> datetime:
|
|
19
|
+
s = s.strip()
|
|
20
|
+
if s.endswith('Z'):
|
|
21
|
+
s = s[:-1]
|
|
22
|
+
dt = datetime.fromisoformat(s)
|
|
23
|
+
return dt.replace(tzinfo=UTC)
|
|
24
|
+
dt = datetime.fromisoformat(s)
|
|
25
|
+
if dt.tzinfo is None:
|
|
26
|
+
dt = dt.replace(tzinfo=UTC)
|
|
27
|
+
return dt.astimezone(UTC)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wrap_delta_deg(a: float, b: float) -> float:
|
|
31
|
+
return (a - b + 540.0) % 360.0 - 180.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _drift_longitude(a: af.AetherField, dt: datetime, body: str, anchor_mode: str = 'nearest') -> float:
|
|
35
|
+
a._ensure_anchor(body) # type: ignore[attr-defined]
|
|
36
|
+
rate = a.rates_deg_per_day.get(body)
|
|
37
|
+
if rate is None:
|
|
38
|
+
raise KeyError(f"No drift rate for body: {body}")
|
|
39
|
+
dt = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
40
|
+
dt = dt.astimezone(UTC)
|
|
41
|
+
if anchor_mode == 'nearest':
|
|
42
|
+
d_start = abs((dt - DE421_START).total_seconds())
|
|
43
|
+
d_end = abs((DE421_END - dt).total_seconds())
|
|
44
|
+
anchor_mode = 'start' if d_start < d_end else 'end'
|
|
45
|
+
if anchor_mode == 'start':
|
|
46
|
+
days = (dt - DE421_START).total_seconds() / 86400.0
|
|
47
|
+
return (a.anchors_min[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
48
|
+
else:
|
|
49
|
+
days = (dt - DE421_END).total_seconds() / 86400.0
|
|
50
|
+
return (a.anchors_max[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def skyfield_longitudes(ts: Sequence[datetime], body: str) -> List[float]:
|
|
54
|
+
from skyfield.api import load
|
|
55
|
+
from skyfield.framelib import ecliptic_frame
|
|
56
|
+
|
|
57
|
+
eph = load('de421.bsp')
|
|
58
|
+
earth = eph['earth']
|
|
59
|
+
key = af._body_key(eph, body) # type: ignore[attr-defined]
|
|
60
|
+
tscale = load.timescale()
|
|
61
|
+
out: List[float] = []
|
|
62
|
+
for dt in ts:
|
|
63
|
+
t = tscale.from_datetime(dt.astimezone(UTC))
|
|
64
|
+
app = earth.at(t).observe(key).apparent()
|
|
65
|
+
lon, lat, dist = app.frame_latlon(ecliptic_frame)
|
|
66
|
+
out.append(float(lon.degrees) % 360.0)
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class BodyStats:
|
|
72
|
+
n: int
|
|
73
|
+
mae_mean: float
|
|
74
|
+
mae_piece: float
|
|
75
|
+
med_mean: float
|
|
76
|
+
med_piece: float
|
|
77
|
+
max_mean: float
|
|
78
|
+
max_piece: float
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def benchmark(
|
|
82
|
+
bodies: Sequence[str],
|
|
83
|
+
start: datetime,
|
|
84
|
+
end: datetime,
|
|
85
|
+
step_days: int,
|
|
86
|
+
piecewise_step: int,
|
|
87
|
+
build_piecewise: bool,
|
|
88
|
+
fit_rates: bool,
|
|
89
|
+
drift_anchor: str,
|
|
90
|
+
load_cal: Optional[str],
|
|
91
|
+
save_cal: Optional[str],
|
|
92
|
+
) -> Dict[str, BodyStats]:
|
|
93
|
+
r0 = max(start, DE421_START)
|
|
94
|
+
r1 = min(end, DE421_END)
|
|
95
|
+
if r1 < r0:
|
|
96
|
+
return {}
|
|
97
|
+
tlist: List[datetime] = []
|
|
98
|
+
step_days = max(1, int(step_days))
|
|
99
|
+
t = r0
|
|
100
|
+
while t <= r1:
|
|
101
|
+
tlist.append(t)
|
|
102
|
+
t = t + timedelta(days=step_days)
|
|
103
|
+
|
|
104
|
+
if load_cal:
|
|
105
|
+
a = af.AetherField.load_calibration(load_cal)
|
|
106
|
+
else:
|
|
107
|
+
a = af.AetherField()
|
|
108
|
+
if fit_rates:
|
|
109
|
+
try:
|
|
110
|
+
a.fit_rates()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
if build_piecewise:
|
|
114
|
+
try:
|
|
115
|
+
a.fit_piecewise(step_days=piecewise_step, bodies=tuple(bodies))
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
if save_cal:
|
|
119
|
+
try:
|
|
120
|
+
a.save_calibration(save_cal)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
results: Dict[str, BodyStats] = {}
|
|
125
|
+
for body in bodies:
|
|
126
|
+
sky = skyfield_longitudes(tlist, body)
|
|
127
|
+
mean_preds = [_drift_longitude(a, dt, body, anchor_mode=drift_anchor) for dt in tlist]
|
|
128
|
+
if build_piecewise and (body in getattr(a, 'piecewise', {})):
|
|
129
|
+
piece_preds = [a.longitude_piecewise(dt, body, anchor_mode=drift_anchor) for dt in tlist]
|
|
130
|
+
else:
|
|
131
|
+
piece_preds = [a.longitude(dt, body) for dt in tlist]
|
|
132
|
+
|
|
133
|
+
mean_err = [abs(wrap_delta_deg(p, s)) for p, s in zip(mean_preds, sky)]
|
|
134
|
+
piece_err = [abs(wrap_delta_deg(p, s)) for p, s in zip(piece_preds, sky)]
|
|
135
|
+
|
|
136
|
+
stats = BodyStats(
|
|
137
|
+
n=len(sky),
|
|
138
|
+
mae_mean=mean(mean_err) if mean_err else float('nan'),
|
|
139
|
+
mae_piece=mean(piece_err) if piece_err else float('nan'),
|
|
140
|
+
med_mean=median(mean_err) if mean_err else float('nan'),
|
|
141
|
+
med_piece=median(piece_err) if piece_err else float('nan'),
|
|
142
|
+
max_mean=max(mean_err) if mean_err else float('nan'),
|
|
143
|
+
max_piece=max(piece_err) if piece_err else float('nan'),
|
|
144
|
+
)
|
|
145
|
+
results[body] = stats
|
|
146
|
+
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def main(argv=None) -> int:
|
|
151
|
+
parser = argparse.ArgumentParser(description="Benchmark AetherField mean drift vs piecewise against Skyfield/de421.")
|
|
152
|
+
parser.add_argument('--bodies', default='sun,moon,mercury,venus,mars,jupiter,saturn,uranus,neptune,pluto', help='Comma-separated list of bodies to evaluate.')
|
|
153
|
+
parser.add_argument('--start', required=True, help='Start datetime ISO8601 (e.g., 2001-01-01T00:00:00Z).')
|
|
154
|
+
parser.add_argument('--end', required=True, help='End datetime ISO8601 (inclusive).')
|
|
155
|
+
parser.add_argument('--step-days', type=int, default=5, help='Sampling step in days.')
|
|
156
|
+
parser.add_argument('--drift-anchor', choices=['start', 'end', 'nearest'], default='nearest', help='Anchor used for mean-drift and extrapolation.')
|
|
157
|
+
parser.add_argument('--piecewise', action='store_true', help='Build and use piecewise segments for evaluation.')
|
|
158
|
+
parser.add_argument('--piecewise-step', type=int, default=10, help='Step in days for fitting piecewise segments.')
|
|
159
|
+
parser.add_argument('--fit-rates', action='store_true', help='Fit mean drift rates from in-range samples before benchmarking.')
|
|
160
|
+
parser.add_argument('--load-calibration', default=None, help='Path to load saved calibration (rates/anchors/piecewise).')
|
|
161
|
+
parser.add_argument('--save-calibration', default=None, help='Path to save calibration after building.')
|
|
162
|
+
parser.add_argument('--json', action='store_true', help='Emit JSON summary instead of text table.')
|
|
163
|
+
|
|
164
|
+
args = parser.parse_args(argv)
|
|
165
|
+
bodies = [b.strip().lower() for b in args.bodies.split(',') if b.strip()]
|
|
166
|
+
|
|
167
|
+
start = parse_dt(args.start)
|
|
168
|
+
end = parse_dt(args.end)
|
|
169
|
+
stats = benchmark(
|
|
170
|
+
bodies=bodies,
|
|
171
|
+
start=start,
|
|
172
|
+
end=end,
|
|
173
|
+
step_days=args.step_days,
|
|
174
|
+
piecewise_step=args.piecewise_step,
|
|
175
|
+
build_piecewise=args.piecewise,
|
|
176
|
+
fit_rates=args.fit_rates,
|
|
177
|
+
drift_anchor=args.drift_anchor,
|
|
178
|
+
load_cal=args.load_calibration,
|
|
179
|
+
save_cal=args.save_calibration,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if args.json:
|
|
183
|
+
import json
|
|
184
|
+
print(json.dumps({k: vars(v) for k, v in stats.items()}, indent=2))
|
|
185
|
+
else:
|
|
186
|
+
for b, st in stats.items():
|
|
187
|
+
print(f"{b:9s} N={st.n:4d} MAE(mean)={st.mae_mean:6.3f}° MAE(piece)={st.mae_piece:6.3f}° "
|
|
188
|
+
f"MED(mean)={st.med_mean:6.3f}° MED(piece)={st.med_piece:6.3f}° "
|
|
189
|
+
f"MAX(mean)={st.max_mean:6.3f}° MAX(piece)={st.max_piece:6.3f}°")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == '__main__':
|
|
194
|
+
raise SystemExit(main())
|
|
195
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
import aetherfield as af
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_OUT = 'aetherfield_calibration.json'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv=None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(description='Calibrate AetherField and save piecewise segments for all bodies.')
|
|
13
|
+
parser.add_argument('--out', default=DEFAULT_OUT, help='Output JSON path for calibration data.')
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
a = af.AetherField()
|
|
17
|
+
|
|
18
|
+
bodies_all = ('sun','moon','mercury','venus','mars','jupiter','saturn','uranus','neptune','pluto')
|
|
19
|
+
# Ensure anchors
|
|
20
|
+
for b in bodies_all:
|
|
21
|
+
a._ensure_anchor(b) # type: ignore[attr-defined]
|
|
22
|
+
|
|
23
|
+
# Fit mean drift from in-range samples (coarse global refinement)
|
|
24
|
+
try:
|
|
25
|
+
a.fit_rates()
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
# Piecewise segments with tuned steps per group
|
|
30
|
+
groups = [
|
|
31
|
+
(('moon',), 1),
|
|
32
|
+
(('sun','mercury','venus'), 5),
|
|
33
|
+
(('mars',), 10),
|
|
34
|
+
(('jupiter','saturn'), 15),
|
|
35
|
+
(('uranus','neptune','pluto'), 30),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
summary = {}
|
|
39
|
+
for bodies, step in groups:
|
|
40
|
+
try:
|
|
41
|
+
created = a.fit_piecewise(step_days=step, bodies=bodies)
|
|
42
|
+
summary.update(created)
|
|
43
|
+
except Exception:
|
|
44
|
+
for b in bodies:
|
|
45
|
+
summary[b] = summary.get(b, 0)
|
|
46
|
+
|
|
47
|
+
# Persist
|
|
48
|
+
a.save_calibration(args.out)
|
|
49
|
+
|
|
50
|
+
# Print a compact summary
|
|
51
|
+
print(f"Saved calibration -> {args.out}")
|
|
52
|
+
for b in bodies_all:
|
|
53
|
+
n = summary.get(b, 0)
|
|
54
|
+
print(f" {b:9s} segments: {n}")
|
|
55
|
+
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == '__main__':
|
|
60
|
+
raise SystemExit(main())
|
|
61
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import pytz
|
|
9
|
+
|
|
10
|
+
import aetherfield as af # reuse host implementation during staging
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
UTC = pytz.utc
|
|
14
|
+
DE421_START = af.DE421_START
|
|
15
|
+
DE421_END = af.DE421_END
|
|
16
|
+
|
|
17
|
+
planet_eph = {
|
|
18
|
+
'jupiter': 5,
|
|
19
|
+
'saturn': 6,
|
|
20
|
+
'uranus': 7,
|
|
21
|
+
'neptune': 8,
|
|
22
|
+
'pluto': 9
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_dt(dt_str: Optional[str]) -> datetime:
|
|
27
|
+
if not dt_str:
|
|
28
|
+
return datetime.now(UTC)
|
|
29
|
+
s = dt_str.strip()
|
|
30
|
+
if s.endswith('Z'):
|
|
31
|
+
s = s[:-1]
|
|
32
|
+
dt = datetime.fromisoformat(s)
|
|
33
|
+
return dt.replace(tzinfo=UTC)
|
|
34
|
+
dt = datetime.fromisoformat(s)
|
|
35
|
+
if dt.tzinfo is None:
|
|
36
|
+
dt = dt.replace(tzinfo=UTC)
|
|
37
|
+
return dt.astimezone(UTC)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_moontime(mt_str: Optional[str]) -> Optional[datetime]:
|
|
41
|
+
if not mt_str:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
from moontime import MoonTime
|
|
45
|
+
mt = MoonTime.fromisoformat(mt_str)
|
|
46
|
+
return mt.to_datetime().astimezone(UTC)
|
|
47
|
+
except Exception:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def wrap_delta_deg(a: float, b: float) -> float:
|
|
52
|
+
d = (a - b + 540.0) % 360.0 - 180.0
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sf_ecliptic_longitude(dt: datetime, body: str) -> float:
|
|
57
|
+
from skyfield.api import load
|
|
58
|
+
from skyfield.framelib import ecliptic_frame
|
|
59
|
+
|
|
60
|
+
if dt.tzinfo is None:
|
|
61
|
+
dt = dt.replace(tzinfo=UTC)
|
|
62
|
+
dt = dt.astimezone(UTC)
|
|
63
|
+
|
|
64
|
+
eph = load('de421.bsp')
|
|
65
|
+
ts = load.timescale()
|
|
66
|
+
t = ts.from_datetime(dt)
|
|
67
|
+
earth = eph['earth']
|
|
68
|
+
b = af._body_key(eph, body) # type: ignore[attr-defined]
|
|
69
|
+
app = earth.at(t).observe(b).apparent()
|
|
70
|
+
lon, lat, dist = app.frame_latlon(ecliptic_frame)
|
|
71
|
+
return float(lon.degrees) % 360.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CompareResult:
|
|
76
|
+
body: str
|
|
77
|
+
dt: datetime
|
|
78
|
+
aether_lon: float
|
|
79
|
+
skyfield_lon: Optional[float]
|
|
80
|
+
delta_deg: Optional[float]
|
|
81
|
+
aether_sign: str
|
|
82
|
+
skyfield_sign: Optional[str]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _drift_longitude(a: af.AetherField, dt: datetime, body: str, anchor_mode: str = 'end') -> float:
|
|
86
|
+
a._ensure_anchor(body) # type: ignore[attr-defined]
|
|
87
|
+
rate = a.rates_deg_per_day.get(body)
|
|
88
|
+
if rate is None:
|
|
89
|
+
raise KeyError(f"No drift rate for body: {body}")
|
|
90
|
+
dt = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
91
|
+
dt = dt.astimezone(UTC)
|
|
92
|
+
if anchor_mode == 'nearest':
|
|
93
|
+
d_start = abs((dt - DE421_START).total_seconds())
|
|
94
|
+
d_end = abs((DE421_END - dt).total_seconds())
|
|
95
|
+
anchor_mode = 'start' if d_start < d_end else 'end'
|
|
96
|
+
if anchor_mode == 'start':
|
|
97
|
+
days = (dt - DE421_START).total_seconds() / 86400.0
|
|
98
|
+
return (a.anchors_min[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
99
|
+
else:
|
|
100
|
+
days = (dt - DE421_END).total_seconds() / 86400.0
|
|
101
|
+
return (a.anchors_max[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compare_once(body: str, dt: datetime, force_aether: bool = False, fit_rates: bool = False) -> CompareResult:
|
|
105
|
+
a = af.AetherField()
|
|
106
|
+
if fit_rates:
|
|
107
|
+
try:
|
|
108
|
+
a.fit_rates()
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
if force_aether:
|
|
112
|
+
aether_lon = _drift_longitude(a, dt, body)
|
|
113
|
+
aether_sign = af.get_zodiac_by_longitude(aether_lon)
|
|
114
|
+
else:
|
|
115
|
+
aether_lon = a.longitude(dt, body)
|
|
116
|
+
aether_sign = a.sign(dt, body)
|
|
117
|
+
sky_lon: Optional[float] = None
|
|
118
|
+
sky_sign: Optional[str] = None
|
|
119
|
+
if DE421_START <= dt <= DE421_END:
|
|
120
|
+
try:
|
|
121
|
+
sky_lon = sf_ecliptic_longitude(dt, body)
|
|
122
|
+
sky_sign = af.get_zodiac_by_longitude(sky_lon)
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
delta = wrap_delta_deg(aether_lon, sky_lon) if sky_lon is not None else None
|
|
126
|
+
return CompareResult(body, dt, aether_lon, sky_lon, delta, aether_sign, sky_sign)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv=None) -> int:
|
|
130
|
+
p = argparse.ArgumentParser(description="Compare AetherField with Skyfield for a single timestamp.")
|
|
131
|
+
p.add_argument('--body', required=True, help='Body (sun, moon, mercury, ... pluto)')
|
|
132
|
+
p.add_argument('--dt', default=None, help='ISO8601 datetime (UTC).')
|
|
133
|
+
p.add_argument('--moontime', default=None, help='MoonTime string (mt:...)')
|
|
134
|
+
p.add_argument('--force-aether', action='store_true', help='Use AetherField-only (drift).')
|
|
135
|
+
p.add_argument('--fit-rates', action='store_true', help='Fit mean drift from in-range samples.')
|
|
136
|
+
p.add_argument('--calibrate', action='store_true', help='Ensure anchors and piecewise segments (no-ops if present).')
|
|
137
|
+
p.add_argument('--save-calibration', default=None, help='Save calibration JSON path.')
|
|
138
|
+
p.add_argument('--load-calibration', default=None, help='Load calibration JSON path.')
|
|
139
|
+
p.add_argument('--drift-anchor', choices=['start','end','nearest'], default='end', help='Anchor for drift only mode.')
|
|
140
|
+
p.add_argument('--piecewise', action='store_true', help='Use piecewise segments when forcing aether.')
|
|
141
|
+
p.add_argument('--piecewise-step', type=int, default=30, help='Step in days for piecewise building.')
|
|
142
|
+
p.add_argument('--json', action='store_true', help='Emit JSON instead of text.')
|
|
143
|
+
args = p.parse_args(argv)
|
|
144
|
+
|
|
145
|
+
body = args.body.strip().lower()
|
|
146
|
+
dt = parse_moontime(args.moontime) or parse_dt(args.dt)
|
|
147
|
+
|
|
148
|
+
if args.load_calibration:
|
|
149
|
+
try:
|
|
150
|
+
a = af.AetherField.load_calibration(args.load_calibration)
|
|
151
|
+
except Exception:
|
|
152
|
+
a = af.AetherField()
|
|
153
|
+
else:
|
|
154
|
+
a = af.AetherField()
|
|
155
|
+
if args.fit_rates:
|
|
156
|
+
try:
|
|
157
|
+
a.fit_rates()
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
if args.calibrate:
|
|
161
|
+
try:
|
|
162
|
+
if args.piecewise:
|
|
163
|
+
a.fit_piecewise(step_days=int(args.piecewise_step), bodies=(body,))
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
if args.save_calibration:
|
|
167
|
+
try:
|
|
168
|
+
a.save_calibration(args.save_calibration)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
res = compare_once(body, dt, force_aether=args.force_aether, fit_rates=args.fit_rates)
|
|
173
|
+
|
|
174
|
+
if args.json:
|
|
175
|
+
import json
|
|
176
|
+
print(json.dumps({
|
|
177
|
+
'body': res.body,
|
|
178
|
+
'dt': res.dt.isoformat(),
|
|
179
|
+
'aether_lon': res.aether_lon,
|
|
180
|
+
'skyfield_lon': res.skyfield_lon,
|
|
181
|
+
'delta_deg': res.delta_deg,
|
|
182
|
+
'aether_sign': res.aether_sign,
|
|
183
|
+
'skyfield_sign': res.skyfield_sign,
|
|
184
|
+
}, indent=2))
|
|
185
|
+
else:
|
|
186
|
+
print(f"{res.body} @ {res.dt.isoformat()}\n"
|
|
187
|
+
f" Aether: {res.aether_lon:8.3f} deg ({res.aether_sign})\n"
|
|
188
|
+
f" Skyfield: {'n/a' if res.skyfield_lon is None else f'{res.skyfield_lon:8.3f} deg ('+str(res.skyfield_sign)+')'}\n"
|
|
189
|
+
f" Delta: {'n/a' if res.delta_deg is None else f'{res.delta_deg:+.3f} deg'}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == '__main__':
|
|
194
|
+
raise SystemExit(main())
|
aetherfield_pkg/core.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any, Dict, Optional, Tuple, List
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import os
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
# --- aetherfield: is_up utilities -------------------------------------------
|
|
10
|
+
import math
|
|
11
|
+
from datetime import timezone
|
|
12
|
+
from iplocal import get_ip_data
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Mean obliquity (degrees). You can make this tunable from your calibration file if you want.
|
|
16
|
+
OBLIQUITY_DEG = 23.43928
|
|
17
|
+
|
|
18
|
+
bodies = ['sun','moon','mercury','venus','mars','jupiter','saturn']
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _wrap_deg(x: float) -> float:
|
|
22
|
+
return x % 360.0
|
|
23
|
+
|
|
24
|
+
def _angdiff_deg(a: float, b: float) -> float:
|
|
25
|
+
"""Signed smallest difference a-b in (-180, +180]."""
|
|
26
|
+
return ((a - b + 180.0) % 360.0) - 180.0
|
|
27
|
+
|
|
28
|
+
def ecliptic_to_equatorial(lon_deg: float, lat_deg: float = 0.0, eps_deg: float = OBLIQUITY_DEG):
|
|
29
|
+
"""
|
|
30
|
+
Convert ecliptic (λ, β) -> equatorial (RA, Dec), all degrees.
|
|
31
|
+
Using β≈0 for planets gives a good first pass; Moon will be rough but serviceable.
|
|
32
|
+
"""
|
|
33
|
+
lam = math.radians(_wrap_deg(lon_deg))
|
|
34
|
+
beta = math.radians(lat_deg)
|
|
35
|
+
eps = math.radians(eps_deg)
|
|
36
|
+
|
|
37
|
+
sin_dec = math.sin(beta)*math.cos(eps) + math.cos(beta)*math.sin(eps)*math.sin(lam)
|
|
38
|
+
dec = math.degrees(math.asin(sin_dec))
|
|
39
|
+
|
|
40
|
+
y = math.sin(lam)*math.cos(eps) - math.tan(beta)*math.sin(eps)
|
|
41
|
+
x = math.cos(lam)
|
|
42
|
+
ra = math.degrees(math.atan2(y, x)) % 360.0
|
|
43
|
+
return ra, dec
|
|
44
|
+
|
|
45
|
+
def _julian_date(dt):
|
|
46
|
+
d = dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
47
|
+
d = d.astimezone(timezone.utc)
|
|
48
|
+
y, m = d.year, d.month
|
|
49
|
+
day = d.day + (d.hour + (d.minute + d.second/60.0)/60.0)/24.0
|
|
50
|
+
if m <= 2:
|
|
51
|
+
y -= 1; m += 12
|
|
52
|
+
A = y // 100
|
|
53
|
+
B = 2 - A + (A // 5)
|
|
54
|
+
return int(365.25*(y + 4716)) + int(30.6001*(m + 1)) + day + B - 1524.5
|
|
55
|
+
|
|
56
|
+
def _gmst_deg(dt):
|
|
57
|
+
jd = _julian_date(dt)
|
|
58
|
+
T = (jd - 2451545.0)/36525.0
|
|
59
|
+
gmst = 280.46061837 + 360.98564736629*(jd-2451545.0) + 0.000387933*T*T - (T*T*T)/38710000.0
|
|
60
|
+
return gmst % 360.0
|
|
61
|
+
|
|
62
|
+
def _lst_deg(dt, lon_deg):
|
|
63
|
+
return (_gmst_deg(dt) + lon_deg) % 360.0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
load_dotenv()
|
|
67
|
+
|
|
68
|
+
import pytz
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
from skyfield.api import load
|
|
72
|
+
from skyfield.framelib import ecliptic_frame
|
|
73
|
+
SKYFIELD_OK = True
|
|
74
|
+
except Exception:
|
|
75
|
+
SKYFIELD_OK = False
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
# Reuse existing helper for sign segmentation if present
|
|
79
|
+
from skyfieldcomm import get_zodiac_by_longitude
|
|
80
|
+
except Exception:
|
|
81
|
+
def get_zodiac_by_longitude(longitude: float) -> str:
|
|
82
|
+
signs = [
|
|
83
|
+
"Aries", "Taurus", "Gemini", "Cancer", "Leo",
|
|
84
|
+
"Virgo", "Libra", "Scorpio", "Sagittarius",
|
|
85
|
+
"Capricorn", "Aquarius", "Pisces"
|
|
86
|
+
]
|
|
87
|
+
i = int(longitude // 30) % 12
|
|
88
|
+
return signs[i]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
UTC = pytz.utc
|
|
92
|
+
DE421_START = datetime(1951, 1, 1, tzinfo=UTC)
|
|
93
|
+
DE421_END = datetime(2050, 12, 31, 23, 59, 59, tzinfo=UTC)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Mean sidereal/orbital motion in degrees per day (approximate)
|
|
97
|
+
# Sources: standard planetary orbital periods; Moon uses ~27.321661 d (sidereal)
|
|
98
|
+
MEAN_DEG_PER_DAY: Dict[str, float] = {
|
|
99
|
+
"sun": 360.0 / 365.2422, # ~0.985647
|
|
100
|
+
"moon": 360.0 / 27.321661, # ~13.176358
|
|
101
|
+
"mercury": 360.0 / 87.9691, # ~4.092334
|
|
102
|
+
"venus": 360.0 / 224.701, # ~1.602130
|
|
103
|
+
"mars": 360.0 / 686.980, # ~0.524039
|
|
104
|
+
"jupiter": 360.0 / 4332.589, # ~0.083129
|
|
105
|
+
"saturn": 360.0 / 10759.22, # ~0.033497
|
|
106
|
+
"uranus": 360.0 / 30688.5, # ~0.011749
|
|
107
|
+
"neptune": 360.0 / 60182.0, # ~0.005981
|
|
108
|
+
"pluto": 360.0 / 90560.0, # ~0.003977
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
planet_eph = {
|
|
112
|
+
'jupiter': 5,
|
|
113
|
+
'saturn': 6,
|
|
114
|
+
'uranus': 7,
|
|
115
|
+
'neptune': 8,
|
|
116
|
+
'pluto': 9
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
PHASE_NAMES = [
|
|
120
|
+
"New", "Waxing Crescent", "First Quarter", "Waxing Gibbous",
|
|
121
|
+
"Full", "Waning Gibbous", "Last Quarter", "Waning Crescent"
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def _resolve_cal_path(path: str) -> Path:
|
|
125
|
+
p = Path(path)
|
|
126
|
+
if p.is_file():
|
|
127
|
+
return p
|
|
128
|
+
# also try alongside this file
|
|
129
|
+
here = Path(__file__).resolve().parent
|
|
130
|
+
p2 = here.parent.parent.parent / path # repo root relative
|
|
131
|
+
return p2 if p2.is_file() else Path(path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _in_de421(dt: datetime) -> bool:
|
|
135
|
+
dt_utc = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
136
|
+
dt_utc = dt_utc.astimezone(UTC)
|
|
137
|
+
return DE421_START <= dt_utc <= DE421_END
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _as_datetime(dt_or_mt: Any) -> datetime:
|
|
141
|
+
"""Accept a datetime or a MoonTime-like object with to_datetime()."""
|
|
142
|
+
if isinstance(dt_or_mt, datetime):
|
|
143
|
+
return dt_or_mt
|
|
144
|
+
to_dt = getattr(dt_or_mt, 'to_datetime', None)
|
|
145
|
+
if callable(to_dt):
|
|
146
|
+
dt = to_dt()
|
|
147
|
+
if not isinstance(dt, datetime):
|
|
148
|
+
raise TypeError("to_datetime() did not return a datetime")
|
|
149
|
+
return dt
|
|
150
|
+
raise TypeError("Expected datetime or MoonTime-like object with to_datetime()")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _body_key(eph, name: str):
|
|
154
|
+
"""Find a usable ephemeris key for a body name."""
|
|
155
|
+
candidates = [
|
|
156
|
+
name,
|
|
157
|
+
name.lower(),
|
|
158
|
+
name.capitalize(),
|
|
159
|
+
f"{name} barycenter",
|
|
160
|
+
f"{name.capitalize()} BARYCENTER",
|
|
161
|
+
]
|
|
162
|
+
for c in candidates:
|
|
163
|
+
if c in eph:
|
|
164
|
+
return eph[c]
|
|
165
|
+
# Fallback for numbered outer planets (de421 indexes)
|
|
166
|
+
idx = {
|
|
167
|
+
"jupiter": 5,
|
|
168
|
+
"saturn": 6,
|
|
169
|
+
"uranus": 7,
|
|
170
|
+
"neptune": 8,
|
|
171
|
+
"pluto": 9,
|
|
172
|
+
}.get(name)
|
|
173
|
+
if idx is not None:
|
|
174
|
+
try:
|
|
175
|
+
return eph[idx]
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
raise KeyError(f"Body key not found for: {name}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _ecliptic_longitude_skyfield(dt: datetime, body: str) -> float:
|
|
182
|
+
if not SKYFIELD_OK:
|
|
183
|
+
raise RuntimeError("Skyfield not available for anchor computation")
|
|
184
|
+
# Cache ephemeris and timescale to avoid reloading for every call
|
|
185
|
+
global _SF_EPH, _SF_TS
|
|
186
|
+
try:
|
|
187
|
+
_SF_EPH
|
|
188
|
+
except NameError:
|
|
189
|
+
_SF_EPH = None # type: ignore[var-annotated]
|
|
190
|
+
_SF_TS = None # type: ignore[var-annotated]
|
|
191
|
+
if _SF_EPH is None or _SF_TS is None:
|
|
192
|
+
_SF_EPH = load('de421.bsp')
|
|
193
|
+
_SF_TS = load.timescale()
|
|
194
|
+
eph = _SF_EPH
|
|
195
|
+
ts = _SF_TS
|
|
196
|
+
earth = eph['earth']
|
|
197
|
+
b = _body_key(eph, body)
|
|
198
|
+
if dt.tzinfo is None:
|
|
199
|
+
dt = dt.replace(tzinfo=UTC)
|
|
200
|
+
t = ts.from_datetime(dt.astimezone(UTC))
|
|
201
|
+
app = earth.at(t).observe(b).apparent()
|
|
202
|
+
lon, lat, dist = app.frame_latlon(ecliptic_frame)
|
|
203
|
+
return float(lon.degrees) % 360.0
|
|
204
|
+
|
|
205
|
+
def fetch_celestial_data(time=None, world='venus', home='earth'):
|
|
206
|
+
if not SKYFIELD_OK:
|
|
207
|
+
raise RuntimeError("Skyfield not available for anchor computation")
|
|
208
|
+
# Determines the alignment of world in the zodiac from home
|
|
209
|
+
world = planet_eph.get(world, world)
|
|
210
|
+
home = planet_eph.get(home, home)
|
|
211
|
+
|
|
212
|
+
ts = load.timescale()
|
|
213
|
+
t = ts.now() if time is None else ts.from_datetime(time)
|
|
214
|
+
sanitized_time = t.utc_datetime().strftime("%Y%m%d%H%M%S")
|
|
215
|
+
if time is None:
|
|
216
|
+
time = datetime.now(timezone.utc)
|
|
217
|
+
|
|
218
|
+
coarse_model = 'de421.bsp'
|
|
219
|
+
fine_model = 'de430t.bsp'
|
|
220
|
+
planets = load(coarse_model)
|
|
221
|
+
earth = planets[home]
|
|
222
|
+
sun = planets[world]
|
|
223
|
+
|
|
224
|
+
astrometric_sun = earth.at(t).observe(sun)
|
|
225
|
+
ra_sun, dec_sun, distance_sun = astrometric_sun.radec()
|
|
226
|
+
ra_deg = ra_sun.hours * 15
|
|
227
|
+
return ra_deg
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _days_between(a: datetime, b: datetime) -> float:
|
|
231
|
+
return (b - a).total_seconds() / 86400.0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class DriftSegment:
|
|
236
|
+
"""Linearized drift segment derived from Skyfield samples."""
|
|
237
|
+
start: datetime
|
|
238
|
+
end: datetime
|
|
239
|
+
lon0_unwrapped: float
|
|
240
|
+
slope_deg_per_day: float
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class AetherField:
|
|
245
|
+
"""
|
|
246
|
+
Approximate celestial positions beyond ephemeris bounds.
|
|
247
|
+
|
|
248
|
+
Strategy:
|
|
249
|
+
- Within de421 range: prefer Skyfield (if available).
|
|
250
|
+
- Beyond range: use anchor longitude at the nearest boundary and advance
|
|
251
|
+
using a mean drift rate (deg/day). Optional: rates can be re-fitted from
|
|
252
|
+
Skyfield across a window inside the valid range.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
rates_deg_per_day: Dict[str, float] = None
|
|
256
|
+
anchors_min: Dict[str, float] = None # longitudes at DE421_START
|
|
257
|
+
anchors_max: Dict[str, float] = None # longitudes at DE421_END
|
|
258
|
+
piecewise: Dict[str, List[DriftSegment]] = None # per-body linear segments inside de421
|
|
259
|
+
|
|
260
|
+
def __post_init__(self):
|
|
261
|
+
if self.rates_deg_per_day is None:
|
|
262
|
+
self.rates_deg_per_day = dict(MEAN_DEG_PER_DAY)
|
|
263
|
+
self.anchors_min = self.anchors_min or {}
|
|
264
|
+
self.anchors_max = self.anchors_max or {}
|
|
265
|
+
self.piecewise = self.piecewise or {}
|
|
266
|
+
|
|
267
|
+
def _ensure_anchor(self, body: str, use_skyfield: bool = False):
|
|
268
|
+
if body in self.anchors_min and body in self.anchors_max:
|
|
269
|
+
return
|
|
270
|
+
if use_skyfield and SKYFIELD_OK:
|
|
271
|
+
try:
|
|
272
|
+
self.anchors_min.setdefault(body, fetch_celestial_data(DE421_START, body))
|
|
273
|
+
except Exception:
|
|
274
|
+
self.anchors_min.setdefault(body, 0.0)
|
|
275
|
+
try:
|
|
276
|
+
self.anchors_max.setdefault(body, fetch_celestial_data(DE421_END, body))
|
|
277
|
+
except Exception:
|
|
278
|
+
self.anchors_max.setdefault(body, self.anchors_min.get(body, 0.0))
|
|
279
|
+
return
|
|
280
|
+
# Runtime fallback: avoid Skyfield; default to zeros if missing
|
|
281
|
+
self.anchors_min.setdefault(body, 0.0)
|
|
282
|
+
self.anchors_max.setdefault(body, 0.0)
|
|
283
|
+
|
|
284
|
+
def longitude(self, dt: Any, body: str) -> float:
|
|
285
|
+
dt = _as_datetime(dt)
|
|
286
|
+
dt = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
287
|
+
# Runtime: do not depend on Skyfield; prefer piecewise when available
|
|
288
|
+
segs = self.piecewise.get(body) if self.piecewise else None
|
|
289
|
+
if segs:
|
|
290
|
+
return self.longitude_piecewise(dt, body)
|
|
291
|
+
|
|
292
|
+
# Extrapolate from the nearest boundary
|
|
293
|
+
self._ensure_anchor(body)
|
|
294
|
+
rate = self.rates_deg_per_day.get(body)
|
|
295
|
+
if rate is None:
|
|
296
|
+
raise KeyError(f"No drift rate for body: {body}")
|
|
297
|
+
|
|
298
|
+
if dt < DE421_START:
|
|
299
|
+
days = _days_between(dt, DE421_START)
|
|
300
|
+
# going backward in time -> subtract motion
|
|
301
|
+
lon = (self.anchors_min[body] - rate * days) % 360.0
|
|
302
|
+
else:
|
|
303
|
+
days = _days_between(DE421_END, dt)
|
|
304
|
+
lon = (self.anchors_max[body] + rate * days) % 360.0
|
|
305
|
+
return lon
|
|
306
|
+
|
|
307
|
+
# --- Piecewise drift support ---
|
|
308
|
+
def fit_piecewise(self, step_days: int = 30, bodies: Optional[Tuple[str, ...]] = None) -> Dict[str, int]:
|
|
309
|
+
"""
|
|
310
|
+
Build per-body linear drift segments across the de421 window by sampling
|
|
311
|
+
Skyfield at a regular cadence. Each consecutive pair of samples defines
|
|
312
|
+
a segment with a local slope (deg/day) and an unwrapped base longitude.
|
|
313
|
+
|
|
314
|
+
Returns a dict mapping body -> number of segments created.
|
|
315
|
+
"""
|
|
316
|
+
if not SKYFIELD_OK:
|
|
317
|
+
return {b: 0 for b in (bodies or tuple(self.rates_deg_per_day.keys()))}
|
|
318
|
+
|
|
319
|
+
start = DE421_START
|
|
320
|
+
end = DE421_END
|
|
321
|
+
step_days = max(1, int(step_days))
|
|
322
|
+
bodies = bodies or tuple(self.rates_deg_per_day.keys())
|
|
323
|
+
|
|
324
|
+
# Build uniform sample grid inclusive of end
|
|
325
|
+
ts: List[datetime] = []
|
|
326
|
+
t = start
|
|
327
|
+
while t < end:
|
|
328
|
+
ts.append(t)
|
|
329
|
+
t = t + timedelta(days=step_days)
|
|
330
|
+
ts.append(end)
|
|
331
|
+
|
|
332
|
+
created: Dict[str, int] = {}
|
|
333
|
+
for b in bodies:
|
|
334
|
+
# Gather longitudes and unwrap
|
|
335
|
+
longs = [fetch_celestial_data(ti, b) for ti in ts]
|
|
336
|
+
unwrapped = [longs[0]]
|
|
337
|
+
for i in range(1, len(longs)):
|
|
338
|
+
prev = unwrapped[-1]
|
|
339
|
+
cur = longs[i]
|
|
340
|
+
delta = (cur - prev + 540.0) % 360.0 - 180.0
|
|
341
|
+
unwrapped.append(prev + delta)
|
|
342
|
+
|
|
343
|
+
segs: List[DriftSegment] = []
|
|
344
|
+
for i in range(len(ts) - 1):
|
|
345
|
+
t0, t1 = ts[i], ts[i + 1]
|
|
346
|
+
d_days = (t1 - t0).total_seconds() / 86400.0
|
|
347
|
+
if d_days <= 0:
|
|
348
|
+
continue
|
|
349
|
+
slope = (unwrapped[i + 1] - unwrapped[i]) / d_days
|
|
350
|
+
segs.append(DriftSegment(start=t0, end=t1, lon0_unwrapped=unwrapped[i], slope_deg_per_day=slope))
|
|
351
|
+
|
|
352
|
+
self.piecewise[b] = segs
|
|
353
|
+
created[b] = len(segs)
|
|
354
|
+
|
|
355
|
+
return created
|
|
356
|
+
|
|
357
|
+
def longitude_piecewise(self, dt: Any, body: str, anchor_mode: str = 'end') -> float:
|
|
358
|
+
"""
|
|
359
|
+
Estimate ecliptic longitude using piecewise linear drift if available.
|
|
360
|
+
- Inside de421: use the segment covering dt (linearized Skyfield).
|
|
361
|
+
- Outside de421: extend from the nearest boundary using the boundary segment slope.
|
|
362
|
+
|
|
363
|
+
anchor_mode: 'start' | 'end' | 'nearest' — only used outside-range.
|
|
364
|
+
"""
|
|
365
|
+
dt = _as_datetime(dt)
|
|
366
|
+
dt = dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
|
367
|
+
segs = self.piecewise.get(body)
|
|
368
|
+
if not segs:
|
|
369
|
+
# No segments; fallback to simple model
|
|
370
|
+
return self.longitude(dt, body)
|
|
371
|
+
|
|
372
|
+
# Inside range: choose segment spanning dt
|
|
373
|
+
if DE421_START <= dt <= DE421_END:
|
|
374
|
+
for s in segs:
|
|
375
|
+
if s.start <= dt <= s.end:
|
|
376
|
+
days = (dt - s.start).total_seconds() / 86400.0
|
|
377
|
+
return (s.lon0_unwrapped + s.slope_deg_per_day * days) % 360.0
|
|
378
|
+
# Edge case: dt at exact end
|
|
379
|
+
s = segs[-1]
|
|
380
|
+
days = (dt - s.start).total_seconds() / 86400.0
|
|
381
|
+
return (s.lon0_unwrapped + s.slope_deg_per_day * days) % 360.0
|
|
382
|
+
|
|
383
|
+
# Outside range: pick boundary and extend with boundary slope
|
|
384
|
+
if anchor_mode == 'nearest':
|
|
385
|
+
d_start = abs((dt - DE421_START).total_seconds())
|
|
386
|
+
d_end = abs((DE421_END - dt).total_seconds())
|
|
387
|
+
anchor_mode = 'start' if d_start < d_end else 'end'
|
|
388
|
+
|
|
389
|
+
if anchor_mode == 'start':
|
|
390
|
+
s0 = next((s for s in segs if s.start == DE421_START), segs[0])
|
|
391
|
+
days = _days_between(DE421_START, dt)
|
|
392
|
+
return (s0.lon0_unwrapped + s0.slope_deg_per_day * days) % 360.0
|
|
393
|
+
else:
|
|
394
|
+
s1 = next((s for s in segs if s.end == DE421_END), segs[-1])
|
|
395
|
+
days = _days_between(DE421_END, dt)
|
|
396
|
+
return (s1.lon0_unwrapped + s1.slope_deg_per_day * days) % 360.0
|
|
397
|
+
|
|
398
|
+
def sign(self, dt: Any, body: str) -> str:
|
|
399
|
+
lon = self.longitude(dt, body)
|
|
400
|
+
return get_zodiac_by_longitude(lon)
|
|
401
|
+
|
|
402
|
+
def alignments(self, dt: Any) -> Dict[str, str]:
|
|
403
|
+
bodies = [
|
|
404
|
+
'sun', 'moon', 'mercury', 'venus', 'mars',
|
|
405
|
+
'jupiter', 'saturn', 'uranus', 'neptune', 'pluto',
|
|
406
|
+
]
|
|
407
|
+
return {b: self.sign(dt, b) for b in bodies}
|
|
408
|
+
|
|
409
|
+
def fit_rates(self, window_days: int = 365, step_days: int = 30) -> Dict[str, float]:
|
|
410
|
+
"""
|
|
411
|
+
Re-estimate mean drift rates using Skyfield inside the valid range by
|
|
412
|
+
sampling over a window and computing average slope.
|
|
413
|
+
Returns the updated rates dict.
|
|
414
|
+
"""
|
|
415
|
+
if not SKYFIELD_OK:
|
|
416
|
+
return self.rates_deg_per_day
|
|
417
|
+
|
|
418
|
+
start = max(DE421_START, datetime(2000, 1, 1, tzinfo=UTC))
|
|
419
|
+
bodies = list(self.rates_deg_per_day.keys())
|
|
420
|
+
ts = [start + timedelta(days=i) for i in range(0, window_days + 1, max(1, step_days))]
|
|
421
|
+
|
|
422
|
+
new_rates: Dict[str, float] = {}
|
|
423
|
+
for b in bodies:
|
|
424
|
+
# Use Skyfield samples directly for calibration
|
|
425
|
+
longs = [fetch_celestial_data(t, b) for t in ts]
|
|
426
|
+
# Unwrap angles to avoid 360 jumps
|
|
427
|
+
unwrapped = [longs[0]]
|
|
428
|
+
for i in range(1, len(longs)):
|
|
429
|
+
prev = unwrapped[-1]
|
|
430
|
+
cur = longs[i]
|
|
431
|
+
delta = (cur - prev + 540.0) % 360.0 - 180.0
|
|
432
|
+
unwrapped.append(prev + delta)
|
|
433
|
+
total_days = (ts[-1] - ts[0]).total_seconds() / 86400.0
|
|
434
|
+
slope = (unwrapped[-1] - unwrapped[0]) / total_days if total_days else self.rates_deg_per_day[b]
|
|
435
|
+
new_rates[b] = slope
|
|
436
|
+
self.rates_deg_per_day.update(new_rates)
|
|
437
|
+
return self.rates_deg_per_day
|
|
438
|
+
|
|
439
|
+
# --- Calibration helpers ---
|
|
440
|
+
def calibrate(self, window_days: int = 365, step_days: int = 30, bodies: Optional[Tuple[str, ...]] = None, piecewise: bool = False) -> Dict[str, Dict[str, float]]:
|
|
441
|
+
"""
|
|
442
|
+
Learn from Skyfield within de421 by anchoring and re-fitting drift rates.
|
|
443
|
+
|
|
444
|
+
- Ensures anchors at both de421 boundaries for the selected bodies.
|
|
445
|
+
- Fits average drift rates using in-range Skyfield samples.
|
|
446
|
+
|
|
447
|
+
Returns a summary dict with 'rates', 'anchors_min', 'anchors_max'.
|
|
448
|
+
"""
|
|
449
|
+
bodies = bodies or tuple(self.rates_deg_per_day.keys())
|
|
450
|
+
for b in bodies:
|
|
451
|
+
self._ensure_anchor(b, use_skyfield=True)
|
|
452
|
+
self.fit_rates(window_days=window_days, step_days=step_days)
|
|
453
|
+
if piecewise:
|
|
454
|
+
self.fit_piecewise(step_days=step_days, bodies=bodies)
|
|
455
|
+
return {
|
|
456
|
+
'rates': dict(self.rates_deg_per_day),
|
|
457
|
+
'anchors_min': dict(self.anchors_min),
|
|
458
|
+
'anchors_max': dict(self.anchors_max),
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def save_calibration(self, path: str) -> None:
|
|
462
|
+
"""Persist current rates, anchors, and piecewise segments to JSON."""
|
|
463
|
+
pw = {}
|
|
464
|
+
for b, segs in (self.piecewise or {}).items():
|
|
465
|
+
pw[b] = [
|
|
466
|
+
{
|
|
467
|
+
'start': s.start.isoformat(),
|
|
468
|
+
'end': s.end.isoformat(),
|
|
469
|
+
'lon0_unwrapped': s.lon0_unwrapped,
|
|
470
|
+
'slope_deg_per_day': s.slope_deg_per_day,
|
|
471
|
+
}
|
|
472
|
+
for s in segs
|
|
473
|
+
]
|
|
474
|
+
payload = {
|
|
475
|
+
'rates_deg_per_day': self.rates_deg_per_day,
|
|
476
|
+
'anchors_min': self.anchors_min,
|
|
477
|
+
'anchors_max': self.anchors_max,
|
|
478
|
+
'de421_start': DE421_START.isoformat(),
|
|
479
|
+
'de421_end': DE421_END.isoformat(),
|
|
480
|
+
'piecewise': pw,
|
|
481
|
+
}
|
|
482
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
483
|
+
json.dump(payload, f, indent=2, sort_keys=True)
|
|
484
|
+
|
|
485
|
+
@classmethod
|
|
486
|
+
def load_calibration(cls, path: str) -> 'AetherField':
|
|
487
|
+
"""Load rates, anchors, and piecewise segments from a JSON file."""
|
|
488
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
489
|
+
data = json.load(f)
|
|
490
|
+
rates = data.get('rates_deg_per_day') or data.get('rates') or {}
|
|
491
|
+
anchors_min = data.get('anchors_min') or {}
|
|
492
|
+
anchors_max = data.get('anchors_max') or {}
|
|
493
|
+
inst = cls(rates_deg_per_day=rates, anchors_min=anchors_min, anchors_max=anchors_max)
|
|
494
|
+
piecewise_data = data.get('piecewise') or {}
|
|
495
|
+
pw: Dict[str, List[DriftSegment]] = {}
|
|
496
|
+
for b, segs in piecewise_data.items():
|
|
497
|
+
parsed: List[DriftSegment] = []
|
|
498
|
+
for s in segs:
|
|
499
|
+
try:
|
|
500
|
+
parsed.append(
|
|
501
|
+
DriftSegment(
|
|
502
|
+
start=datetime.fromisoformat(s['start']).astimezone(UTC),
|
|
503
|
+
end=datetime.fromisoformat(s['end']).astimezone(UTC),
|
|
504
|
+
lon0_unwrapped=float(s['lon0_unwrapped']),
|
|
505
|
+
slope_deg_per_day=float(s['slope_deg_per_day']),
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
except Exception:
|
|
509
|
+
continue
|
|
510
|
+
pw[b] = parsed
|
|
511
|
+
inst.piecewise = pw
|
|
512
|
+
return inst
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# Convenience singleton
|
|
516
|
+
_GLOBAL_AETHER = AetherField()
|
|
517
|
+
_CAL_LOADED = False
|
|
518
|
+
if not _CAL_LOADED:
|
|
519
|
+
# allow override via env var if you want
|
|
520
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
521
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
522
|
+
_CAL_LOADED = True
|
|
523
|
+
|
|
524
|
+
def aether_alignments(dt: Optional[Any] = None) -> Dict[str, str]:
|
|
525
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
526
|
+
if not _CAL_LOADED:
|
|
527
|
+
# allow override via env var if you want
|
|
528
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
529
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
530
|
+
_CAL_LOADED = True
|
|
531
|
+
|
|
532
|
+
if dt is None:
|
|
533
|
+
dt = datetime.now(UTC)
|
|
534
|
+
return _GLOBAL_AETHER.alignments(dt)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def aether_longitude(dt: Any, body: str) -> float:
|
|
538
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
539
|
+
if not _CAL_LOADED:
|
|
540
|
+
# allow override via env var if you want
|
|
541
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
542
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
543
|
+
_CAL_LOADED = True
|
|
544
|
+
|
|
545
|
+
return _GLOBAL_AETHER.longitude(dt, body)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def aether_sign(dt: Any, body: str) -> str:
|
|
549
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
550
|
+
if not _CAL_LOADED:
|
|
551
|
+
# allow override via env var if you want
|
|
552
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
553
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
554
|
+
_CAL_LOADED = True
|
|
555
|
+
|
|
556
|
+
return _GLOBAL_AETHER.sign(dt, body)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# MoonTime-specific convenience wrappers (duck-typed; no hard dependency)
|
|
560
|
+
def aether_longitude_mt(mt: Any, body: str) -> float:
|
|
561
|
+
return aether_longitude(mt, body)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def aether_sign_mt(mt: Any, body: str) -> str:
|
|
565
|
+
return aether_sign(mt, body)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def aether_alignments_mt(mt: Any) -> Dict[str, str]:
|
|
569
|
+
return aether_alignments(mt)
|
|
570
|
+
|
|
571
|
+
def aetherium_longitude_mt(mt: Any, body: str) -> float:
|
|
572
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
573
|
+
if _GLOBAL_AETHER is None:
|
|
574
|
+
_GLOBAL_AETHER = AetherField() # base instance
|
|
575
|
+
|
|
576
|
+
if not _CAL_LOADED:
|
|
577
|
+
# allow override via env var if you want
|
|
578
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
579
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
580
|
+
_CAL_LOADED = True
|
|
581
|
+
|
|
582
|
+
return _GLOBAL_AETHER.longitude(mt, body)
|
|
583
|
+
|
|
584
|
+
def moon_phase(dt: Any):
|
|
585
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
586
|
+
|
|
587
|
+
"""
|
|
588
|
+
Returns:
|
|
589
|
+
idx: int in [0..7] (0 = New, 4 = Full)
|
|
590
|
+
info: dict with 'name', 'angle_deg', 'illum'
|
|
591
|
+
"""
|
|
592
|
+
# normalize input -> timezone-aware UTC
|
|
593
|
+
d = _as_datetime(dt)
|
|
594
|
+
d = d if d.tzinfo else d.replace(tzinfo=UTC)
|
|
595
|
+
if _GLOBAL_AETHER is None:
|
|
596
|
+
_GLOBAL_AETHER = AetherField() # base instance
|
|
597
|
+
|
|
598
|
+
if not _CAL_LOADED:
|
|
599
|
+
# allow override via env var if you want
|
|
600
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
601
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
602
|
+
_CAL_LOADED = True
|
|
603
|
+
|
|
604
|
+
# use AetherField's longitudes (already calibrated / piecewise-capable)
|
|
605
|
+
lon_moon = _GLOBAL_AETHER.longitude(d, "moon")
|
|
606
|
+
lon_sun = _GLOBAL_AETHER.longitude(d, "sun")
|
|
607
|
+
|
|
608
|
+
# Moon-Sun elongation (0=new, 180=full)
|
|
609
|
+
elong = (lon_moon - lon_sun) % 360.0
|
|
610
|
+
|
|
611
|
+
# map to 8 bins centered every 45°
|
|
612
|
+
idx = int(((elong + 22.5) % 360.0) // 45)
|
|
613
|
+
|
|
614
|
+
# physical illumination (0..1), useful for UI
|
|
615
|
+
illum = 0.5 * (1.0 - math.cos(math.radians(elong)))
|
|
616
|
+
illum_pct = int(round(max(0.0, min(1.0, float(illum))) * 100))
|
|
617
|
+
angle_deg = float(elong) % 360
|
|
618
|
+
|
|
619
|
+
return idx, {
|
|
620
|
+
"name": PHASE_NAMES[idx],
|
|
621
|
+
"angle_deg": angle_deg,
|
|
622
|
+
"illum": illum_pct,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# --- Sunrise/Sunset estimation (no Skyfield) ---------------------------------
|
|
627
|
+
def _get_pytz_timezone(zone):
|
|
628
|
+
try:
|
|
629
|
+
if hasattr(zone, "localize"):
|
|
630
|
+
return zone
|
|
631
|
+
if isinstance(zone, str) and zone:
|
|
632
|
+
return pytz.timezone(zone)
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
return UTC
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def sunrise_sunset(zone, coords: str, date=None, depression_deg: float = -0.833):
|
|
639
|
+
"""
|
|
640
|
+
Estimate local sunrise and sunset using AetherField's solar longitude.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
zone: IANA tz string or pytz tzinfo
|
|
644
|
+
coords: "lat,lon" (degrees; east-positive longitude)
|
|
645
|
+
date: datetime.date (local) or None for today
|
|
646
|
+
depression_deg: apparent altitude at sunrise/set (default -0.833°)
|
|
647
|
+
|
|
648
|
+
Returns:
|
|
649
|
+
(sunrise_dt, sunset_dt) as timezone-aware datetimes in `zone`.
|
|
650
|
+
|
|
651
|
+
Notes:
|
|
652
|
+
- Uses a simple equation-of-time approximation and ignores refraction dynamics
|
|
653
|
+
beyond the altitude constant. Good as a Skyfield-free fallback.
|
|
654
|
+
"""
|
|
655
|
+
tz = _get_pytz_timezone(zone)
|
|
656
|
+
try:
|
|
657
|
+
lat_str, lon_str = str(coords).replace(" ", "").split(",", 1)
|
|
658
|
+
lat_deg = float(lat_str)
|
|
659
|
+
lon_deg = float(lon_str)
|
|
660
|
+
except Exception as exc:
|
|
661
|
+
raise ValueError(f"Invalid coords format, expected 'lat,lon': {coords}") from exc
|
|
662
|
+
|
|
663
|
+
# Local base day
|
|
664
|
+
from datetime import date as _date
|
|
665
|
+
if date is None:
|
|
666
|
+
base_day = _date.today()
|
|
667
|
+
else:
|
|
668
|
+
base_day = date if isinstance(date, _date) else date.date()
|
|
669
|
+
|
|
670
|
+
# Local solar noon estimate using Equation of Time (EoT)
|
|
671
|
+
# Day of year
|
|
672
|
+
n = int((_date(base_day.year, base_day.month, base_day.day) - _date(base_day.year, 1, 1)).days) + 1
|
|
673
|
+
B = math.radians(360.0 * (n - 81) / 364.0)
|
|
674
|
+
eot_min = 9.87 * math.sin(2 * B) - 7.53 * math.cos(B) - 1.5 * math.sin(B)
|
|
675
|
+
|
|
676
|
+
# Timezone offset (hours) at local noon
|
|
677
|
+
local_noon_base = tz.localize(datetime(base_day.year, base_day.month, base_day.day, 12, 0, 0))
|
|
678
|
+
tz_offset_hours = (local_noon_base.utcoffset() or timedelta(0)).total_seconds() / 3600.0
|
|
679
|
+
lstm = 15.0 * tz_offset_hours
|
|
680
|
+
offset_min = eot_min + 4.0 * (lon_deg - lstm)
|
|
681
|
+
local_solar_noon = local_noon_base - timedelta(minutes=offset_min)
|
|
682
|
+
|
|
683
|
+
# Solar declination from AetherField solar longitude (at solar noon)
|
|
684
|
+
lam = aether_longitude(local_solar_noon, "sun") # degrees
|
|
685
|
+
# Convert to declination via obliquity
|
|
686
|
+
_, dec = ecliptic_to_equatorial(lam, 0.0, OBLIQUITY_DEG)
|
|
687
|
+
|
|
688
|
+
# Hour angle at apparent sunrise/sunset
|
|
689
|
+
h0 = math.radians(depression_deg)
|
|
690
|
+
phi = math.radians(lat_deg)
|
|
691
|
+
sd = math.sin(math.radians(dec))
|
|
692
|
+
cd = math.cos(math.radians(dec))
|
|
693
|
+
cosH0 = (math.sin(h0) - math.sin(phi) * sd) / (math.cos(phi) * cd)
|
|
694
|
+
# Handle polar day/night
|
|
695
|
+
if cosH0 > 1.0:
|
|
696
|
+
# Sun always below horizon
|
|
697
|
+
return None, None
|
|
698
|
+
if cosH0 < -1.0:
|
|
699
|
+
# Sun always above horizon
|
|
700
|
+
return None, None
|
|
701
|
+
H0 = math.degrees(math.acos(max(-1.0, min(1.0, cosH0)))) # degrees
|
|
702
|
+
daylen_hours = 2.0 * (H0 / 15.0)
|
|
703
|
+
half = timedelta(hours=daylen_hours / 2.0)
|
|
704
|
+
sunrise = local_solar_noon - half
|
|
705
|
+
sunset = local_solar_noon + half
|
|
706
|
+
return sunrise, sunset
|
|
707
|
+
|
|
708
|
+
def ae_is_up(dt, body: str, coords: (float, float) = None, method: str = "full", min_alt_deg: float = 0.0):
|
|
709
|
+
global _GLOBAL_AETHER, _CAL_LOADED
|
|
710
|
+
|
|
711
|
+
"""
|
|
712
|
+
Determine whether `body` is above the horizon at (lat, lon) at time `dt`.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
dt: datetime or MoonTime; timezone-naive treated as UTC.
|
|
716
|
+
body: 'sun','moon','mars', etc. (uses your calibrated longitudes)
|
|
717
|
+
lat_deg/lon_deg: observer geodetic latitude/longitude (east positive)
|
|
718
|
+
method: 'clock' -> fast hemisphere test (no latitude), or 'full' -> altitude calc
|
|
719
|
+
min_alt_deg: require altitude > min_alt_deg (e.g. set 5.0 to ignore murky horizon)
|
|
720
|
+
|
|
721
|
+
Returns:
|
|
722
|
+
(up_bool, details_dict)
|
|
723
|
+
"""
|
|
724
|
+
if not coords:
|
|
725
|
+
coords, zone, tz = get_ip_data()
|
|
726
|
+
d = _as_datetime(dt)
|
|
727
|
+
d = d if d.tzinfo else d.replace(tzinfo=UTC)
|
|
728
|
+
if _GLOBAL_AETHER is None:
|
|
729
|
+
_GLOBAL_AETHER = AetherField() # base instance
|
|
730
|
+
|
|
731
|
+
if not _CAL_LOADED:
|
|
732
|
+
# allow override via env var if you want
|
|
733
|
+
cal_path = os.getenv("AETHER_CAL_FILE", "aetherfield_calibration.json")
|
|
734
|
+
_GLOBAL_AETHER = AetherField.load_calibration(str(_resolve_cal_path(cal_path)))
|
|
735
|
+
_CAL_LOADED = True
|
|
736
|
+
lat_deg, lon_deg = map(float, coords.split(','))
|
|
737
|
+
|
|
738
|
+
# Ecliptic longitudes from your model
|
|
739
|
+
lon_body = _GLOBAL_AETHER.longitude(d, body)
|
|
740
|
+
#lon_sun = _GLOBAL_AETHER.longitude(d, "sun")
|
|
741
|
+
|
|
742
|
+
# Approx equatorial coordinates (β≈0)
|
|
743
|
+
ra_body, dec_body = ecliptic_to_equatorial(lon_body, 0.0)
|
|
744
|
+
# (We compute RA_sun/Dec_sun only if you want it in details)
|
|
745
|
+
#ra_sun, dec_sun = ecliptic_to_equatorial(lon_sun, 0.0)
|
|
746
|
+
|
|
747
|
+
LST = _lst_deg(d, lon_deg)
|
|
748
|
+
|
|
749
|
+
if method == "clock":
|
|
750
|
+
# Object is considered up if within 6h of the local meridian.
|
|
751
|
+
delta = abs(_angdiff_deg(ra_body, LST))
|
|
752
|
+
up = (delta <= 90.0)
|
|
753
|
+
return up, {
|
|
754
|
+
"scheme": "clock",
|
|
755
|
+
"lst_deg": LST,
|
|
756
|
+
"ra_deg": ra_body,
|
|
757
|
+
"dec_deg": dec_body,
|
|
758
|
+
"delta_meridian_deg": delta,
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
# FULL altitude: sin a = sin φ sin δ + cos φ cos δ cos H
|
|
762
|
+
phi = math.radians(lat_deg)
|
|
763
|
+
dec_r = math.radians(dec_body)
|
|
764
|
+
H = math.radians(_angdiff_deg(LST, ra_body))
|
|
765
|
+
sin_alt = math.sin(phi)*math.sin(dec_r) + math.cos(phi)*math.cos(dec_r)*math.cos(H)
|
|
766
|
+
alt_deg = math.degrees(math.asin(sin_alt))
|
|
767
|
+
up = alt_deg > min_alt_deg
|
|
768
|
+
return up, {
|
|
769
|
+
"scheme": "full",
|
|
770
|
+
"alt_deg": alt_deg,
|
|
771
|
+
"hour_angle_deg": math.degrees(H),
|
|
772
|
+
"lst_deg": LST,
|
|
773
|
+
"ra_deg": ra_body,
|
|
774
|
+
"dec_deg": dec_body,
|
|
775
|
+
"min_alt_deg": min_alt_deg,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
def summarize_is_up(dt, bodies=bodies):
|
|
779
|
+
out = {}
|
|
780
|
+
for b in bodies:
|
|
781
|
+
is_up, info = ae_is_up(dt, body=b) # assume info has 'alt_deg' and 'lst_deg'
|
|
782
|
+
alt = info.get('alt_deg', None)
|
|
783
|
+
lst = info.get('lst_deg', None)
|
|
784
|
+
altitude = alt if alt is not None else lst
|
|
785
|
+
out[b.title()] = {"is_up": bool(is_up), "altitude": altitude}
|
|
786
|
+
return out
|
|
787
|
+
|