The Moira Service Layer is not merely a collection of helper functions; it is a Sovereign Orchestration Layer. The Moira class serves as the High Priestess and Dependency Injection (DI) Container, binding a persistent SpkReader (JPL DE441) to a vast pantheon of computational sub-engines.
For admitted service calls, the facade enforces one contract: computation
flows through the Moira instance. This keeps reader ownership, time policy,
and public doctrine coherent. Deliberately module-direct research and
validation products may remain outside the service layer; they do not acquire
facade or REST status merely because the engine can compute them. The
topography-conditioned lunar-contact surface is one such explicit boundary.
When a service method (e.g., m.conjunctions()) is invoked, the facade executes the following Liturgy of Transformation:
julian.py substrate. The Meeus algorithm handles all proleptic Gregorian dates, including BCE dates via astronomical year numbering (0 = 1 BC, −1 = 2 BC).phenomena.py, dasha.py) while passing the Moira instance’s own _reader to ensure I/O consistency.slots-optimized vessel and returned to the caller. All result records are immutable. The root moira.Chart vessel is frozen=True, slots=True as of 2.0.0.The Moira facade exposes 70+ public methods organized into ten sovereign domains:
| Domain | Representative Methods | Governance Module |
|---|---|---|
| Chart Construction | chart(), houses(), sky_position() |
planets.py, houses.py |
| Aspects | aspects(), antiscia() |
aspects.py |
| Phenomena | conjunctions_in_range(), moon_phases_in_range(), greatest_elongation(), resonance() |
phenomena.py |
| Eclipses | eclipse() |
eclipse.py |
| Stations & Retrogrades | stations(), retrograde_periods(), is_retrograde() |
stations.py |
| Synastry | synastry_aspects(), house_overlay(), composite_chart(), davison_chart() |
synastry.py |
| Time Lords | vimshottari_dasha(), firdaria(), zodiacal_releasing() |
dasha.py, timelords.py |
| Progressions | progression(), solar_arc_directions(), tertiary_progression(), converse_progression() |
progressions.py |
| Transits & Returns | transits(), ingresses(), solar_return(), lunar_return() |
transits.py |
| Techniques | lots(), dignities(), midpoints(), harmonic(), profection(), planetary_hours(), astrocartography(), local_space() |
various |
| Sidereal | sidereal_chart(), nakshatras() |
sidereal.py |
| Fixed Stars | fixed_star(), heliacal_rising() |
stars.py |
The REST access surface must not collapse a facade-visible reduction pipeline into an opaque final-only payload by default and then leave no lawful path to recover the omitted truth.
This is a hard service-layer rule, derived from Moira’s light-box doctrine:
Therefore, for any REST endpoint backed by a facade or engine call that carries reduction truth, the transport contract must support one of the following:
result and reduction
sectionsWhat the REST layer must preserve when the underlying engine surface exposes it:
What the REST layer may do for transport economy:
What the REST layer may not do:
This rule applies repository-wide to moira_server models, serializers,
services, and routers.
Planetary positional truth originates from the active admitted JPL DE-series
binary SPK reader. DE441 (de441.bsp) is the flagship full-range reader; the
ordinary planetary path also admits DE430 and DE440, with coverage determined
by the loaded kernel. The SpkReader class in spk_reader.py provides native
memory-mapped access to those Chebyshev state vectors.
SpkReader
├── __init__(kernel_path) → opens DAF/BSP through Moira's native reader when supported
├── position(center, target, jd) → Vec3 (x, y, z) in km, ICRF
└── position_and_velocity(center, target, jd) → (Vec3, Vec3) in km, km/day
The kernel uses a two-epoch structure: one segment covers −13200 to 1969, and another covers 1969 to 17191. The SpkReader automatically selects the correct segment for any query.
DE441 stores positions relative to various barycenters. To obtain a body’s position relative to a desired center, the engine chains multiple SPK segments using NAIF ID routes:
| Body | Route | Meaning |
|---|---|---|
| Sun | (0→10) |
SSB → Sun |
| Moon | (3→301) |
Earth-Moon Barycenter → Moon |
| Mercury | (0→1), (1→199) |
SSB → Mercury Barycenter → Mercury |
| Venus | (0→2), (2→299) |
SSB → Venus Barycenter → Venus |
| Mars | (0→4) |
SSB → Mars Barycenter |
| Jupiter–Pluto | (0→N) |
SSB → Planet Barycenter |
| Earth | (0→3), (3→399) |
SSB → EMB → Earth |
The route endpoint is part of result identity. In particular, DE441 exposes
Mars through Pluto on the ordinary Moira path as system barycenters (4
through 9), not planet centers. A Horizons validation query must use the same
target command; otherwise the comparison measures two different objects.
The geocentric position of any body is computed by:
[(0,3), (3,399)] to get Earth’s SSB-relative position.xyz_geo = xyz_body_ssb − xyz_earth_ssb.The SpkReader is managed as a thread-safe module-level singleton via get_reader() and an RLock:
get_reader(kernel_path) — returns the cached instance, creating it on first call.set_kernel_path(path) — configures the kernel path before first access.The crown jewel of the Moira engine is the Seven-Step Apparent Position Pipeline, implemented across corrections.py and coordinates.py. This transforms raw ICRF barycentric state vectors into the true apparent ecliptic position an observer would see.
Module: corrections.apply_light_time()
The photon from a distant planet takes time to reach Earth. The planet’s position must be evaluated at t − τ, where τ = d/c.
t, calculate distance d₀ and initial light-time τ₀ = d₀/c. Then re-evaluate the planet at t − τ₀ and compute the final corrected τ₁.C_KM_PER_DAY = 299,792.458 × 86,400 = 25,902,068,371.2 km/dayModule: corrections.apply_aberration()
Earth’s orbital velocity (~29.8 km/s) causes an apparent displacement of all celestial objects in the direction of motion.
β = v_earth / c
γ = 1 / √(1 − β²)
u' = [u + (1 + (u·β)/(1+γ))·β] / [γ(1 + u·β)]
where u is the unit vector toward the body and β is Earth’s velocity vector in units of c.
Module: corrections.apply_deflection()
The Sun’s gravitational field bends light passing near it, displacing apparent positions.
deflection = (2 * r_s / d_sun) * [(u·q_sun)·p_sun − (p_sun·q_sun)·u]
where r_s = 2.95325008 km (solar Schwarzschild radius), q_sun is the Sun’s unit vector, and p_sun is the body’s unit vector.
cos(ψ) < −0.9999999 (anti-solar point).Module: corrections.apply_frame_bias()
The ICRF (International Celestial Reference Frame) is not exactly aligned with the J2000.0 dynamical frame used by precession/nutation theories.
dα₀ = −14.6 mas (right ascension origin offset)ξ₀ = −16.6170 mas (x-axis tilt)dε₀ = −6.8192 mas (y-axis tilt)Module: coordinates.precession_matrix_equatorial() → delegates to precession.py
The Earth’s spin axis slowly traces a cone with a ~25,772-year period. Precession rotates the J2000.0 mean equator/equinox to the mean equator/equinox of the observation date.
Module: coordinates.nutation_matrix_equatorial() → delegates to nutation_2000a.py
Short-period oscillations of the Earth’s axis caused by the Moon’s orbital plane and solar gravitational torques.
06A) precession-nutation stack.l), mean anomaly of Sun (l'), mean argument of latitude (F), mean elongation of Moon (D), longitude of ascending node (Ω).pₐ).(Δψ, Δε) in degrees.N = R₁(−ε) · R₃(−Δψ) · R₁(ε₀) where ε₀ is mean obliquity.Module: corrections.topocentric_correction()
Converts geocentric positions to the observer’s actual location on Earth’s surface.
f = 1/298.257223563).Raw SPK (ICRF, Barycentric, at time t)
│
├─[1] Light-Time ──→ ICRF, Geocentric, at time t−τ
├─[2] Aberration ──→ ICRF, Geocentric, apparent direction
├─[3] Deflection ──→ ICRF, Geocentric, gravity-corrected
├─[4] Frame Bias ──→ J2000 Dynamical, Geocentric
├─[5] Precession ──→ Mean Equator of Date
├─[6] Nutation ──→ True Equator of Date
└─[7] Parallax ──→ Topocentric, True Equinox of Date
│
└── icrf_to_true_ecliptic() → (λ, β, Δ)
When apparent=False is passed to planet_at(), only the light-time correction and basic geocentric transformation are applied (geometric position).
All internal timestamps are expressed in Julian Days (JD), a continuous count of days since January 1, 4713 BCE at noon UT.
julian_day(year, month, day, hour) → JD via the Meeus algorithm, valid for any proleptic Gregorian date.jd_from_datetime(dt) → JD from a timezone-aware Python datetime (naïve datetimes raise ValueError).calendar_from_jd(jd) → (year, month, day, decimal_hour).datetime_from_jd(jd) → Python datetime (limited to 1 AD–9999 AD).calendar_datetime_from_jd(jd) → CalendarDateTime dataclass (BCE-safe via astronomical year numbering).@dataclass(frozen=True, slots=True)
class CalendarDateTime:
year: int # astronomical: 0 = 1 BC, −1 = 2 BC
month: int
day: int
hour: int
minute: int
second: int
microsecond: int = 0
tzname: str = "UTC"
This vessel exists because Python’s datetime cannot represent dates before 1 AD. All BCE-era calculations (e.g., ancient eclipse searches) use this type.
The difference between Terrestrial Time (uniform, atomic) and Universal Time (tied to Earth’s irregular rotation) is denoted ΔT. Moira interpolates ΔT from five historical tables:
| Era | Source | Method |
|---|---|---|
| 1600–1900 | Historical reconstructions | 5-year interpolation |
| 1900–1955 | Pre-modern observations | 5-year interpolation |
| 1955–2015 | IERS observed values | 5-year interpolation |
| 2015–2026 | IERS annual values | Annual interpolation |
| 2026+ / ancient | HPIERS 2016 long-range model | Polynomial extrapolation |
delta_t(decimal_year) → seconds.ut_to_tt(jd_ut) → jd_ut + delta_t / 86400.tt_to_ut(jd_tt) → JD UT via iterative inversion (since ΔT depends on the unknown UT).greenwich_mean_sidereal_time(jd_ut) → GMST in degrees.apparent_sidereal_time(jd_ut, Δψ, ε) → GAST = GMST + Δψ·cos(ε) (nutation-corrected).local_sidereal_time(jd_ut, longitude, Δψ, ε) → LST = GAST + λ_observer.Governance: moira/phenomena.py
The Phenomena services identify discrete celestial milestones using a Two-Phase Discovery Archetype.
The service performs a coarse-grained scan using geometric positions (raw SPK, no apparent pipeline). Step sizes are body-dependent and event-dependent:
The scan detects sign changes in a discriminant function (for zero-crossings like conjunctions and phases) or slope reversals (for extrema like elongations and apsides).
Once a crossing or extremum is localized to a coarse interval, the service activates the full Apparent Pipeline and applies:
@dataclass(slots=True)
class PhenomenonEvent:
body: str # e.g., "Venus"
phenomenon: str # e.g., "greatest_eastern_elongation"
jd_ut: float # precise Julian Day of event
value: float # e.g., elongation angle in degrees
@dataclass(slots=True)
class OrbitalResonance:
ratio: float # raw period ratio (e.g., 1.6255)
synodic_period: float # days
harmonic_ratio: str # "13:8"
near_integer: tuple # (13, 8)
error: float # fractional deviation
| Function | Description |
|---|---|
greatest_elongation(body, jd_start, direction, max_days) |
Mercury/Venus max angular distance from Sun |
perihelion(body, jd_start, max_days) |
Closest approach to Sun |
aphelion(body, jd_start, max_days) |
Furthest distance from Sun |
next_moon_phase(phase_name, jd_start) |
Exact moment of named Moon phase |
moon_phases_in_range(jd_start, jd_end) |
All 8 phases chronologically |
next_conjunction(body1, body2, jd_start) |
Zero longitudinal separation |
conjunctions_in_range(body1, body2, jd_start, jd_end) |
All conjunctions in window |
resonance(body1, body2) |
Orbital resonance via continued fractions |
The resonance() service derives harmonic ratios from raw orbital periods using a Continued Fraction Approximation:
Input: ratio = T_earth / T_venus = 1.6255...
Algorithm:
x = 1.6255
a₀ = 1, remainder = 1/(1.6255 − 1) = 1.5988...
a₁ = 1, remainder = 1/(1.5988 − 1) = 1.6686...
a₂ = 1, remainder = 1/(1.6686 − 1) = 1.4957...
...convergents: 1/1, 2/1, 3/2, 5/3, 8/5, 13/8 ←── Venus Rose!
Output: OrbitalResonance(ratio=1.6255, harmonic_ratio="13:8", error=0.0005)
The algorithm halts when the denominator exceeds max_denominator=50, producing the best rational approximation. This mathematically identifies the “Heartbeat of the Sphere” from raw orbital periods rather than relying on look-up tables.
Governance: moira/eclipse.py, moira/eclipse_besselian.py
The eclipse engine combines lunisolar geometry, event search, contact solving,
local circumstances, and shadow projection. Its Besselian surface is a
separate instantaneous engine product; it is not embedded in the general
EclipseData snapshot and does not perform an event search.
calculate(dt) → EclipseData — general geometry snapshot at the supplied
aware datetime.calculate_jd(jd_ut1) → EclipseData — the same snapshot at the supplied
UT1 Julian Day.solar_besselian_elements(jd_ut1) → SolarBesselianElements — native
fundamental-plane geometry at that instant. It converts UT1 to the
reader-bound TT epoch, requires a content-identified DE441/LE441 reader, and
does not find a nearest eclipse or fit a Besselian polynomial.| Solar | Lunar |
|---|---|
| Total | Total |
| Partial | Partial |
| Annular | Penumbral |
| Hybrid (Annular-Total) | — |
@dataclass(frozen=True, slots=True)
class EclipseData:
eclipse_type: EclipseType
eclipse_magnitude: float
saros_index: float
metonic_year: float
# Positions, apparent radii, separation, and cycle state omitted here.
...
@dataclass(frozen=True, slots=True)
class EclipseEvent:
jd_ut: float
data: EclipseData
@dataclass(frozen=True, slots=True)
class SolarBesselianElements:
jd_ut1: float
jd_tt: float
x: float
y: float
d: float
mu: float
l1: float
l2: float
tan_f1: float
tan_f2: float
ephemeris: str
@dataclass(frozen=True, slots=True)
class SolarEclipseLocalCircumstances:
# Observer-specific eclipse visibility
...
@dataclass(frozen=True, slots=True)
class LunarEclipseAnalysis:
# Penumbral/umbral geometry
...
SolarBesselianElements uses the DE441 Earth-reception light-time Sun/Moon
center-of-mass shadow line with no stellar aberration, expressed in the true
equator and equinox of date. x and y are east-positive and north-positive;
x, y, l1, and l2 are in Earth equatorial radii; d and mu are in
degrees; and tan_f1 and tan_f2 are dimensionless. mu is the TT/TDT
ephemeris hour angle, not physical UT1 GAST. Moira’s mean-limb physical radii
govern the cones, while l2 follows the NASA fundamental-plane sign convention:
negative for an umbral cone and positive for an antumbral cone. Global hybrid
classification remains a separate Earth-surface result.
This admission is engine-only. The Moira facade, FastAPI routes and schemas,
existing eclipse event/path vessels, and native C++ substrate are unchanged.
EclipseData.saros_index and .metonic_year are continuous cycle-position
indicators. They are not catalog Saros-series identity and position fields.
Governance: moira/stations.py
Stations (the apparent standstills of planets as they switch between direct and retrograde motion) are detected via zero-crossing analysis of the planet’s daily speed.
planet_at(body, jd).speed at each step.speed[i] > 0 and speed[i+1] < 0 (or vice versa), a station is bracketed.@dataclass(slots=True)
class StationEvent:
body: str # e.g., "Mars"
station_type: str # "retrograde" (SR) or "direct" (SD)
jd_ut: float # precise Julian Day
longitude: float # ecliptic longitude at station
| Function | Description |
|---|---|
find_stations(body, jd_start, jd_end) |
All SR/SD stations in range |
next_station(body, jd_start, max_days) |
First upcoming station |
is_retrograde(body, jd) |
Boolean test at any instant |
retrograde_periods(body, jd_start, jd_end) |
List of (SR_jd, SD_jd) tuples |
Governance: moira/dasha.py, moira/timelords.py
Unlike the searcher-based Phenomena services, the Temporal services are Recursive Solvers that divide life into nested hierarchical periods.
The 120-year Vimshottari Cycle is governed by nine planetary lords, each ruling a fixed number of years:
| Lord | Years | Lord | Years |
|---|---|---|---|
| Ketu | 7 | Rahu | 18 |
| Venus | 20 | Jupiter | 16 |
| Sun | 6 | Saturn | 19 |
| Moon | 10 | Mercury | 17 |
| Mars | 7 | Total | 120 |
Sequence: Ketu → Venus → Sun → Moon → Mars → Rahu → Jupiter → Saturn → Mercury → (repeat)
Algorithm:
| Level | Name | Division |
|---|---|---|
| 1 | Mahadasha | 120-year cycle ÷ 9 lords |
| 2 | Antardasha | Each Mahadasha ÷ 9 lords |
| 3 | Pratyantardasha | Each Antardasha ÷ 9 lords |
| 4 | Sookshma | Each Pratyantardasha ÷ 9 lords |
| 5 | Prana | Each Sookshma ÷ 9 lords |
Each level is calculated as a fraction of its parent’s span, maintained with sub-microsecond precision in the Julian Day substrate.
Doctrinal Policies: Users inject a VimshottariComputationPolicy to customize:
@dataclass(frozen=True, slots=True)
class VimshottariComputationPolicy:
year: VimshottariYearPolicy # "julian_365.25" or "savana_360"
ayanamsa: VimshottariAyanamsaPolicy # Lahiri, Raman, Krishnamurti, etc.
The year basis choice affects every period boundary: Julian (365.25 days/year) produces longer absolute durations than Vedic Savana (360 days/year).
Data Vessels:
@dataclass(slots=True)
class DashaPeriod:
level: int # 1–5
planet: str # ruling lord
start_jd: float # period start
end_jd: float # period end
year_days: float # year length used (365.25 or 360)
sub: list[DashaPeriod] # nested children
year_basis: str # doctrinal provenance
birth_nakshatra: str # computed nakshatra
nakshatra_fraction: float # fraction elapsed at birth
lord_type: str # LUMINARY, INNER, OUTER, NODE
@dataclass(slots=True)
class DashaActiveLine:
mahadasha: str
antardasha: str
pratyantardasha: str
sookshma: str
prana: str
Analytical Functions:
| Function | Returns |
|---|---|
vimshottari(moon_lon, natal_jd, levels, ...) |
Full 120-year period tree |
current_dasha(moon_lon, natal_jd, current_jd, levels) |
Active periods at query moment |
dasha_balance(moon_lon, natal_jd) |
(lord, remaining_years) at birth |
dasha_active_line(periods) |
Named relational chain |
dasha_condition_profile(period) |
Integrated local condition |
dasha_sequence_profile(periods) |
Chart-wide aggregate stats |
dasha_lord_pair(line) |
Network node for lord pairing |
validate_vimshottari_output(periods) |
Structural invariant checker |
The Firdaria system assigns planetary rulerships based on sect (day vs. night chart):
Each major period is subdivided into sub-periods ruled by the other planets. The firdaria() function generates the complete sequence as a list of FirdarPeriod vessels.
Zodiacal Releasing projects a Lot (e.g., Lot of Fortune, Lot of Spirit) through the signs of the zodiac, with each sign’s duration determined by its planetary ruler’s “minor years”:
@dataclass(slots=True)
class ReleasingPeriod:
sign: str # zodiac sign
lord: str # sign ruler
start_jd: float
end_jd: float
level: int # 1 (major), 2 (sub), etc.
peak: bool # angular to Fortune = "peak period"
The service generates nested periods (major → sub → sub-sub) allowing for detailed life-phase analysis.
Governance: moira/synastry.py
The Relational services orchestrate truth between two or more discrete state snapshots. Four distinct techniques are supported:
synastry_aspects(chart_a, chart_b, tier, orbs, orb_factor) computes every admitted aspect between the planets of two charts:
list[AspectData] with cross-chart body references.house_overlay(chart_source, target_houses) projects the planetary positions of one chart into the house framework of another:
chart_source, determines which house of target_houses it falls in.SynastryHouseOverlay with a list of HousePlacement vessels.mutual_house_overlays() performs both directions simultaneously.composite_chart(chart_a, chart_b) generates a virtual synthetic chart by computing the spatial midpoints of corresponding planetary positions:
CompositeChart with synthesized planets, nodes, and houses.Unlike the abstract Composite, the Davison produces a real chart cast for the temporal and geographic midpoint of two births:
@dataclass(slots=True)
class DavisonInfo:
jd_a: float # natal JD person A
jd_b: float # natal JD person B
lat_a, lon_a: float # birth coordinates A
lat_b, lon_b: float # birth coordinates B
midpoint_jd: float # (jd_a + jd_b) / 2
midpoint_lat: float # (lat_a + lat_b) / 2
midpoint_lon: float # (lon_a + lon_b) / 2
method: str # "arithmetic" | "spherical" | "corrected"
Multiple Davison variants exist:
davison_chart() — arithmetic midpoint (default).davison_chart_spherical_midpoint() — great-circle midpoint on the sphere.davison_chart_corrected() — corrected for geographic curvature.davison_chart_reference_place() — midpoint time, user-specified location.All synastry operations accept granular policy injection:
@dataclass(frozen=True, slots=True)
class SynastryComputationPolicy:
aspect: SynastryAspectPolicy
overlay: SynastryOverlayPolicy
composite: SynastryCompositePolicy
davison: SynastryDavisonPolicy
Governance: moira/aspects.py
The aspect engine classifies 24 distinct aspects across three tiers and two domains:
| Tier | Aspects | Count |
|---|---|---|
| Major | Conjunction (0°), Sextile (60°), Square (90°), Trine (120°), Opposition (180°) | 5 |
| Common Minor | Semisextile (30°), Semisquare (45°), Quintile (72°), Sesquiquadrate (135°), Biquintile (144°), Quincunx (150°) | 6 |
| Extended Minor | Septile (51.43°), Novile (40°), Decile (36°), Tridecile (108°), and others | 11 |
| Aspect | Condition |
|---|---|
| Parallel | Same declination (within orb) |
| Contra-Parallel | Equal but opposite declination (within orb) |
Every detected aspect carries a full classification descriptor:
@dataclass(frozen=True, slots=True)
class AspectClassification:
domain: AspectDomain # ZODIACAL or DECLINATION
tier: AspectTier # MAJOR, COMMON_MINOR, EXTENDED_MINOR
family: AspectFamily # CONJUNCTION, OPPOSITION, SQUARE, TRINE, SEXTILE,
# QUINTILE, SEPTILE, NOVILE, ...
Orbs are stored in a DEFAULT_ORBS dictionary keyed by aspect angle. An orb_factor multiplier allows global tightening or widening:
1.0 = default orbs (e.g., 8° for conjunction, 6° for trine).0.5 = tight orbs (e.g., 4° conjunction, 3° trine).1.5 = wide orbs (e.g., 12° conjunction, 9° trine).When longitudinal speeds are available, the engine determines motion state:
@dataclass(slots=True)
class AspectData:
body1: str # e.g., "Sun"
body2: str # e.g., "Saturn"
aspect: str # e.g., "Square"
angle: float # exact aspect angle (90.0)
separation: float # actual angular distance
orb: float # |separation − angle|
allowed_orb: float # maximum admitted orb
applying: bool | None # True, False, or None (no speed data)
stationary: bool # body near standstill
classification: AspectClassification
@dataclass(slots=True)
class AspectStrength:
orb: float
allowed_orb: float
surplus: float # allowed_orb − orb (positive = admitted)
exactness: float # 1.0 − (orb / allowed_orb), range [0, 1]
The engine identifies multi-body geometric configurations from aspect lists:
| Pattern | Definition |
|---|---|
| T-Square | Two planets in opposition, both square a third |
| Grand Trine | Three mutual trines forming an equilateral triangle |
| Grand Cross | Four planets in two oppositions and four squares |
| Yod | Two planets sextile each other, both quincunx a third (Finger of God) |
| Stellium | Three or more conjunctions in tight cluster |
| Kite | Grand trine with one planet opposed to one corner |
| Mystic Rectangle | Two oppositions connected by sextiles and trines |
@dataclass(slots=True)
class AspectPattern:
kind: str # "T-Square", "Grand Trine", etc.
bodies: list[str] # participating planets
aspects: list[AspectData] # constituent aspects
build_aspect_graph(aspects, bodies) converts the flat aspect list into a relational network:
@dataclass(slots=True)
class AspectGraph:
nodes: list[AspectGraphNode]
edges: list[AspectData]
components: list[list[str]] # connected subgraphs
@dataclass(slots=True)
class AspectGraphNode:
name: str # planet name
degree: int # number of aspects
edges: list[AspectData] # incident aspects
family_counts: dict # {TRINE: 2, SQUARE: 1, ...}
This enables structural queries like “which planet is the most aspected?” or “are there isolated planets with no major aspects?”
Governance: moira/houses.py
Moira implements 21 house systems spanning every major tradition:
| System | Method | |——–|——–| | Equal | 30° from Ascendant | | Whole Sign | Sign boundaries from Ascendant’s sign | | Vehlow | Equal houses offset by 15° (cusps at mid-sign) | | Morinus | Equal divisions of the celestial equator | | Meridian | Equal divisions from the MC |
| System | Method | |——–|——–| | Placidus | Trisection of diurnal/nocturnal semi-arcs (iterative) | | Koch | Ascendant’s birth-place semi-arc projected onto ecliptic | | Porphyry | Trisection of quadrant arcs (direct) | | Campanus | Prime vertical great circles projected onto ecliptic | | Regiomontanus | Celestial equator divisions projected onto ecliptic | | Alcabitius | Diurnal semi-arc trisection (similar to Placidus variant) | | Topocentric | Polich-Page: observer-centered conic sections | | Azimuthal / Horizontal | Horizon-based divisions | | Carter (Poli-Equatorial) | Equal ARMC divisions | | Krusinski | Great circles through N/S horizon points | | APC | Ascendant-Parallel-Circle |
| System | Method | |——–|——–| | Sunshine (Makransky) | Divisions based on Sun’s position relative to horizon |
@dataclass(slots=True)
class HouseCusps:
cusps: list[float] # 12 ecliptic longitudes
asc: float # Ascendant
mc: float # Midheaven (MC)
east_point: float # East Point / Equatorial Ascendant
vertex: float # Vertex
armc: float # ARMC (sidereal time in degrees)
obliquity: float # True obliquity of ecliptic
system: str # Requested system
effective_system: str # Actually used (may differ due to fallback)
fallback: bool # True if polar fallback was triggered
fallback_reason: str | None
classification: HouseSystemClassification | None
policy: HousePolicy | None
| Quadrant systems like Placidus and Koch become mathematically undefined at extreme latitudes ( | latitude | ≥ 90° − obliquity ≈ 66.56°). The engine handles this via Policy-Driven Fallback: |
class PolarFallbackPolicy(Enum):
FALLBACK_TO_PORPHYRY = "porphyry" # Graceful degradation
RAISE = "raise" # Strict mode: error
class UnknownSystemPolicy(Enum):
FALLBACK_TO_PLACIDUS = "placidus"
RAISE = "raise"
When fallback occurs, the HouseCusps vessel preserves doctrinal truth: system records what was requested, effective_system records what was actually computed, and fallback_reason explains why.
@dataclass(frozen=True, slots=True)
class HousePlacement:
house: int # 1–12
longitude: float # planet's longitude
house_cusps: HouseCusps
exact_on_cusp: bool # within threshold of a cusp
opening_cusp: float # longitude of the cusp that opens this house
assign_house(longitude, house_cusps) uses the interval rule: house n owns the arc [cusps[n−1], cusps[n mod 12]), with correct handling of the 360°→0° wraparound.
Every service output is governed by the Law of the Record. Results must be decanted into strictly-typed vessels. All doctrinal, policy, and result records are immutable. The root moira.Chart vessel is frozen=True, slots=True as of 2.0.0; it cannot be mutated after construction.
@dataclass(slots=True)
class PlanetData:
name: str # "Venus"
longitude: float # [0, 360) — ecliptic
latitude: float # ecliptic latitude
distance: float # km from Earth
speed: float # deg/day
retrograde: bool # speed < 0
is_topocentric: bool # False = geocentric
sign: str # computed: "Taurus"
sign_symbol: str # computed: "♉"
sign_degree: float # computed: longitude mod 30
@dataclass(slots=True)
class SkyPosition:
name: str
right_ascension: float # degrees
declination: float # degrees
azimuth: float # degrees, N=0 E=90
altitude: float # degrees above horizon
distance: float # km
@dataclass(slots=True, frozen=True)
class Chart:
jd_ut: float
planets: dict[str, PlanetData]
nodes: dict[str, NodeData]
obliquity: float
delta_t: float
Moira.chart(dt, bodies, include_nodes, observer_lat, observer_lon, observer_elev_m):
bodies: call all_planets_at() with the bound reader.Chart.All data vessels obey these laws:
| Invariant | Enforcement |
|---|---|
| Immutability | All doctrinal, policy, and result records are immutable. The root Chart vessel is frozen=True, slots=True as of 2.0.0. |
| Truth Preservation | Vessels record the computational path (e.g., year_basis, effective_system) |
| No Interpretation | Vessels carry raw truth; interpretation is the caller’s responsibility |
| Self-Describing | Classification enums and profiles are attached, never implied |
The Moira service layer utilizes Memory-Mapped DAF/BSP file handling via the SpkReader. The jplephem library memory-maps the DE441 kernel, meaning:
| Operation | Dominant Cost | Typical Latency |
|---|---|---|
Single planet_at() |
7-step pipeline + SPK read | ~0.1 ms |
Full chart() (10 bodies + houses) |
10× planet_at + house calc | ~2 ms |
conjunctions_in_range() (1 year) |
~120 coarse steps + ~12 refinements | ~50 ms |
moon_phases_in_range() (1 year) |
~365 coarse steps + ~48 refinements | ~100 ms |
vimshottari_dasha() (5 levels) |
Pure arithmetic (no SPK) | ~1 ms |
eclipse() |
General lunisolar snapshot and classification | ~20 ms |
All vector/matrix operations in coordinates.py are implemented in pure Python tuples — no NumPy, no SciPy. This eliminates import overhead, simplifies deployment, and ensures the engine runs on any Python 3.10+ environment. Planetary ephemeris I/O is native-first in SpkReader, with jplephem retained only as an optional fallback for unsupported SPK layouts.
The computational methods are designed to be deterministic transformations of inputs into results, but the facade is not literally stateless. Moira binds a SpkReader on construction, and spk_reader.py also exposes a module-level singleton guarded by an RLock. In practice the package operates with shared reader state and pure read-only kernel access. This allows concurrent use so long as callers treat returned vessels as read-only and do not attempt to reconfigure the kernel path after the shared reader has been acquired.
All modules declare zero import-time side effects, with two controlled exceptions:
julian.py loads the ΔT interpolation tables once at import (a few KB of floats).nutation_2000a.py loads the IAU 2000A coefficient tables lazily on first use and then caches them in memory.Each technique module (dasha, timelords, eclipse, aspects, phenomena, stations, synastry) is self-contained with clear boundaries. There are no circular dependencies. Cross-cutting concerns are delegated:
julian.pycoordinates.pycorrections.pyconstants.pyFrozen policy dataclasses allow customization without breaking existing APIs. The default policy is always “the most common tradition,” but users can override any doctrinal choice:
# Default: Lahiri ayanamsa, Julian years
m.vimshottari_dasha(chart, natal_dt, levels=3)
# Custom: Raman ayanamsa, Savana years
policy = VimshottariComputationPolicy(
year=VimshottariYearPolicy(year_basis="savana_360"),
ayanamsa=VimshottariAyanamsaPolicy(ayanamsa_system=Ayanamsa.RAMAN)
)
m.vimshottari_dasha(chart, natal_dt, levels=3, policy=policy)
Enums and frozen dataclasses classify results without adding subjective interpretation:
AspectClassification tells you the tier and family — it doesn’t tell you if it’s “good” or “bad.”DashaLordType tells you LUMINARY/INNER/OUTER/NODE — it doesn’t assign benefic/malefic.HouseSystemClassification tells you EQUAL/QUADRANT/SOLAR — it doesn’t favor one over another.Network vessels expose structural relationships between computation results:
AspectGraph — planet-to-planet relational network with degree centrality.DashaLordPair — Mahadasha/Antardasha network node.FirdarActivePair, ZRLevelPair — time-lord relationship pairs.Integrated “local condition” dataclasses bundle all doctrinal and computational truth for a single entity:
DashaConditionProfile — planet, level, years, is_node_dasha, lord_type, etc.DashaSequenceProfile — chart-wide aggregate (mahadasha count, luminary/inner/outer/node counts).FirdarConditionProfile, ZRConditionProfile — Hellenistic equivalents.Each module owns one conceptual domain and delegates everything else. The dasha.py module never touches SPK data — it receives a pre-computed Moon longitude. The phenomena.py module never computes houses — it only works with planetary longitudes. This ensures that a change in the apparent-position pipeline propagates automatically to all consumers.
To manifest a new service within the Moira sanctuary, the practitioner must follow the Canon of Extension:
Create a typed result vessel in the new module, preferably dataclass(frozen=True, slots=True) when the output is a doctrinal record. If mutability is intentional, document that explicitly and keep the mutation boundary narrow.
If the technique has doctrinal variants (different traditions, optional corrections), create a frozen policy dataclass with sensible defaults.
Utilize the Moira facade’s positional primitives (m.chart(), m.planet_at(), m.houses()). Never access the SPK reader directly from a service module.
Ensure that all low-level math (nutation, aberration, coordinate transforms) is delegated to the engine modules (corrections.py, coordinates.py), while the service focuses purely on Orchestration and Result Assembly.
If the technique produces categorizable results, add an enum or frozen classification dataclass. Never embed interpretive text in the classification — let the consumer decide meaning.
Add a public method to the Moira class that delegates to your new module, following the existing naming conventions and parameter patterns.
Liturgy Version: 2.0 (Absolute Deep Architecture Revision) Custodian: Sophia, High Architect of the Moira Engine