aetherfield 0.2.6__tar.gz → 0.2.7__tar.gz
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.2.6/src/aetherfield.egg-info → aetherfield-0.2.7}/PKG-INFO +1 -1
- {aetherfield-0.2.6 → aetherfield-0.2.7}/pyproject.toml +2 -7
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield/core.py +23 -19
- {aetherfield-0.2.6 → aetherfield-0.2.7/src/aetherfield.egg-info}/PKG-INFO +1 -1
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield.egg-info/SOURCES.txt +1 -5
- aetherfield-0.2.6/src/aetherfield/cli/benchmark.py +0 -195
- aetherfield-0.2.6/src/aetherfield/cli/calibrate_all.py +0 -61
- aetherfield-0.2.6/src/aetherfield/cli/compare.py +0 -194
- aetherfield-0.2.6/src/aetherfield.egg-info/entry_points.txt +0 -4
- {aetherfield-0.2.6 → aetherfield-0.2.7}/LICENSE +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/README.md +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/setup.cfg +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield/__init__.py +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield/iplocal.py +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield.egg-info/dependency_links.txt +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield.egg-info/requires.txt +0 -0
- {aetherfield-0.2.6 → aetherfield-0.2.7}/src/aetherfield.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "aetherfield"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.7"
|
|
8
8
|
description = "AetherField runtime ephemeris"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -32,9 +32,4 @@ package-dir = {"" = "src"}
|
|
|
32
32
|
[tool.setuptools.packages.find]
|
|
33
33
|
where = ["src"]
|
|
34
34
|
include = ["aetherfield*"]
|
|
35
|
-
exclude = ["tests*"]
|
|
36
|
-
|
|
37
|
-
[project.scripts]
|
|
38
|
-
aetherfield-compare = "aetherfield.cli.compare:main"
|
|
39
|
-
aetherfield-benchmark = "aetherfield.cli.benchmark:main"
|
|
40
|
-
aetherfield-calibrate-all = "aetherfield.cli.calibrate_all:main"
|
|
35
|
+
exclude = ["tests*"]
|
|
@@ -40,6 +40,8 @@ AGE_LENGTH = 2147.67
|
|
|
40
40
|
ANCHOR_YEAR = 1
|
|
41
41
|
ANCHOR_SIGN = "Pisces"
|
|
42
42
|
|
|
43
|
+
load_dotenv()
|
|
44
|
+
|
|
43
45
|
def _wrap_deg(x: float) -> float:
|
|
44
46
|
return x % 360.0
|
|
45
47
|
|
|
@@ -156,7 +158,6 @@ def _lst_deg(dt, lon_deg):
|
|
|
156
158
|
return (_gmst_deg(dt) + lon_deg) % 360.0
|
|
157
159
|
|
|
158
160
|
|
|
159
|
-
load_dotenv()
|
|
160
161
|
|
|
161
162
|
try:
|
|
162
163
|
import pytz
|
|
@@ -219,8 +220,8 @@ except Exception:
|
|
|
219
220
|
|
|
220
221
|
DE421_START = datetime(1951, 1, 1, tzinfo=UTC)
|
|
221
222
|
DE421_END = datetime(2050, 12, 31, 23, 59, 59, tzinfo=UTC)
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
DE440_START = datetime(1550, 1, 1, tzinfo=UTC)
|
|
224
|
+
DE440_END = datetime(2650, 12, 31, 23, 59, 59, tzinfo=UTC)
|
|
224
225
|
# DE441 spans beyond Python's datetime range; clamp to supported bounds.
|
|
225
226
|
DE441_START = datetime(1, 1, 1, tzinfo=UTC)
|
|
226
227
|
DE441_END = datetime(9999, 12, 31, 23, 59, 59, tzinfo=UTC)
|
|
@@ -235,16 +236,6 @@ class EphemerisSpec:
|
|
|
235
236
|
true_start_year: int
|
|
236
237
|
true_end_year: int
|
|
237
238
|
|
|
238
|
-
|
|
239
|
-
_DE440_SPEC = EphemerisSpec(
|
|
240
|
-
name="de440",
|
|
241
|
-
filename="de440.bsp",
|
|
242
|
-
start=DE400_START,
|
|
243
|
-
end=DE400_END,
|
|
244
|
-
true_start_year=1550,
|
|
245
|
-
true_end_year=2650,
|
|
246
|
-
)
|
|
247
|
-
|
|
248
239
|
EPHEMERIS_SPECS: Dict[str, EphemerisSpec] = {
|
|
249
240
|
"de421": EphemerisSpec(
|
|
250
241
|
name="de421",
|
|
@@ -254,8 +245,14 @@ EPHEMERIS_SPECS: Dict[str, EphemerisSpec] = {
|
|
|
254
245
|
true_start_year=1951,
|
|
255
246
|
true_end_year=2050,
|
|
256
247
|
),
|
|
257
|
-
"de440":
|
|
258
|
-
|
|
248
|
+
"de440": EphemerisSpec(
|
|
249
|
+
name="de440",
|
|
250
|
+
filename="de440.bsp",
|
|
251
|
+
start=DE440_START,
|
|
252
|
+
end=DE440_END,
|
|
253
|
+
true_start_year=1550,
|
|
254
|
+
true_end_year=2650,
|
|
255
|
+
),
|
|
259
256
|
"de441": EphemerisSpec(
|
|
260
257
|
name="de441",
|
|
261
258
|
filename="de441.bsp",
|
|
@@ -318,6 +315,13 @@ def _select_calibration_ephemeris() -> Tuple[EphemerisSpec, Optional[Path]]:
|
|
|
318
315
|
return spec, path
|
|
319
316
|
return EPHEMERIS_SPECS["de421"], _resolve_ephemeris_path("de421.bsp")
|
|
320
317
|
|
|
318
|
+
def resolve_ephemeris(year: int):
|
|
319
|
+
if 1950 <= year <= 2050:
|
|
320
|
+
return "de421"
|
|
321
|
+
elif 1550 <= year <= 2650:
|
|
322
|
+
return "de440"
|
|
323
|
+
else:
|
|
324
|
+
return "de441"
|
|
321
325
|
|
|
322
326
|
_CAL_EPHEMERIS_SPEC, _CAL_EPHEMERIS_PATH = _select_calibration_ephemeris()
|
|
323
327
|
EPHEMERIS_NAME = _CAL_EPHEMERIS_SPEC.name
|
|
@@ -943,14 +947,14 @@ class AetherField:
|
|
|
943
947
|
'rates_deg_per_day': self.rates_deg_per_day,
|
|
944
948
|
'anchors_min': self.anchors_min,
|
|
945
949
|
'anchors_max': self.anchors_max,
|
|
946
|
-
'ephemeris_name': ephemeris_name,
|
|
947
|
-
'ephemeris_path': ephemeris_path,
|
|
950
|
+
#'ephemeris_name': ephemeris_name,
|
|
951
|
+
#'ephemeris_path': ephemeris_path,
|
|
948
952
|
'ephemeris_start': self.window_start.isoformat(),
|
|
949
953
|
'ephemeris_end': self.window_end.isoformat(),
|
|
950
954
|
'ephemeris_true_start_year': spec.true_start_year if spec else None,
|
|
951
955
|
'ephemeris_true_end_year': spec.true_end_year if spec else None,
|
|
952
|
-
'de421_start': self.window_start.isoformat(),
|
|
953
|
-
'de421_end': self.window_end.isoformat(),
|
|
956
|
+
#'de421_start': self.window_start.isoformat(),
|
|
957
|
+
#'de421_end': self.window_end.isoformat(),
|
|
954
958
|
'piecewise': pw,
|
|
955
959
|
}
|
|
956
960
|
with open(path, 'w', encoding='utf-8') as f:
|
|
@@ -7,9 +7,5 @@ src/aetherfield/iplocal.py
|
|
|
7
7
|
src/aetherfield.egg-info/PKG-INFO
|
|
8
8
|
src/aetherfield.egg-info/SOURCES.txt
|
|
9
9
|
src/aetherfield.egg-info/dependency_links.txt
|
|
10
|
-
src/aetherfield.egg-info/entry_points.txt
|
|
11
10
|
src/aetherfield.egg-info/requires.txt
|
|
12
|
-
src/aetherfield.egg-info/top_level.txt
|
|
13
|
-
src/aetherfield/cli/benchmark.py
|
|
14
|
-
src/aetherfield/cli/calibrate_all.py
|
|
15
|
-
src/aetherfield/cli/compare.py
|
|
11
|
+
src/aetherfield.egg-info/top_level.txt
|
|
@@ -1,195 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,61 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,194 +0,0 @@
|
|
|
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())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|