aetherfield 0.3.0__tar.gz → 0.3.2__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.3.0 → aetherfield-0.3.2}/PKG-INFO +1 -1
- {aetherfield-0.3.0 → aetherfield-0.3.2}/pyproject.toml +5 -2
- aetherfield-0.3.2/src/aetherfield/cli.py +350 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield.egg-info/PKG-INFO +1 -1
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield.egg-info/SOURCES.txt +2 -0
- aetherfield-0.3.2/src/aetherfield.egg-info/entry_points.txt +2 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/LICENSE +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/README.md +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/setup.cfg +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield/__init__.py +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield/core.py +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield/iplocal.py +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield.egg-info/dependency_links.txt +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/src/aetherfield.egg-info/requires.txt +0 -0
- {aetherfield-0.3.0 → aetherfield-0.3.2}/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.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "AetherField runtime ephemeris"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -32,4 +32,7 @@ package-dir = {"" = "src"}
|
|
|
32
32
|
[tool.setuptools.packages.find]
|
|
33
33
|
where = ["src"]
|
|
34
34
|
include = ["aetherfield*"]
|
|
35
|
-
exclude = ["tests*"]
|
|
35
|
+
exclude = ["tests*"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
aetherfield = "aetherfield.cli:main"
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import pytz
|
|
10
|
+
|
|
11
|
+
from . import core as af
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
UTC = pytz.utc
|
|
15
|
+
EPHEMERIS_START = af.EPHEMERIS_START
|
|
16
|
+
EPHEMERIS_END = af.EPHEMERIS_END
|
|
17
|
+
EPHEMERIS_PATH = af.EPHEMERIS_PATH
|
|
18
|
+
|
|
19
|
+
planet_eph = {
|
|
20
|
+
'jupiter': 5,
|
|
21
|
+
'saturn': 6,
|
|
22
|
+
'uranus': 7,
|
|
23
|
+
'neptune': 8,
|
|
24
|
+
'pluto': 9
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_dt(dt_str: Optional[str]) -> datetime:
|
|
29
|
+
if not dt_str:
|
|
30
|
+
return datetime.now(UTC)
|
|
31
|
+
s = dt_str.strip()
|
|
32
|
+
if s.endswith('Z'):
|
|
33
|
+
s = s[:-1]
|
|
34
|
+
dt = datetime.fromisoformat(s)
|
|
35
|
+
return dt.replace(tzinfo=UTC)
|
|
36
|
+
dt = datetime.fromisoformat(s)
|
|
37
|
+
if dt.tzinfo is None:
|
|
38
|
+
dt = dt.replace(tzinfo=UTC)
|
|
39
|
+
return dt.astimezone(UTC)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_moontime(mt_str: Optional[str]) -> Optional[datetime]:
|
|
43
|
+
if not mt_str:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
from moontime import MoonTime
|
|
47
|
+
mt = MoonTime.fromisoformat(mt_str)
|
|
48
|
+
return mt.to_datetime().astimezone(UTC)
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SF_TIME_RE = re.compile(
|
|
54
|
+
r"^\\s*(?P<year>-?\\d+)"
|
|
55
|
+
r"(?:-(?P<month>\\d{1,2})"
|
|
56
|
+
r"(?:-(?P<day>\\d{1,2})"
|
|
57
|
+
r"(?:[T\\s](?P<hour>\\d{1,2})"
|
|
58
|
+
r"(?::(?P<minute>\\d{1,2})"
|
|
59
|
+
r"(?::(?P<second>\\d{1,2}))?"
|
|
60
|
+
r")?"
|
|
61
|
+
r")?"
|
|
62
|
+
r")?"
|
|
63
|
+
r")?"
|
|
64
|
+
r"(?:\\s*(?P<tz>Z|[+-]\\d{2}:\\d{2}))?\\s*$",
|
|
65
|
+
re.IGNORECASE,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parse_sf_time(sf_str: Optional[str]):
|
|
70
|
+
if not sf_str:
|
|
71
|
+
return None
|
|
72
|
+
s = sf_str.strip()
|
|
73
|
+
if not s:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
if "," not in s:
|
|
77
|
+
iso_candidate = s
|
|
78
|
+
if iso_candidate.endswith(("Z", "z")):
|
|
79
|
+
iso_candidate = iso_candidate[:-1] + "+00:00"
|
|
80
|
+
try:
|
|
81
|
+
dt = datetime.fromisoformat(iso_candidate)
|
|
82
|
+
except Exception:
|
|
83
|
+
dt = None
|
|
84
|
+
if dt is not None:
|
|
85
|
+
if dt.tzinfo is None:
|
|
86
|
+
dt = dt.replace(tzinfo=UTC)
|
|
87
|
+
else:
|
|
88
|
+
dt = dt.astimezone(UTC)
|
|
89
|
+
try:
|
|
90
|
+
from skyfield.api import load
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise ValueError("Skyfield is required for --sf") from exc
|
|
93
|
+
ts = load.timescale()
|
|
94
|
+
return ts.from_datetime(dt)
|
|
95
|
+
|
|
96
|
+
offset_days = 0.0
|
|
97
|
+
year = month = day = hour = minute = second = None
|
|
98
|
+
if "," in s:
|
|
99
|
+
parts = [p.strip() for p in s.split(",") if p.strip()]
|
|
100
|
+
if not 1 <= len(parts) <= 6:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
"Invalid --sf value; expected YEAR[,MONTH[,DAY[,HOUR[,MINUTE[,SECOND]]]]]"
|
|
103
|
+
)
|
|
104
|
+
nums = [int(p) for p in parts]
|
|
105
|
+
year = nums[0]
|
|
106
|
+
month = nums[1] if len(nums) > 1 else 1
|
|
107
|
+
day = nums[2] if len(nums) > 2 else 1
|
|
108
|
+
hour = nums[3] if len(nums) > 3 else 0
|
|
109
|
+
minute = nums[4] if len(nums) > 4 else 0
|
|
110
|
+
second = nums[5] if len(nums) > 5 else 0
|
|
111
|
+
else:
|
|
112
|
+
match = _SF_TIME_RE.match(s)
|
|
113
|
+
if not match:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"Invalid --sf value; expected ISO 8601 (YYYY-MM-DD[THH[:MM[:SS]][Z|+HH:MM]]) or YEAR,MONTH,DAY"
|
|
116
|
+
)
|
|
117
|
+
year = int(match.group("year"))
|
|
118
|
+
month = int(match.group("month") or 1)
|
|
119
|
+
day = int(match.group("day") or 1)
|
|
120
|
+
hour = int(match.group("hour") or 0)
|
|
121
|
+
minute = int(match.group("minute") or 0)
|
|
122
|
+
second = int(match.group("second") or 0)
|
|
123
|
+
tz = match.group("tz")
|
|
124
|
+
if tz and tz.upper() != "Z":
|
|
125
|
+
sign = 1 if tz[0] == "+" else -1
|
|
126
|
+
offset_hours = int(tz[1:3])
|
|
127
|
+
offset_minutes = int(tz[4:6])
|
|
128
|
+
offset_days = sign * (offset_hours * 60 + offset_minutes) / 1440.0
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
from skyfield.api import load
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
raise ValueError("Skyfield is required for --sf") from exc
|
|
134
|
+
|
|
135
|
+
ts = load.timescale()
|
|
136
|
+
t = af.make_skyfield_time(
|
|
137
|
+
ts,
|
|
138
|
+
year=year,
|
|
139
|
+
month=month,
|
|
140
|
+
day=day,
|
|
141
|
+
hour=hour,
|
|
142
|
+
minute=minute,
|
|
143
|
+
second=second,
|
|
144
|
+
)
|
|
145
|
+
if offset_days:
|
|
146
|
+
t = t - offset_days
|
|
147
|
+
return t
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _ensure_utc_datetime(dt_value: datetime) -> datetime:
|
|
151
|
+
if dt_value.tzinfo is None:
|
|
152
|
+
return dt_value.replace(tzinfo=UTC)
|
|
153
|
+
return dt_value.astimezone(UTC)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _days_between(start: datetime, end: Any) -> float:
|
|
157
|
+
if af.is_skyfield_time(end):
|
|
158
|
+
from skyfield.api import load
|
|
159
|
+
|
|
160
|
+
ts = getattr(end, "ts", None) or load.timescale()
|
|
161
|
+
start_t = ts.from_datetime(_ensure_utc_datetime(start))
|
|
162
|
+
return float(end.tt) - float(start_t.tt)
|
|
163
|
+
if not isinstance(end, datetime):
|
|
164
|
+
to_dt = getattr(end, "to_datetime", None)
|
|
165
|
+
if callable(to_dt):
|
|
166
|
+
end = to_dt()
|
|
167
|
+
else:
|
|
168
|
+
raise TypeError("Expected datetime or Skyfield Time for comparison")
|
|
169
|
+
start_dt = _ensure_utc_datetime(start)
|
|
170
|
+
end_dt = _ensure_utc_datetime(end)
|
|
171
|
+
return (end_dt - start_dt).total_seconds() / 86400.0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def format_time_label(value: Any) -> str:
|
|
175
|
+
if af.is_skyfield_time(value):
|
|
176
|
+
fmt = getattr(value, "utc_strftime", None)
|
|
177
|
+
if callable(fmt):
|
|
178
|
+
try:
|
|
179
|
+
return fmt("%Y-%m-%dT%H:%M:%SZ")
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
jpl = getattr(value, "utc_jpl", None)
|
|
183
|
+
if callable(jpl):
|
|
184
|
+
try:
|
|
185
|
+
return jpl()
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
return str(value)
|
|
189
|
+
if isinstance(value, datetime):
|
|
190
|
+
return _ensure_utc_datetime(value).isoformat()
|
|
191
|
+
return str(value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def wrap_delta_deg(a: float, b: float) -> float:
|
|
195
|
+
d = (a - b + 540.0) % 360.0 - 180.0
|
|
196
|
+
return d
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sf_ecliptic_longitude(dt: Any, body: str) -> float:
|
|
200
|
+
from skyfield.api import load
|
|
201
|
+
from skyfield.framelib import ecliptic_frame
|
|
202
|
+
|
|
203
|
+
eph = load(EPHEMERIS_PATH)
|
|
204
|
+
ts = load.timescale()
|
|
205
|
+
if af.is_skyfield_time(dt):
|
|
206
|
+
t = dt
|
|
207
|
+
else:
|
|
208
|
+
if not isinstance(dt, datetime):
|
|
209
|
+
to_dt = getattr(dt, "to_datetime", None)
|
|
210
|
+
if callable(to_dt):
|
|
211
|
+
dt = to_dt()
|
|
212
|
+
else:
|
|
213
|
+
raise TypeError("Expected datetime or Skyfield Time")
|
|
214
|
+
dt = _ensure_utc_datetime(dt)
|
|
215
|
+
t = ts.from_datetime(dt)
|
|
216
|
+
earth = eph['earth']
|
|
217
|
+
b = af.get_body_key(eph, body)
|
|
218
|
+
app = earth.at(t).observe(b).apparent()
|
|
219
|
+
lat, lon, dist = app.frame_latlon(ecliptic_frame)
|
|
220
|
+
return float(lon.degrees) % 360.0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class CompareResult:
|
|
225
|
+
body: str
|
|
226
|
+
dt: Any
|
|
227
|
+
lon: float
|
|
228
|
+
#skyfield_lon: Optional[float]
|
|
229
|
+
#delta_deg: Optional[float]
|
|
230
|
+
sign: str
|
|
231
|
+
#skyfield_sign: Optional[str]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _drift_longitude(a: af.AetherField, dt: Any, body: str, anchor_mode: str = 'end') -> float:
|
|
235
|
+
a._ensure_anchor(body) # type: ignore[attr-defined]
|
|
236
|
+
rate = a.rates_deg_per_day.get(body)
|
|
237
|
+
if rate is None:
|
|
238
|
+
raise KeyError(f"No drift rate for body: {body}")
|
|
239
|
+
if anchor_mode == 'nearest':
|
|
240
|
+
d_start = abs(_days_between(a.window_start, dt))
|
|
241
|
+
d_end = abs(_days_between(a.window_end, dt))
|
|
242
|
+
anchor_mode = 'start' if d_start < d_end else 'end'
|
|
243
|
+
if anchor_mode == 'start':
|
|
244
|
+
days = _days_between(a.window_start, dt)
|
|
245
|
+
return (a.anchors_min[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
246
|
+
else:
|
|
247
|
+
days = _days_between(a.window_end, dt)
|
|
248
|
+
return (a.anchors_max[body] + rate * days) % 360.0 # type: ignore[attr-defined]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def compare_once(body: str, dt: Any, force_aether: bool = False, fit_rates: bool = False) -> CompareResult:
|
|
252
|
+
a = af.AetherField()
|
|
253
|
+
if fit_rates:
|
|
254
|
+
try:
|
|
255
|
+
a.fit_rates()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
if force_aether:
|
|
259
|
+
aether_lon = _drift_longitude(a, dt, body)
|
|
260
|
+
aether_sign = af.get_zodiac_by_longitude(aether_lon)
|
|
261
|
+
else:
|
|
262
|
+
aether_lon = a.longitude(dt, body)
|
|
263
|
+
aether_sign = a.sign(dt, body)
|
|
264
|
+
sky_lon: Optional[float] = None
|
|
265
|
+
sky_sign: Optional[str] = None
|
|
266
|
+
if af.in_ephemeris_window(dt):
|
|
267
|
+
try:
|
|
268
|
+
sky_lon = sf_ecliptic_longitude(dt, body)
|
|
269
|
+
sky_sign = af.get_zodiac_by_longitude(sky_lon)
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
delta = wrap_delta_deg(aether_lon, sky_lon) if sky_lon is not None else None
|
|
273
|
+
return CompareResult(body, dt, aether_lon, sky_lon, delta, aether_sign, sky_sign)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def main(argv=None) -> int:
|
|
277
|
+
p = argparse.ArgumentParser(description="Compare AetherField with Skyfield for a single timestamp.")
|
|
278
|
+
p.add_argument('--body', required=True, help='Body (sun, moon, mercury, ... pluto)')
|
|
279
|
+
p.add_argument('--dt', default=None, help='ISO8601 datetime (UTC).')
|
|
280
|
+
p.add_argument('--moontime', default=None, help='MoonTime string (mt:...)')
|
|
281
|
+
p.add_argument('--sf', default=None, help='Skyfield time with astronomical year numbering (ISO 8601 YYYY-MM-DD[THH[:MM[:SS]][Z|+HH:MM]] or YEAR,MONTH,DAY). Overrides --dt/--moontime.')
|
|
282
|
+
p.add_argument('--force-aether', action='store_true', help='Use AetherField-only (drift).')
|
|
283
|
+
p.add_argument('--fit-rates', action='store_true', help='Fit mean drift from in-range samples.')
|
|
284
|
+
p.add_argument('--calibrate', action='store_true', help='Ensure anchors and piecewise segments (no-ops if present).')
|
|
285
|
+
p.add_argument('--save-calibration', default=None, help='Save calibration JSON path.')
|
|
286
|
+
p.add_argument('--load-calibration', default=None, help='Load calibration JSON path.')
|
|
287
|
+
p.add_argument('--drift-anchor', choices=['start','end','nearest'], default='end', help='Anchor for drift only mode.')
|
|
288
|
+
p.add_argument('--piecewise', action='store_true', help='Use piecewise segments when forcing aether.')
|
|
289
|
+
p.add_argument('--piecewise-step', type=int, default=30, help='Step in days for piecewise building.')
|
|
290
|
+
p.add_argument('--json', action='store_true', help='Emit JSON instead of text.')
|
|
291
|
+
args = p.parse_args(argv)
|
|
292
|
+
|
|
293
|
+
body = args.body.strip().lower()
|
|
294
|
+
if args.sf:
|
|
295
|
+
try:
|
|
296
|
+
dt = parse_sf_time(args.sf)
|
|
297
|
+
except ValueError as exc:
|
|
298
|
+
p.error(str(exc))
|
|
299
|
+
if dt is None:
|
|
300
|
+
p.error("Invalid --sf value")
|
|
301
|
+
else:
|
|
302
|
+
dt = parse_moontime(args.moontime) or parse_dt(args.dt)
|
|
303
|
+
|
|
304
|
+
if args.load_calibration:
|
|
305
|
+
try:
|
|
306
|
+
a = af.AetherField.load_calibration(args.load_calibration)
|
|
307
|
+
except Exception:
|
|
308
|
+
a = af.AetherField()
|
|
309
|
+
else:
|
|
310
|
+
a = af.AetherField()
|
|
311
|
+
if args.fit_rates:
|
|
312
|
+
try:
|
|
313
|
+
a.fit_rates()
|
|
314
|
+
except Exception:
|
|
315
|
+
pass
|
|
316
|
+
if args.calibrate:
|
|
317
|
+
try:
|
|
318
|
+
if args.piecewise:
|
|
319
|
+
a.fit_piecewise(step_days=int(args.piecewise_step), bodies=(body,))
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
if args.save_calibration:
|
|
323
|
+
try:
|
|
324
|
+
a.save_calibration(args.save_calibration)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
res = compare_once(body, dt, force_aether=True, fit_rates=args.fit_rates)
|
|
329
|
+
|
|
330
|
+
if args.json:
|
|
331
|
+
import json
|
|
332
|
+
print(json.dumps({
|
|
333
|
+
'body': res.body,
|
|
334
|
+
'dt': format_time_label(res.dt),
|
|
335
|
+
'lon': res.aether_lon,
|
|
336
|
+
#'skyfield_lon': res.skyfield_lon,
|
|
337
|
+
#'delta_deg': res.delta_deg,
|
|
338
|
+
'sign': res.aether_sign,
|
|
339
|
+
#'skyfield_sign': res.skyfield_sign,
|
|
340
|
+
}, indent=2))
|
|
341
|
+
else:
|
|
342
|
+
print(f"{res.body} @ {format_time_label(res.dt)}\n"
|
|
343
|
+
f" Aether: {res.aether_lon:8.3f} deg ({res.aether_sign})")
|
|
344
|
+
#f" Skyfield: {'n/a' if res.skyfield_lon is None else f'{res.skyfield_lon:8.3f} deg ('+str(res.skyfield_sign)+')'}\n"
|
|
345
|
+
#f" Delta: {'n/a' if res.delta_deg is None else f'{res.delta_deg:+.3f} deg'}")
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
if __name__ == '__main__':
|
|
350
|
+
raise SystemExit(main())
|
|
@@ -2,10 +2,12 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/aetherfield/__init__.py
|
|
5
|
+
src/aetherfield/cli.py
|
|
5
6
|
src/aetherfield/core.py
|
|
6
7
|
src/aetherfield/iplocal.py
|
|
7
8
|
src/aetherfield.egg-info/PKG-INFO
|
|
8
9
|
src/aetherfield.egg-info/SOURCES.txt
|
|
9
10
|
src/aetherfield.egg-info/dependency_links.txt
|
|
11
|
+
src/aetherfield.egg-info/entry_points.txt
|
|
10
12
|
src/aetherfield.egg-info/requires.txt
|
|
11
13
|
src/aetherfield.egg-info/top_level.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|