natal-chart 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,42 @@
1
+ """natal-chart: tropical natal chart math on NASA JPL ephemerides (via Skyfield).
2
+
3
+ >>> from natal_chart import chart, aspects
4
+ >>> c = chart("1990-05-17T14:30", lat=55.75, lon=37.62, tz="Europe/Moscow")
5
+ >>> c["Sun"].sign
6
+ 'Taurus'
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from ._ephemeris import data_dir, set_ephemeris
14
+ from .aspects import (
15
+ ASPECT_ANGLES,
16
+ DEFAULT_ORBS,
17
+ DEFAULT_TRANSIT_ORBS,
18
+ Aspect,
19
+ aspects,
20
+ transits,
21
+ )
22
+ from .chart import PLANETS, Chart, Point, chart, positions
23
+ from .houses import HOUSE_SYSTEMS, PolarHousesError
24
+
25
+ __all__ = [
26
+ "ASPECT_ANGLES",
27
+ "DEFAULT_ORBS",
28
+ "DEFAULT_TRANSIT_ORBS",
29
+ "HOUSE_SYSTEMS",
30
+ "PLANETS",
31
+ "Aspect",
32
+ "Chart",
33
+ "Point",
34
+ "PolarHousesError",
35
+ "aspects",
36
+ "chart",
37
+ "data_dir",
38
+ "positions",
39
+ "set_ephemeris",
40
+ "transits",
41
+ "__version__",
42
+ ]
natal_chart/_angles.py ADDED
@@ -0,0 +1,69 @@
1
+ """Small angle helpers (degrees)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ SIGNS: tuple[str, ...] = (
8
+ "Aries",
9
+ "Taurus",
10
+ "Gemini",
11
+ "Cancer",
12
+ "Leo",
13
+ "Virgo",
14
+ "Libra",
15
+ "Scorpio",
16
+ "Sagittarius",
17
+ "Capricorn",
18
+ "Aquarius",
19
+ "Pisces",
20
+ )
21
+
22
+
23
+ def norm360(x: float) -> float:
24
+ """Normalise an angle to [0, 360)."""
25
+ x = math.fmod(x, 360.0)
26
+ if x < 0:
27
+ x += 360.0
28
+ # fmod can return 360.0 for tiny negative inputs after the addition
29
+ return 0.0 if x >= 360.0 else x
30
+
31
+
32
+ def norm180(x: float) -> float:
33
+ """Normalise an angle to [-180, 180)."""
34
+ return norm360(x + 180.0) - 180.0
35
+
36
+
37
+ def sind(x: float) -> float:
38
+ return math.sin(math.radians(x))
39
+
40
+
41
+ def cosd(x: float) -> float:
42
+ return math.cos(math.radians(x))
43
+
44
+
45
+ def tand(x: float) -> float:
46
+ return math.tan(math.radians(x))
47
+
48
+
49
+ def asind(x: float) -> float:
50
+ return math.degrees(math.asin(max(-1.0, min(1.0, x))))
51
+
52
+
53
+ def atan2d(y: float, x: float) -> float:
54
+ return math.degrees(math.atan2(y, x))
55
+
56
+
57
+ def sign_of(longitude: float) -> tuple[str, float]:
58
+ """Return (sign name, degree within sign) for an ecliptic longitude."""
59
+ lon = norm360(longitude)
60
+ idx = int(lon // 30.0)
61
+ return SIGNS[idx], lon - 30.0 * idx
62
+
63
+
64
+ def format_dms(degree_in_sign: float) -> str:
65
+ """Format a degree within a sign as ``DD°MM'SS"``."""
66
+ total = int(round(degree_in_sign * 3600.0))
67
+ d, rem = divmod(total, 3600)
68
+ m, s = divmod(rem, 60)
69
+ return f"{d:2d}°{m:02d}'{s:02d}\""
@@ -0,0 +1,61 @@
1
+ """Loading of the JPL ephemeris through Skyfield.
2
+
3
+ The ephemeris file is never shipped with the package. On first use Skyfield
4
+ downloads it into a cache directory:
5
+
6
+ * ``$NATAL_CHART_DATA`` if set, otherwise
7
+ * ``~/.cache/natal-chart``.
8
+
9
+ The default file is NASA JPL DE440s (1849-2150, ~32 MB). Any other
10
+ Skyfield-readable SPK file (for example ``de421.bsp``, 1900-2050, ~17 MB, or
11
+ ``de440.bsp``, 1550-2650, ~114 MB) can be selected with
12
+ :func:`set_ephemeris` or the ``NATAL_CHART_EPHEMERIS`` environment variable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from functools import lru_cache
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from skyfield.api import Loader
23
+
24
+ DEFAULT_EPHEMERIS = "de440s.bsp"
25
+
26
+ _ephemeris_name: str = os.environ.get("NATAL_CHART_EPHEMERIS", DEFAULT_EPHEMERIS)
27
+
28
+
29
+ def data_dir() -> Path:
30
+ """Directory where the ephemeris file is cached."""
31
+ env = os.environ.get("NATAL_CHART_DATA")
32
+ return Path(env).expanduser() if env else Path.home() / ".cache" / "natal-chart"
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _loader() -> Loader:
37
+ path = data_dir()
38
+ path.mkdir(parents=True, exist_ok=True)
39
+ return Loader(str(path), verbose=False)
40
+
41
+
42
+ @lru_cache(maxsize=1)
43
+ def timescale() -> Any:
44
+ """Skyfield timescale (uses Skyfield's built-in Delta T / leap-second data)."""
45
+ return _loader().timescale()
46
+
47
+
48
+ @lru_cache(maxsize=4)
49
+ def _load(name: str) -> Any:
50
+ return _loader()(name)
51
+
52
+
53
+ def ephemeris() -> Any:
54
+ """The currently selected JPL ephemeris, downloading it on first use."""
55
+ return _load(_ephemeris_name)
56
+
57
+
58
+ def set_ephemeris(name_or_path: str) -> None:
59
+ """Select the SPK file to use, e.g. ``"de421.bsp"`` or a local path."""
60
+ global _ephemeris_name
61
+ _ephemeris_name = name_or_path
natal_chart/aspects.py ADDED
@@ -0,0 +1,136 @@
1
+ """Major aspects within a chart and transits to a natal chart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from itertools import combinations
8
+ from typing import Iterable, Mapping
9
+
10
+ from ._angles import norm180
11
+ from .chart import ASCENDANT, MC, PLANETS, Chart, Point, positions
12
+
13
+ _ANGLES = {ASCENDANT, MC}
14
+
15
+ ASPECT_ANGLES: dict[str, float] = {
16
+ "conjunction": 0.0,
17
+ "sextile": 60.0,
18
+ "square": 90.0,
19
+ "trine": 120.0,
20
+ "opposition": 180.0,
21
+ }
22
+
23
+ DEFAULT_ORBS: dict[str, float] = {
24
+ "conjunction": 8.0,
25
+ "opposition": 8.0,
26
+ "trine": 7.0,
27
+ "square": 7.0,
28
+ "sextile": 5.0,
29
+ }
30
+
31
+ DEFAULT_TRANSIT_ORBS: dict[str, float] = {name: 1.0 for name in ASPECT_ANGLES}
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Aspect:
36
+ """An aspect between two points.
37
+
38
+ ``orb`` is the absolute distance from exactness in degrees. ``applying``
39
+ is True when the orb is shrinking given the current speeds of both
40
+ points (for transits the natal point is fixed).
41
+ """
42
+
43
+ first: str
44
+ second: str
45
+ aspect: str
46
+ angle: float
47
+ separation: float
48
+ """Shortest angular distance between the two points, 0..180."""
49
+ orb: float
50
+ applying: bool
51
+
52
+ @property
53
+ def separating(self) -> bool:
54
+ return not self.applying
55
+
56
+
57
+ def _aspect_between(
58
+ p1: Point, p2: Point, orbs: Mapping[str, float], speed1: float, speed2: float
59
+ ) -> Aspect | None:
60
+ sep = norm180(p2.longitude - p1.longitude) # signed, p1 -> p2
61
+ dist = abs(sep)
62
+ best: Aspect | None = None
63
+ for name, max_orb in orbs.items():
64
+ angle = ASPECT_ANGLES[name]
65
+ dev = dist - angle
66
+ if abs(dev) > max_orb:
67
+ continue
68
+ # d(dist)/dt; at sep == 0 the sign is irrelevant for a conjunction
69
+ rate = (speed2 - speed1) * (1.0 if sep >= 0 else -1.0)
70
+ applying = bool(dev * rate < 0)
71
+ cand = Aspect(p1.name, p2.name, name, angle, dist, abs(dev), applying)
72
+ if best is None or cand.orb < best.orb:
73
+ best = cand
74
+ return best
75
+
76
+
77
+ def _check_orbs(orbs: Mapping[str, float]) -> None:
78
+ unknown = set(orbs) - set(ASPECT_ANGLES)
79
+ if unknown:
80
+ raise ValueError(f"unknown aspect(s) {sorted(unknown)}; known: {list(ASPECT_ANGLES)}")
81
+
82
+
83
+ def aspects(
84
+ chart: Chart,
85
+ orbs: Mapping[str, float] | None = None,
86
+ points: Iterable[str] | None = None,
87
+ ) -> list[Aspect]:
88
+ """Major aspects between the points of a chart, tightest first.
89
+
90
+ Args:
91
+ chart: a :class:`~natal_chart.chart.Chart`.
92
+ orbs: maximum orb per aspect name in degrees; aspects not in the
93
+ mapping are not searched. Defaults to :data:`DEFAULT_ORBS`.
94
+ points: names of points to consider (default: all chart points,
95
+ including the Ascendant and MC; the ASC-MC pair itself is skipped).
96
+ """
97
+ orbs = DEFAULT_ORBS if orbs is None else orbs
98
+ _check_orbs(orbs)
99
+ names = list(chart.points) if points is None else list(points)
100
+ found: list[Aspect] = []
101
+ for a, b in combinations(names, 2):
102
+ if a in _ANGLES and b in _ANGLES:
103
+ continue # ASC-MC geometry is not an aspect
104
+ p1, p2 = chart[a], chart[b]
105
+ asp = _aspect_between(p1, p2, orbs, p1.speed, p2.speed)
106
+ if asp is not None:
107
+ found.append(asp)
108
+ return sorted(found, key=lambda x: x.orb)
109
+
110
+
111
+ def transits(
112
+ natal: Chart,
113
+ when: datetime | str,
114
+ tz: str | None = None,
115
+ orbs: Mapping[str, float] | None = None,
116
+ transiting: Iterable[str] = PLANETS,
117
+ natal_points: Iterable[str] | None = None,
118
+ ) -> list[Aspect]:
119
+ """Aspects from transiting planets at ``when`` to natal points.
120
+
121
+ In each returned :class:`Aspect`, ``first`` is the transiting body and
122
+ ``second`` the natal point. Natal points are treated as fixed when
123
+ deciding applying/separating. Default orbs: 1° (:data:`DEFAULT_TRANSIT_ORBS`).
124
+ """
125
+ orbs = DEFAULT_TRANSIT_ORBS if orbs is None else orbs
126
+ _check_orbs(orbs)
127
+ sky = positions(when, tz)
128
+ targets = list(natal.points) if natal_points is None else list(natal_points)
129
+ found: list[Aspect] = []
130
+ for t_name in transiting:
131
+ tp = sky[t_name]
132
+ for n_name in targets:
133
+ asp = _aspect_between(tp, natal[n_name], orbs, tp.speed, 0.0)
134
+ if asp is not None:
135
+ found.append(asp)
136
+ return sorted(found, key=lambda x: x.orb)
natal_chart/chart.py ADDED
@@ -0,0 +1,256 @@
1
+ """Natal chart computation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Iterator
8
+ from zoneinfo import ZoneInfo
9
+
10
+ from skyfield.framelib import ecliptic_frame
11
+ from skyfield.nutationlib import iau2000a_radians, mean_obliquity
12
+
13
+ from . import _ephemeris
14
+ from ._angles import norm180, norm360, sign_of
15
+ from .houses import Cusps, ascendant, house_cusps, house_of, midheaven
16
+ from .points import mean_apogee, mean_apogee_speed, mean_node, mean_node_speed
17
+
18
+ PLANETS: tuple[str, ...] = (
19
+ "Sun",
20
+ "Moon",
21
+ "Mercury",
22
+ "Venus",
23
+ "Mars",
24
+ "Jupiter",
25
+ "Saturn",
26
+ "Uranus",
27
+ "Neptune",
28
+ "Pluto",
29
+ )
30
+ MEAN_NODE = "Mean Node"
31
+ LILITH = "Lilith"
32
+ ASCENDANT = "Ascendant"
33
+ MC = "MC"
34
+
35
+ # Segments available in every JPL DE file (DE440s has no planet-centre
36
+ # segments for Mars..Pluto). The offset between a planet's system
37
+ # barycenter and its centre is < 0.1" as seen from Earth.
38
+ _TARGETS = {
39
+ "Sun": "sun",
40
+ "Moon": "moon",
41
+ "Mercury": "mercury barycenter",
42
+ "Venus": "venus barycenter",
43
+ "Mars": "mars barycenter",
44
+ "Jupiter": "jupiter barycenter",
45
+ "Saturn": "saturn barycenter",
46
+ "Uranus": "uranus barycenter",
47
+ "Neptune": "neptune barycenter",
48
+ "Pluto": "pluto barycenter",
49
+ }
50
+
51
+ _SPEED_STEP_DAYS = 0.01 # central difference step for speeds (14.4 min)
52
+ _ARCSEC = 1.0 / 3600.0
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Point:
57
+ """A chart point: planet, lunar point or angle.
58
+
59
+ ``longitude``/``latitude`` are geocentric apparent ecliptic coordinates
60
+ referred to the true equinox and ecliptic of date (tropical zodiac).
61
+ ``speed`` is in degrees per day of longitude.
62
+ """
63
+
64
+ name: str
65
+ longitude: float
66
+ latitude: float
67
+ speed: float
68
+ house: int | None = None
69
+
70
+ @property
71
+ def sign(self) -> str:
72
+ return sign_of(self.longitude)[0]
73
+
74
+ @property
75
+ def degree(self) -> float:
76
+ """Degree within the sign, 0 <= degree < 30."""
77
+ return sign_of(self.longitude)[1]
78
+
79
+ @property
80
+ def retrograde(self) -> bool:
81
+ return bool(self.speed < 0)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Chart:
86
+ """A computed chart. Index it by point name: ``chart["Moon"]``."""
87
+
88
+ datetime_utc: datetime
89
+ latitude: float
90
+ longitude: float
91
+ house_system: str
92
+ """The house system actually used (may differ from the requested one
93
+ if ``polar_fallback`` kicked in, see :attr:`polar_fallback_used`)."""
94
+ polar_fallback_used: bool
95
+ cusps: Cusps
96
+ ramc: float
97
+ obliquity: float
98
+ """True obliquity of the ecliptic, degrees."""
99
+ jd_ut: float
100
+ jd_tt: float
101
+ points: dict[str, Point] = field(repr=False)
102
+
103
+ def __getitem__(self, name: str) -> Point:
104
+ return self.points[name]
105
+
106
+ def __iter__(self) -> Iterator[Point]:
107
+ return iter(self.points.values())
108
+
109
+ @property
110
+ def ascendant(self) -> Point:
111
+ return self.points[ASCENDANT]
112
+
113
+ @property
114
+ def mc(self) -> Point:
115
+ return self.points[MC]
116
+
117
+ @property
118
+ def planets(self) -> list[Point]:
119
+ return [self.points[n] for n in PLANETS]
120
+
121
+
122
+ def to_utc(when: datetime | str, tz: str | None = None) -> datetime:
123
+ """Normalise input time to an aware UTC datetime.
124
+
125
+ * aware ``datetime``: converted to UTC (``tz`` must be omitted);
126
+ * naive ``datetime`` + ``tz``: interpreted as local time in that IANA
127
+ zone (ambiguous/non-existent DST times follow ``datetime.fold``);
128
+ * naive ``datetime`` without ``tz``: rejected, to avoid silent mistakes.
129
+ """
130
+ if isinstance(when, str):
131
+ when = datetime.fromisoformat(when)
132
+ if when.tzinfo is not None:
133
+ if tz is not None:
134
+ raise ValueError("pass either an aware datetime or tz, not both")
135
+ return when.astimezone(timezone.utc)
136
+ if tz is None:
137
+ raise ValueError(
138
+ "naive datetime: pass tz='Area/City' (IANA) or a timezone-aware datetime"
139
+ )
140
+ return when.replace(tzinfo=ZoneInfo(tz)).astimezone(timezone.utc)
141
+
142
+
143
+ _UTC_EPOCH = datetime(1972, 1, 1, tzinfo=timezone.utc)
144
+
145
+
146
+ def _time(dt_utc: datetime): # -> skyfield Time
147
+ """Skyfield Time for a UTC instant.
148
+
149
+ Modern UTC (leap seconds) starts in 1972. Before that, civil time
150
+ followed UT (the "rubber second" UTC of 1961-1971 stayed within 0.1 s of
151
+ UT1), so earlier instants are taken as UT1 and converted with
152
+ Skyfield's Delta T model, as astrology software conventionally does.
153
+ """
154
+ ts = _ephemeris.timescale()
155
+ sec = dt_utc.second + dt_utc.microsecond / 1e6
156
+ args = (dt_utc.year, dt_utc.month, dt_utc.day, dt_utc.hour, dt_utc.minute, sec)
157
+ return ts.utc(*args) if dt_utc >= _UTC_EPOCH else ts.ut1(*args)
158
+
159
+
160
+ def _bodies(t) -> dict[str, Point]:
161
+ """Apparent geocentric ecliptic positions of the ten planets + lunar points."""
162
+ ts = _ephemeris.timescale()
163
+ eph = _ephemeris.ephemeris()
164
+ earth = eph["earth"]
165
+ h = _SPEED_STEP_DAYS
166
+ tt = ts.tt_jd([t.tt - h, t.tt, t.tt + h])
167
+ out: dict[str, Point] = {}
168
+ for name in PLANETS:
169
+ apparent = earth.at(tt).observe(eph[_TARGETS[name]]).apparent()
170
+ lat, lon, _ = apparent.frame_latlon(ecliptic_frame)
171
+ lons = lon.degrees
172
+ speed = norm180(lons[2] - lons[0]) / (2 * h)
173
+ out[name] = Point(name, float(norm360(lons[1])), float(lat.degrees[1]), float(speed))
174
+
175
+ T = (float(t.tt) - 2451545.0) / 36525.0
176
+ dpsi, _ = iau2000a_radians(t)
177
+ dpsi_deg = float(dpsi) * 180.0 / 3.141592653589793
178
+ out[MEAN_NODE] = Point(
179
+ MEAN_NODE, norm360(mean_node(T) + dpsi_deg), 0.0, mean_node_speed(T)
180
+ )
181
+ a_lon, a_lat = mean_apogee(T)
182
+ out[LILITH] = Point(LILITH, norm360(a_lon + dpsi_deg), a_lat, mean_apogee_speed(T))
183
+ return out
184
+
185
+
186
+ def _true_obliquity(t) -> float:
187
+ _, deps = iau2000a_radians(t)
188
+ return mean_obliquity(t.tdb) * _ARCSEC + float(deps) * 180.0 / 3.141592653589793
189
+
190
+
191
+ def chart(
192
+ when: datetime | str,
193
+ lat: float,
194
+ lon: float,
195
+ tz: str | None = None,
196
+ house_system: str = "placidus",
197
+ polar_fallback: str | None = None,
198
+ ) -> Chart:
199
+ """Compute a tropical natal chart.
200
+
201
+ Args:
202
+ when: birth time; an aware datetime, or a naive one together with ``tz``.
203
+ lat: geographic latitude, degrees, north positive.
204
+ lon: geographic longitude, degrees, east positive.
205
+ tz: IANA time zone name for a naive ``when`` (e.g. ``"Europe/Moscow"``).
206
+ house_system: ``"placidus"`` (default), ``"koch"``, ``"porphyry"``,
207
+ ``"equal"`` or ``"whole_sign"``.
208
+ polar_fallback: system to use when Placidus/Koch are undefined
209
+ (``|lat| > 90° - obliquity``, about 66.56°). ``None`` (default)
210
+ raises :class:`~natal_chart.houses.PolarHousesError` instead.
211
+ """
212
+ if not -90.0 <= lat <= 90.0:
213
+ raise ValueError("latitude must be within [-90, 90]")
214
+ if not -180.0 <= lon <= 360.0:
215
+ raise ValueError("longitude must be within [-180, 360]")
216
+ dt_utc = to_utc(when, tz)
217
+ t = _time(dt_utc)
218
+
219
+ eps = _true_obliquity(t)
220
+ ramc = norm360(t.gast * 15.0 + lon)
221
+ cusps, used = house_cusps(ramc, eps, lat, house_system, polar_fallback)
222
+
223
+ points = _bodies(t)
224
+ points = {n: _with_house(p, cusps) for n, p in points.items()}
225
+
226
+ # Speeds of the angles from the rate of sidereal time (deg/day).
227
+ d_ramc = 360.98564736629 * 0.001 # 0.001 day step
228
+ asc = ascendant(ramc, eps, lat)
229
+ mc = midheaven(ramc, eps)
230
+ asc_speed = norm180(ascendant(ramc + d_ramc, eps, lat) - ascendant(ramc - d_ramc, eps, lat)) / 0.002
231
+ mc_speed = norm180(midheaven(ramc + d_ramc, eps) - midheaven(ramc - d_ramc, eps)) / 0.002
232
+ points[ASCENDANT] = Point(ASCENDANT, asc, 0.0, asc_speed, house_of(asc, cusps))
233
+ points[MC] = Point(MC, mc, 0.0, mc_speed, house_of(mc, cusps))
234
+
235
+ return Chart(
236
+ datetime_utc=dt_utc,
237
+ latitude=lat,
238
+ longitude=lon,
239
+ house_system=used,
240
+ polar_fallback_used=used != house_system,
241
+ cusps=cusps,
242
+ ramc=ramc,
243
+ obliquity=eps,
244
+ jd_ut=float(t.ut1),
245
+ jd_tt=float(t.tt),
246
+ points=points,
247
+ )
248
+
249
+
250
+ def _with_house(p: Point, cusps: Cusps) -> Point:
251
+ return Point(p.name, p.longitude, p.latitude, p.speed, house_of(p.longitude, cusps))
252
+
253
+
254
+ def positions(when: datetime | str, tz: str | None = None) -> dict[str, Point]:
255
+ """Planet and lunar-point positions only (no location, no houses)."""
256
+ return _bodies(_time(to_utc(when, tz)))
natal_chart/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """Command line interface: ``natal-chart 1990-05-17T14:30 --tz Europe/Moscow --lat 55.75 --lon 37.62``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from typing import Sequence
10
+
11
+ from . import __version__
12
+ from ._angles import format_dms
13
+ from .aspects import aspects, transits
14
+ from .chart import Chart, chart
15
+ from .houses import HOUSE_SYSTEMS, PolarHousesError
16
+
17
+ _GLYPH_ASPECT = {
18
+ "conjunction": "conj",
19
+ "sextile": "sext",
20
+ "square": "sqr",
21
+ "trine": "tri",
22
+ "opposition": "opp",
23
+ }
24
+
25
+
26
+ def _parser() -> argparse.ArgumentParser:
27
+ p = argparse.ArgumentParser(
28
+ prog="natal-chart",
29
+ description="Tropical natal chart (planets, houses, aspects) from JPL ephemerides.",
30
+ )
31
+ p.add_argument("datetime", help="birth time, ISO format, e.g. 1990-05-17T14:30")
32
+ p.add_argument("--tz", help="IANA time zone of the given time (default: UTC)", default=None)
33
+ p.add_argument("--lat", type=float, required=True, help="latitude, north positive")
34
+ p.add_argument("--lon", type=float, required=True, help="longitude, east positive")
35
+ p.add_argument("--houses", choices=HOUSE_SYSTEMS, default="placidus")
36
+ p.add_argument(
37
+ "--polar-fallback",
38
+ choices=("porphyry", "equal", "whole_sign"),
39
+ default=None,
40
+ help="house system to use where Placidus/Koch are undefined (|lat| > ~66.56)",
41
+ )
42
+ p.add_argument("--aspects", action="store_true", help="also list major aspects")
43
+ p.add_argument("--transits", metavar="DATETIME", help="list transits for this time (UTC unless it has an offset)")
44
+ p.add_argument("--json", action="store_true", help="machine-readable output")
45
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
46
+ return p
47
+
48
+
49
+ def _chart_dict(c: Chart) -> dict:
50
+ return {
51
+ "datetime_utc": c.datetime_utc.isoformat(),
52
+ "latitude": c.latitude,
53
+ "longitude": c.longitude,
54
+ "house_system": c.house_system,
55
+ "polar_fallback_used": c.polar_fallback_used,
56
+ "points": [
57
+ {
58
+ "name": p.name,
59
+ "longitude": p.longitude,
60
+ "latitude": p.latitude,
61
+ "sign": p.sign,
62
+ "degree": p.degree,
63
+ "speed": p.speed,
64
+ "retrograde": p.retrograde,
65
+ "house": p.house,
66
+ }
67
+ for p in c
68
+ ],
69
+ "cusps": list(c.cusps),
70
+ }
71
+
72
+
73
+ def main(argv: Sequence[str] | None = None) -> int:
74
+ args = _parser().parse_args(argv)
75
+ when = datetime.fromisoformat(args.datetime)
76
+ tz = args.tz
77
+ if when.tzinfo is None and tz is None:
78
+ tz = "UTC"
79
+ try:
80
+ c = chart(when, args.lat, args.lon, tz=tz, house_system=args.houses,
81
+ polar_fallback=args.polar_fallback)
82
+ except (PolarHousesError, ValueError) as exc:
83
+ print(f"error: {exc}", file=sys.stderr)
84
+ return 2
85
+
86
+ asp = aspects(c) if args.aspects else []
87
+ tr = []
88
+ if args.transits:
89
+ tw = datetime.fromisoformat(args.transits)
90
+ if tw.tzinfo is None:
91
+ tw = tw.replace(tzinfo=timezone.utc)
92
+ tr = transits(c, tw)
93
+
94
+ if args.json:
95
+ out = _chart_dict(c)
96
+ if args.aspects:
97
+ out["aspects"] = [a.__dict__ for a in asp]
98
+ if args.transits:
99
+ out["transits"] = [a.__dict__ for a in tr]
100
+ print(json.dumps(out, indent=2, ensure_ascii=False))
101
+ return 0
102
+
103
+ note = " (polar fallback)" if c.polar_fallback_used else ""
104
+ print(f"{c.datetime_utc:%Y-%m-%d %H:%M:%S} UTC lat {c.latitude:.4f} lon {c.longitude:.4f}"
105
+ f" houses: {c.house_system}{note}")
106
+ print()
107
+ print(f"{'Point':<10} {'Sign':<12} {'Degree':>10} {'Longitude':>10} {'Speed':>8} R House")
108
+ for p in c:
109
+ r = "R" if p.retrograde else " "
110
+ print(f"{p.name:<10} {p.sign:<12} {format_dms(p.degree):>10} {p.longitude:10.4f}"
111
+ f" {p.speed:8.4f} {r} {p.house or '':>5}")
112
+ print()
113
+ print("Cusps: " + " ".join(f"{i + 1}:{x:.2f}" for i, x in enumerate(c.cusps)))
114
+ if args.aspects:
115
+ print()
116
+ print("Aspects (orb, A=applying S=separating):")
117
+ for a in asp:
118
+ print(f" {a.first:<10} {_GLYPH_ASPECT[a.aspect]:<4} {a.second:<10} "
119
+ f"{a.orb:5.2f} {'A' if a.applying else 'S'}")
120
+ if args.transits:
121
+ print()
122
+ print(f"Transits at {args.transits} (transiting -> natal):")
123
+ for a in tr:
124
+ print(f" {a.first:<10} {_GLYPH_ASPECT[a.aspect]:<4} natal {a.second:<10} "
125
+ f"{a.orb:5.2f} {'A' if a.applying else 'S'}")
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__": # pragma: no cover
130
+ raise SystemExit(main())
natal_chart/houses.py ADDED
@@ -0,0 +1,202 @@
1
+ """House systems, Ascendant and Midheaven.
2
+
3
+ All functions here are pure math and take:
4
+
5
+ * ``ramc`` - right ascension of the meridian (local apparent sidereal time
6
+ in degrees),
7
+ * ``eps`` - true obliquity of the ecliptic, degrees,
8
+ * ``lat`` - geographic latitude, degrees (north positive).
9
+
10
+ Cusps are returned as a 12-tuple of ecliptic longitudes, index 0 = cusp 1.
11
+
12
+ Placidus and Koch are undefined when part of the ecliptic never rises or
13
+ sets, i.e. for ``|lat| > 90° - eps`` (about 66.56°). In that case
14
+ :class:`PolarHousesError` is raised; :func:`house_cusps` can instead fall
15
+ back to another system when asked to.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Literal
21
+
22
+ from ._angles import asind, atan2d, cosd, norm180, norm360, sind, tand
23
+
24
+ HouseSystem = Literal["placidus", "koch", "porphyry", "equal", "whole_sign"]
25
+ HOUSE_SYSTEMS: tuple[str, ...] = ("placidus", "koch", "porphyry", "equal", "whole_sign")
26
+ _QUADRANT_SYSTEMS = ("placidus", "koch")
27
+
28
+ Cusps = tuple[float, float, float, float, float, float, float, float, float, float, float, float]
29
+
30
+
31
+ class PolarHousesError(ValueError):
32
+ """Placidus/Koch cusps do not exist at this latitude."""
33
+
34
+
35
+ def midheaven(ramc: float, eps: float) -> float:
36
+ """Ecliptic longitude of the MC."""
37
+ return norm360(atan2d(sind(ramc), cosd(ramc) * cosd(eps)))
38
+
39
+
40
+ def ascendant(ramc: float, eps: float, lat: float) -> float:
41
+ """Ecliptic longitude of the ecliptic point rising in the east."""
42
+ return norm360(
43
+ atan2d(cosd(ramc), -(sind(ramc) * cosd(eps) + tand(lat) * sind(eps)))
44
+ )
45
+
46
+
47
+ def _lon_from_ra(ra: float, eps: float) -> float:
48
+ """Ecliptic longitude of the ecliptic point with right ascension ``ra``."""
49
+ return norm360(atan2d(sind(ra), cosd(ra) * cosd(eps)))
50
+
51
+
52
+ def _check_polar(lat: float, eps: float, system: str) -> None:
53
+ if abs(lat) >= 90.0 - eps:
54
+ raise PolarHousesError(
55
+ f"{system.capitalize()} houses are undefined at latitude {lat:.4f}° "
56
+ f"(|latitude| >= 90° - obliquity = {90.0 - eps:.4f}°): part of the "
57
+ "ecliptic never rises or sets. Use house_system='porphyry', 'equal' or "
58
+ "'whole_sign', or pass polar_fallback=..."
59
+ )
60
+
61
+
62
+ def placidus(ramc: float, eps: float, lat: float) -> Cusps:
63
+ """Placidus cusps by iterating on the diurnal semi-arc of each cusp.
64
+
65
+ Cusp 11/12 is the ecliptic point whose right ascension exceeds RAMC by
66
+ 1/3 resp. 2/3 of its own diurnal semi-arc; cusps 2/3 likewise trisect
67
+ the nocturnal semi-arc between the Ascendant and the IC.
68
+ """
69
+ _check_polar(lat, eps, "placidus")
70
+ tan_lat = tand(lat)
71
+
72
+ def cusp(offset: float, factor: float) -> float:
73
+ # RA(cusp) = RAMC + offset + factor * DSA(dec(cusp)), DSA = 90 + AD
74
+ lon = _lon_from_ra(ramc + offset + factor * 90.0, eps) # start: equator
75
+ for _ in range(100):
76
+ dec = asind(sind(eps) * sind(lon))
77
+ ad = asind(tan_lat * tand(dec))
78
+ new = _lon_from_ra(ramc + offset + factor * (90.0 + ad), eps)
79
+ if abs(norm180(new - lon)) < 1e-10:
80
+ return new
81
+ lon = new
82
+ return lon
83
+
84
+ mc = midheaven(ramc, eps)
85
+ asc = ascendant(ramc, eps, lat)
86
+ c11 = cusp(0.0, 1.0 / 3.0)
87
+ c12 = cusp(0.0, 2.0 / 3.0)
88
+ # RA(c2) = RAMC + 180 - 2/3 NSA = RAMC + 60 + 2/3 DSA ; RA(c3) = RAMC + 120 + 1/3 DSA
89
+ c2 = cusp(60.0, 2.0 / 3.0)
90
+ c3 = cusp(120.0, 1.0 / 3.0)
91
+ return _from_quadrant(asc, c2, c3, mc, c11, c12)
92
+
93
+
94
+ def koch(ramc: float, eps: float, lat: float) -> Cusps:
95
+ """Koch (birthplace) cusps.
96
+
97
+ Intermediate cusps are Ascendants computed for sidereal times that
98
+ trisect the diurnal semi-arc of the MC degree.
99
+ """
100
+ _check_polar(lat, eps, "koch")
101
+ mc = midheaven(ramc, eps)
102
+ dec_mc = asind(sind(eps) * sind(mc))
103
+ ad = asind(tand(lat) * tand(dec_mc))
104
+ dsa = 90.0 + ad
105
+ asc = ascendant(ramc, eps, lat)
106
+ c11 = ascendant(ramc - 2.0 * dsa / 3.0, eps, lat)
107
+ c12 = ascendant(ramc - dsa / 3.0, eps, lat)
108
+ c2 = ascendant(ramc + dsa / 3.0, eps, lat)
109
+ c3 = ascendant(ramc + 2.0 * dsa / 3.0, eps, lat)
110
+ return _from_quadrant(asc, c2, c3, mc, c11, c12)
111
+
112
+
113
+ def porphyry(ramc: float, eps: float, lat: float) -> Cusps:
114
+ """Porphyry: each quadrant between the angles is trisected in longitude."""
115
+ mc = midheaven(ramc, eps)
116
+ asc = ascendant(ramc, eps, lat)
117
+ q1 = norm360(asc - mc) # MC -> ASC (houses 10-12)
118
+ q2 = 180.0 - q1 # ASC -> IC (houses 1-3)
119
+ c11 = norm360(mc + q1 / 3.0)
120
+ c12 = norm360(mc + 2.0 * q1 / 3.0)
121
+ c2 = norm360(asc + q2 / 3.0)
122
+ c3 = norm360(asc + 2.0 * q2 / 3.0)
123
+ return _from_quadrant(asc, c2, c3, mc, c11, c12)
124
+
125
+
126
+ def equal(ramc: float, eps: float, lat: float) -> Cusps:
127
+ """Equal houses: 30° steps from the Ascendant."""
128
+ asc = ascendant(ramc, eps, lat)
129
+ return tuple(norm360(asc + 30.0 * i) for i in range(12)) # type: ignore[return-value]
130
+
131
+
132
+ def whole_sign(ramc: float, eps: float, lat: float) -> Cusps:
133
+ """Whole-sign houses: house 1 is the whole sign containing the Ascendant."""
134
+ asc = ascendant(ramc, eps, lat)
135
+ start = 30.0 * int(asc // 30.0)
136
+ return tuple(norm360(start + 30.0 * i) for i in range(12)) # type: ignore[return-value]
137
+
138
+
139
+ def _from_quadrant(
140
+ asc: float, c2: float, c3: float, mc: float, c11: float, c12: float
141
+ ) -> Cusps:
142
+ ic, dsc = norm360(mc + 180.0), norm360(asc + 180.0)
143
+ return (
144
+ asc,
145
+ c2,
146
+ c3,
147
+ ic,
148
+ norm360(c11 + 180.0),
149
+ norm360(c12 + 180.0),
150
+ dsc,
151
+ norm360(c2 + 180.0),
152
+ norm360(c3 + 180.0),
153
+ mc,
154
+ c11,
155
+ c12,
156
+ )
157
+
158
+
159
+ _SYSTEMS = {
160
+ "placidus": placidus,
161
+ "koch": koch,
162
+ "porphyry": porphyry,
163
+ "equal": equal,
164
+ "whole_sign": whole_sign,
165
+ }
166
+
167
+
168
+ def house_cusps(
169
+ ramc: float,
170
+ eps: float,
171
+ lat: float,
172
+ system: str = "placidus",
173
+ polar_fallback: str | None = None,
174
+ ) -> tuple[Cusps, str]:
175
+ """Compute cusps and return ``(cusps, system_actually_used)``.
176
+
177
+ If ``system`` is Placidus or Koch and the latitude is beyond the polar
178
+ circle, :class:`PolarHousesError` is raised unless ``polar_fallback``
179
+ names another system, which is then used instead.
180
+ """
181
+ if system not in _SYSTEMS:
182
+ raise ValueError(f"unknown house system {system!r}; choose from {HOUSE_SYSTEMS}")
183
+ try:
184
+ return _SYSTEMS[system](ramc, eps, lat), system
185
+ except PolarHousesError:
186
+ if polar_fallback is None:
187
+ raise
188
+ if polar_fallback not in _SYSTEMS or polar_fallback in _QUADRANT_SYSTEMS:
189
+ raise ValueError(
190
+ "polar_fallback must be one of 'porphyry', 'equal', 'whole_sign'"
191
+ ) from None
192
+ return _SYSTEMS[polar_fallback](ramc, eps, lat), polar_fallback
193
+
194
+
195
+ def house_of(longitude: float, cusps: Cusps) -> int:
196
+ """House number (1-12) containing an ecliptic longitude."""
197
+ lon = norm360(longitude)
198
+ for i in range(12):
199
+ start, end = cusps[i], cusps[(i + 1) % 12]
200
+ if norm360(lon - start) < norm360(end - start):
201
+ return i + 1
202
+ return 12 # pragma: no cover - only reachable with degenerate cusps
natal_chart/points.py ADDED
@@ -0,0 +1,76 @@
1
+ """Mean lunar node and mean Black Moon Lilith (mean lunar apogee).
2
+
3
+ Both come from the ELP-2000/82 mean-element polynomials as given by
4
+ J. Meeus, *Astronomical Algorithms* (2nd ed.), ch. 47:
5
+
6
+ * mean ascending node Ω = 125.0445479 − 1934.1362891 T + 0.0020754 T²
7
+ + T³/467441 − T⁴/60616000
8
+ * mean perigee ϖ = 83.3532465 + 4069.0137287 T − 0.0103200 T²
9
+ − T³/80053 + T⁴/18999000
10
+
11
+ with T in Julian centuries of TT from J2000.0. The values refer to the mean
12
+ equinox of date; we add the nutation in longitude so they are on the same
13
+ true-equinox-of-date frame as the planets.
14
+
15
+ The mean apogee (ϖ + 180°) lies on the inclined lunar orbit. Its ecliptic
16
+ longitude is obtained by projecting it from the orbit plane (inclination
17
+ 5.1453964°) onto the ecliptic, which is what most astrology software does.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ from ._angles import atan2d, cosd, norm360, sind
25
+
26
+ LUNAR_INCLINATION = 5.1453964 # degrees, mean inclination of the lunar orbit
27
+
28
+
29
+ def mean_node(T: float) -> float:
30
+ """Mean ascending lunar node, mean equinox of date (degrees)."""
31
+ return norm360(
32
+ 125.0445479
33
+ - 1934.1362891 * T
34
+ + 0.0020754 * T**2
35
+ + T**3 / 467441.0
36
+ - T**4 / 60616000.0
37
+ )
38
+
39
+
40
+ def mean_node_speed(T: float) -> float:
41
+ """Speed of the mean node in degrees per day."""
42
+ dT = -1934.1362891 + 2 * 0.0020754 * T + 3 * T**2 / 467441.0 - 4 * T**3 / 60616000.0
43
+ return dT / 36525.0
44
+
45
+
46
+ def mean_perigee(T: float) -> float:
47
+ """Mean longitude of the lunar perigee (Ω + ω, along ecliptic then orbit)."""
48
+ return norm360(
49
+ 83.3532465
50
+ + 4069.0137287 * T
51
+ - 0.0103200 * T**2
52
+ - T**3 / 80053.0
53
+ + T**4 / 18999000.0
54
+ )
55
+
56
+
57
+ def mean_apogee(T: float) -> tuple[float, float]:
58
+ """Mean lunar apogee (Black Moon Lilith): (ecliptic longitude, latitude).
59
+
60
+ Mean equinox of date, degrees.
61
+ """
62
+ node = mean_node(T)
63
+ # argument of the apogee measured in the orbit plane from the node
64
+ u = norm360(mean_perigee(T) + 180.0 - node)
65
+ i = LUNAR_INCLINATION
66
+ lon = norm360(node + atan2d(sind(u) * cosd(i), cosd(u)))
67
+ lat = math.degrees(math.asin(sind(u) * sind(i)))
68
+ return lon, lat
69
+
70
+
71
+ def mean_apogee_speed(T: float, h: float = 0.5) -> float:
72
+ """Speed of the mean apogee in degrees per day (central difference)."""
73
+ dT = h / 36525.0
74
+ a = mean_apogee(T - dT)[0]
75
+ b = mean_apogee(T + dT)[0]
76
+ return ((b - a + 540.0) % 360.0 - 180.0) / (2 * h)
natal_chart/py.typed ADDED
File without changes
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.5
2
+ Name: natal-chart
3
+ Version: 0.1.0
4
+ Summary: Tropical natal chart math (planets, Placidus/Koch/whole-sign houses, aspects, transits) on NASA JPL ephemerides via Skyfield. No Swiss Ephemeris.
5
+ Project-URL: Homepage, https://astralread.com
6
+ Project-URL: Repository, https://github.com/VictorMachiavelli/natal-chart
7
+ Project-URL: Issues, https://github.com/VictorMachiavelli/natal-chart/issues
8
+ Author-email: Victor Balakirev <balakirevvictor@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: aspects,astrology,ephemeris,horoscope,houses,jpl,natal chart,placidus,skyfield,transits
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: skyfield>=1.45
27
+ Requires-Dist: tzdata; sys_platform == 'win32'
28
+ Provides-Extra: test
29
+ Requires-Dist: pyswisseph>=2.10; extra == 'test'
30
+ Requires-Dist: pytest>=7; extra == 'test'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # natal-chart
34
+
35
+ Tropical natal chart math for Python: planet positions, houses
36
+ (Placidus, Koch, Porphyry, Equal, Whole Sign), Ascendant/MC, mean lunar node,
37
+ mean Black Moon Lilith, major aspects and transits.
38
+
39
+ Positions come from NASA JPL ephemerides through
40
+ [Skyfield](https://rhodesmill.org/skyfield/). The package does **not** use or
41
+ contain the Swiss Ephemeris, so it can be released under the MIT license.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install natal-chart
47
+ ```
48
+
49
+ Requires Python 3.10+. The only dependency is Skyfield (which brings NumPy).
50
+
51
+ The first call downloads the JPL DE440s ephemeris (~32 MB, covers
52
+ 1849-2150) into `~/.cache/natal-chart` (override with the `NATAL_CHART_DATA`
53
+ environment variable). It is never bundled with the package. To use a
54
+ different file, e.g. the smaller DE421 (1900-2050) or DE440 (1550-2650):
55
+
56
+ ```python
57
+ import natal_chart
58
+ natal_chart.set_ephemeris("de421.bsp") # or NATAL_CHART_EPHEMERIS=de421.bsp
59
+ ```
60
+
61
+ ## Quickstart
62
+
63
+ ```python
64
+ from natal_chart import chart, aspects, transits
65
+
66
+ # Fictional birth data: local time + IANA time zone
67
+ c = chart("1990-05-17T14:30", lat=55.75, lon=37.62, tz="Europe/Moscow")
68
+
69
+ c["Sun"].sign, round(c["Sun"].degree, 2) # ('Taurus', 26.26)
70
+ c["Saturn"].retrograde # True
71
+ c["Moon"].house # 5
72
+ c.ascendant.longitude, c.mc.longitude # 166.69..., 71.52...
73
+ c.cusps # 12 cusp longitudes, cusp 1 first
74
+
75
+ for a in aspects(c)[:3]:
76
+ print(a.first, a.aspect, a.second, round(a.orb, 2),
77
+ "applying" if a.applying else "separating")
78
+
79
+ for a in transits(c, "2026-11-10T12:00:00+00:00"):
80
+ print("transiting", a.first, a.aspect, "natal", a.second, round(a.orb, 2))
81
+ ```
82
+
83
+ `chart()` accepts a timezone-aware `datetime`, or a naive one plus `tz=`.
84
+ A naive time without `tz` is rejected rather than silently treated as UTC.
85
+
86
+ Points: Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune,
87
+ Pluto, `"Mean Node"`, `"Lilith"`, `"Ascendant"`, `"MC"`. Each `Point` has
88
+ `longitude`, `latitude`, `speed` (°/day), `sign`, `degree` (within sign),
89
+ `retrograde` and `house`.
90
+
91
+ ### Aspects
92
+
93
+ `aspects(chart, orbs=None, points=None)` finds conjunction, sextile, square,
94
+ trine and opposition, tightest first. Default orbs:
95
+ conjunction/opposition 8°, trine/square 7°, sextile 5°. Pass your own
96
+ mapping, e.g. `orbs={"conjunction": 10, "square": 6}` (aspects left out are
97
+ not searched). `applying` is decided from the current speeds of both points.
98
+
99
+ `transits(natal, when, orbs=None)` compares the transiting planets with every
100
+ natal point (default orb 1°). The natal point is treated as fixed.
101
+
102
+ ### Command line
103
+
104
+ ```text
105
+ $ natal-chart 1990-05-17T14:30 --tz Europe/Moscow --lat 55.75 --lon 37.62 --aspects
106
+ 1990-05-17 10:30:00 UTC lat 55.7500 lon 37.6200 houses: placidus
107
+
108
+ Point Sign Degree Longitude Speed R House
109
+ Sun Taurus 26°15'49" 56.2636 0.9636 9
110
+ Moon Aquarius 21°37'13" 321.6204 12.9387 5
111
+ ...
112
+ ```
113
+
114
+ Options: `--houses {placidus,koch,porphyry,equal,whole_sign}`,
115
+ `--polar-fallback {porphyry,equal,whole_sign}`, `--aspects`,
116
+ `--transits DATETIME`, `--json`. Without `--tz` the time is taken as UTC.
117
+
118
+ ## What exactly is computed
119
+
120
+ * **Planets**: geocentric *apparent* positions (light-time, aberration,
121
+ gravitational deflection), ecliptic longitude and latitude referred to the
122
+ **true equinox and ecliptic of date** (IAU 2006 precession + IAU 2000A
123
+ nutation), tropical zodiac. This is the same convention as the default
124
+ output of most astrology software. Mars-Pluto use the planetary-system
125
+ barycenter segments of the JPL file; the offset from the planet's centre
126
+ is below 0.1" as seen from Earth.
127
+ * **Speed**: central difference over ±0.01 day, degrees per day.
128
+ * **Mean node / mean Lilith**: ELP-2000/82 mean-element polynomials
129
+ (Meeus, *Astronomical Algorithms*, ch. 47), plus nutation in longitude.
130
+ Lilith (mean lunar apogee) is projected from the inclined lunar orbit onto
131
+ the ecliptic, so it also has a small ecliptic latitude.
132
+ * **Houses**: RAMC from Skyfield's Greenwich apparent sidereal time plus the
133
+ east longitude; true obliquity of date. Placidus is computed by iterating
134
+ each cusp on its own diurnal/nocturnal semi-arc; Koch from Ascendants at
135
+ the times that trisect the MC degree's semi-arc.
136
+ * **Time**: from 1972 the input is UTC with leap seconds. Before 1972 the
137
+ given UTC is treated as UT1 (civil time followed UT then); Delta T comes
138
+ from Skyfield's built-in model. Ambiguous or non-existent local times at
139
+ DST changes follow Python's `datetime.fold` rule (`fold=0` by default).
140
+
141
+ ### High latitudes
142
+
143
+ Placidus and Koch are undefined when part of the ecliptic never rises or sets,
144
+ i.e. for |latitude| ≥ 90° − obliquity (about 66.56°). There `chart()` raises
145
+ `PolarHousesError` with an explanation. Pass
146
+ `polar_fallback="porphyry"` (or `"equal"`, `"whole_sign"`) to get a chart
147
+ anyway; then `chart.house_system` names the system actually used and
148
+ `chart.polar_fallback_used` is `True`. Equal, Whole Sign and Porphyry work at
149
+ any latitude short of the poles.
150
+
151
+ ## Accuracy
152
+
153
+ Measured against the Swiss Ephemeris 2.10 (pyswisseph, with its
154
+ `sepl_18`/`semo_18` data files) on 300 random fictional dates 1900-2100 and
155
+ latitudes within ±66°. The comparison is run at identical instants (same TT
156
+ for bodies, same UT1 for houses) so that what is measured is ephemeris,
157
+ reduction and house math, not the two libraries' Delta T models.
158
+
159
+ | Quantity | Max difference |
160
+ | --- | --- |
161
+ | Sun ... Pluto, longitude | 0.007" |
162
+ | Sun ... Pluto, latitude | 0.003" |
163
+ | Speeds | 0.00008 °/day |
164
+ | Mean Node | 0.11" |
165
+ | Mean Lilith (longitude / latitude) | 0.54" / 0.04" |
166
+ | Ascendant, MC | 0.000003° |
167
+ | Placidus, Koch, Porphyry, Equal cusps | 0.000006° |
168
+
169
+ Things that differ between libraries and are *not* in the table:
170
+
171
+ * **Delta T for future dates.** Neither library can know future Earth
172
+ rotation; their predictions differ by a few seconds after 2026, which moves
173
+ the Moon (0.5"/s) by up to ~13" at the same civil time (we measured 0.003"
174
+ for 1972-2025 and 0.4" for 1900-1971).
175
+ * **Sidereal time after 2050.** From 2050-01-01 the Swiss Ephemeris switches
176
+ to its own long-term sidereal-time formula, which differs from the IERS
177
+ expression used here by ~1.9" (0.13 s of time).
178
+
179
+ The comparison test (`tests/test_vs_swisseph.py`) runs only when the
180
+ optional `pyswisseph` dev dependency is installed; point `SE_EPHE_PATH` at the
181
+ Swiss data files to compare against the full Swiss Ephemeris instead of its
182
+ built-in Moshier fallback.
183
+
184
+ ## Ephemeris data
185
+
186
+ NASA JPL DE440 (public domain) via Skyfield. Default file DE440s; the
187
+ file is downloaded on first use and is not part of this package.
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ python -m venv .venv && .venv/bin/pip install -e ".[test]"
193
+ .venv/bin/pytest -s
194
+ ```
195
+
196
+ All birth data in the tests is fictional. `pyswisseph` (AGPL) appears only in
197
+ the optional test extra and is never imported by the package.
198
+
199
+ ## License
200
+
201
+ MIT. See [LICENSE](LICENSE).
202
+
203
+ ---
204
+
205
+ Built by the team behind [AstralRead](https://astralread.com) — free natal
206
+ chart & AI astrologer.
@@ -0,0 +1,14 @@
1
+ natal_chart/__init__.py,sha256=9T-WJMOUAw0jYDaAVecEpfa4q29ZA2S2QhjejpMsQjc,880
2
+ natal_chart/_angles.py,sha256=pL9voqEl5xgRtA3ZnYpWW700pKiNMG1iif6qNJ9h5hA,1512
3
+ natal_chart/_ephemeris.py,sha256=Ps3jESFhy6xfKCDc8m7two_IDzRg_FwizFyHl1b2Z6E,1733
4
+ natal_chart/aspects.py,sha256=64yia5Xcbf6EuUJy9S28KAbYeZJgEOzM46nPEn3OViQ,4366
5
+ natal_chart/chart.py,sha256=VjsJRj-d6gweXGts35gdHwM8E11K8edceQBph4_WpQ0,8421
6
+ natal_chart/cli.py,sha256=JUcPPwPelDfYh4LWLcAsZwbsPIDrfE8gIIaBthouavY,4742
7
+ natal_chart/houses.py,sha256=qBZiXmzEJfUTqAEfalLw0JsA5S7vbuwe50A5RhCHqPc,7113
8
+ natal_chart/points.py,sha256=hOZg90nJsYc7mjzYXXEUb7NQB77DTrvPoxrLG1IwXoE,2501
9
+ natal_chart/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ natal_chart-0.1.0.dist-info/METADATA,sha256=CKDP75WFFX3veiXzxSzHEs_madrZ40Qp4Nu_iPDMr-w,8644
11
+ natal_chart-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ natal_chart-0.1.0.dist-info/entry_points.txt,sha256=RaKlTrmq6ejcUDcpuY1CJZuYH0CKDLEMa9oQTqKtnXM,53
13
+ natal_chart-0.1.0.dist-info/licenses/LICENSE,sha256=UGbCTv1llXHxJ-jOi3S_rk8wFiWtAvlFiW1sqWLeI6o,1073
14
+ natal_chart-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ natal-chart = natal_chart.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Victor Balakirev
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.