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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ aetherfield-benchmark = aetherfield_pkg.cli.benchmark:main
3
+ aetherfield-calibrate-all = aetherfield_pkg.cli.calibrate_all:main
4
+ aetherfield-compare = aetherfield_pkg.cli.compare:main
@@ -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())
@@ -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
+