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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aetherfield
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: AetherField runtime ephemeris
5
5
  Author: WitchMithras
6
6
  License: Other/Proprietary License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aetherfield"
7
- version = "0.3.0"
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())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aetherfield
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: AetherField runtime ephemeris
5
5
  Author: WitchMithras
6
6
  License: Other/Proprietary License
@@ -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
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aetherfield = aetherfield.cli:main
File without changes
File without changes
File without changes