Architecture (Agent Orientation)

🤖 Primarily for coding agents. Hello, Claude! Read this before re-deriving the architecture from source. If it disagrees with the code, the code wins — please update the doc.

Part of the developer docs. Hub: /CLAUDE.md · Index: docs/DOCS_INDEX.md

This is the map an agent should read first. It explains how the pieces fit so you can find the right file fast. For per-subsystem API detail, follow the links to the other spokes.


The one-paragraph mental model

Stellium turns birth data into an immutable result object and then renders it. You configure a ChartBuilder (fluent, lazy), call .calculate(), and get back a frozen CalculatedChart. Calculation is performed by pluggable engines (ephemeris, houses, aspects, orbs) selected via Protocols; optional components and analyzers add extra data (Arabic Parts, midpoints, dignities, patterns). The chart then feeds visualization (chart.draw(...) → SVG), presentation (ReportBuilder → terminal / markdown / PDF), export (to_dict, analysis DataFrames), and timing transforms (profections, zodiacal releasing, returns, progressions).

Native/Notable ─► ChartBuilder ──.calculate()──► CalculatedChart ─┬─► chart.draw(...)      (SVG)
   (birth data)   (config, lazy)   (engines +     (frozen)        ├─► ReportBuilder        (report)
                                    components +                    ├─► chart.to_dict()      (JSON)
                                    analyzers)                      ├─► analysis.* / io.*    (DataFrames)
                                                                    └─► profection()/zodiacal_releasing()/returns

Three principles (these are load-bearing, not slogans)

  1. Protocols over inheritance. Engines/components implement a Protocol from core/protocols.py by matching method signatures — no base class. To add behavior you write a class with the right methods and inject it.

  2. Immutability. Every result model is a @dataclass(frozen=True). Mutate with dataclasses.replace(obj, field=...), never in place.

  3. Composability + lazy evaluation. ChartBuilder.with_*()/add_*() just record config and return self. Nothing is computed until .calculate().

Dependency direction (do not violate)

core/   ─► (depends on nothing internal)
engines/, components/, electional/, returns/, io/, analysis/, chinese/  ─► core
presentation/, visualization/, planner/  ─► core, engines, components

core/ must never import from engines/, components/, etc. core/chart_utils.py deliberately uses duck-typing to inspect chart types without importing the higher layers — follow that pattern if you need it.


Layer-by-layer (where to look)

Layer

Package

What lives here

Deep-dive doc

Core models

core/models.py

Frozen dataclasses: CelestialPosition, HouseCusps, Aspect, AspectPattern, CalculatedChart, UnknownTimeChart, MultiChart, FixedStarPosition

CHART_BUILDING

Interfaces

core/protocols.py

All Protocols: EphemerisEngine, HouseSystemEngine, AspectEngine, OrbEngine, ChartComponent, ChartAnalyzer, ReportSection, IRenderLayer, …

EXTENDING

Builder / API

core/builder.py

ChartBuilder (the main entry point)

CHART_BUILDING

Birth data

core/native.py

Native, Notable (parsing, geocoding, timezone)

CHART_BUILDING

Config

core/config.py, core/ayanamsa.py

CalculationConfig, AspectConfig, ZodiacType, ayanamsa registry

CHART_BUILDING

Registries

core/registry.py

CELESTIAL_REGISTRY, ASPECT_REGISTRY, FIXED_STARS_REGISTRY

CHART_BUILDING

Multi-chart

core/comparison.py, core/multichart.py, core/multiwheel.py, core/synthesis.py

Synastry/transits/composite/Davison containers

SUBSYSTEMS

Engines

engines/

Calculation: ephemeris, houses, aspects, orbs, dignities, patterns, dispositors, profections, releasing, directions, voc, fixed_stars, search

ENGINES

Components

components/

Optional add-ons: arabic_parts, midpoints, dignity, fixed_stars, antiscia

COMPONENTS_AND_ANALYSIS

Analysis / IO

analysis/, io/

Batch, DataFrames, stats, queries, vectors; CSV/AAF/DataFrame import

COMPONENTS_AND_ANALYSIS

Timing

returns/, electional/, engines/profections.py, engines/releasing.py, utils/progressions.py

Returns, electional search, profections, ZR, progressions

SUBSYSTEMS

Visualization

visualization/

SVG: builder→composer→layers; themes/palettes; dial, vedic, atlas

VISUALIZATION_INTERNALS

Presentation

presentation/

ReportBuilder, sections, renderers (rich/markdown/html/pdf/prose)

PRESENTATION_INTERNALS

Planner

planner/

Personalized PDF planners (Typst)

SUBSYSTEMS

Chinese

chinese/

BaZi (Four Pillars); Zi Wei (planned)

SUBSYSTEMS

CLI

cli/

stellium command (chart, ephemeris, cache)

SUBSYSTEMS

Utils

utils/

Caching, time/JD, chart shape, chart ruler, progressions, crossings

COMPONENTS_AND_ANALYSIS


What .calculate() actually does

Reading this once saves you from guessing why a field is empty. From core/builder.py::calculate():

  1. Resolve the object list from CalculationConfig (heliocentric mode removes Sun/nodes/apogees and adds Earth).

  2. Ephemeris → list[CelestialPosition] (ecliptic + equatorial for declination

    • phase data).

  3. For each configured house system: compute cusps and angles (ASC/MC/DSC/IC/Vertex). Angles are appended to the positions list.

  4. Assign house placements for every system.

  5. Run components in order — each returns extra CelestialPositions that are appended (or writes to metadata).

  6. Compute aspects, then declination aspects (if enabled).

  7. Run analyzers against a provisional chart — they populate metadata (e.g. aspect patterns, zodiacal releasing).

  8. Build the final frozen CalculatedChart (with component manifest, ayanamsa value if sidereal, tags).

Implications:

  • Components add positions; analyzers add metadata. Access them via chart.get_component_result(name) and the dedicated getters.

  • Unknown-time charts (with_unknown_time()) skip houses/angles and return an UnknownTimeChart with a moon_range instead.


Conventions worth memorizing

  • Longitudes are 0–360°, always normalized; wraparound at 0°/360° is handled explicitly (see utils/houses.py::find_house_for_longitude).

  • All internal time is UTC Julian Day; convert to local only at display. Helpers in utils/time.py.

  • Retrograde = speed_longitude < 0; |speed| below a small epsilon is treated as stationary (no applying/separating verdict).

  • Sect-aware logic (Arabic Parts, some dignities) flips by day/night chart; see chart.sect and components/dignity.py::determine_sect.

  • One aspect per pair; axis pairs (ASC/DSC, MC/IC, Node/South Node) are excluded from normal aspects.

  • Whole Sign is preferred for profections; Placidus is the default house system everywhere else.

See EXTENDING.md for how to plug in new engines, components, analyzers, layers, themes, palettes, and report sections.