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.
- natal_chart/__init__.py +42 -0
- natal_chart/_angles.py +69 -0
- natal_chart/_ephemeris.py +61 -0
- natal_chart/aspects.py +136 -0
- natal_chart/chart.py +256 -0
- natal_chart/cli.py +130 -0
- natal_chart/houses.py +202 -0
- natal_chart/points.py +76 -0
- natal_chart/py.typed +0 -0
- natal_chart-0.1.0.dist-info/METADATA +206 -0
- natal_chart-0.1.0.dist-info/RECORD +14 -0
- natal_chart-0.1.0.dist-info/WHEEL +4 -0
- natal_chart-0.1.0.dist-info/entry_points.txt +2 -0
- natal_chart-0.1.0.dist-info/licenses/LICENSE +21 -0
natal_chart/__init__.py
ADDED
|
@@ -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,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.
|