kerykeion

This is part of Kerykeion (C) 2025 Giacomo Battaglia

Kerykeion

stars forks
PyPI Downloads PyPI Downloads PyPI Downloads
Package version Supported Python versions

⭐ Like this project? Star it on GitHub and help it grow! ⭐

 

Kerykeion is a Python library for astrology. It computes planetary and house positions, detects aspects, and generates SVG charts—including birth, synastry, transit, and composite charts. You can also customize which planets to include in your calculations.

The main goal of this project is to offer a clean, data-driven approach to astrology, making it accessible and programmable.

Kerykeion also integrates seamlessly with LLM and AI applications.

Here is an example of a birthchart:

John Lenon Chart

📘 For AI Agents & LLMs If you're building LLM-powered applications (or if you are an AI agent 🙂), see AI_AGENT_GUIDE.md for a comprehensive, concise reference optimized for programmatic use and AI context.

Web API

If you want to use Kerykeion in a web application or for commercial or _closed-source_ purposes, you can try the dedicated web API:

AstrologerAPI

It is open source and directly supports this project.

Maintaining this project requires substantial time and effort. The Astrologer API alone cannot cover the costs of full-time development. If you find Kerykeion valuable and would like to support further development, please consider donating:

ko-fi

Table of Contents

Installation

Kerykeion requires Python 3.9 or higher.

pip3 install kerykeion

Quick Start

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

subject = AstrologicalSubjectFactory.from_birth_data(
    name="Example Person",
    year=1990, month=7, day=15,
    hour=10, minute=30,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)

chart_data = ChartDataFactory.create_natal_chart_data(subject)
chart_drawer = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)

chart_drawer.save_svg(output_path=output_dir, filename="example-natal")
print("Chart saved to", (output_dir / "example-natal.svg").resolve())

This script shows the recommended workflow:

  1. Create an AstrologicalSubject with explicit coordinates and timezone (offline mode).
  2. Build a ChartDataModel through ChartDataFactory.
  3. Render the SVG via ChartDrawer, saving it to a controlled folder (charts_output).

Use the same pattern for synastry, composite, transit, or return charts by swapping the factory method.

Documentation Map

  • README (this file): Quick start, common recipes, and v5 overview. Full migration guide: MIGRATION_V4_TO_V5.md.
  • site-docs/ (offline Markdown guides): Deep dives for each factory (chart_data_factory.md, charts.md, planetary_return_factory.md, etc.) with runnable snippets. Run python scripts/test_markdown_snippets.py site-docs to validate them locally.
  • Auto-generated API Reference: Detailed model and function signatures straight from the codebase.
  • Kerykeion website: Rendered documentation with additional context, tutorials, and showcase material.

Basic Usage

Below is a simple example illustrating the creation of an astrological subject and retrieving astrological details:

from kerykeion import AstrologicalSubjectFactory

# Create an instance of the AstrologicalSubjectFactory class.
# Arguments: Name, year, month, day, hour, minutes, city, nation
john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Retrieve information about the Sun:
print(john.sun.model_dump_json())
# > {"name":"Sun","quality":"Cardinal","element":"Air","sign":"Lib","sign_num":6,"position":16.26789199474399,"abs_pos":196.267891994744,"emoji":"♎️","point_type":"AstrologicalPoint","house":"Sixth_House","retrograde":false}

# Retrieve information about the first house:
print(john.first_house.model_dump_json())
# > {"name":"First_House","quality":"Cardinal","element":"Fire","sign":"Ari","sign_num":0,"position":19.74676624176799,"abs_pos":19.74676624176799,"emoji":"♈️","point_type":"House","house":null,"retrograde":null}

# Retrieve the element of the Moon sign:
print(john.moon.element)
# > 'Air'

Working offline: pass online=False and specify lng, lat, and tz_str as shown above.
Working online: set online=True and provide city, nation, and a valid GeoNames username (see AstrologicalSubjectFactory.from_birth_data() for details).

To avoid using GeoNames online, specify longitude, latitude, and timezone instead of city and nation:

john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,  # Longitude for Liverpool
    lat=53.4000,  # Latitude for Liverpool
    tz_str="Europe/London",  # Timezone for Liverpool
    city="Liverpool",
)

Generate a SVG Chart

All chart-rendering examples below create a local charts_output/ folder so the tests can write without touching your home directory. Feel free to change the path when integrating into your own projects.

To generate a chart, use the ChartDataFactory to pre-compute chart data, then ChartDrawer to create the visualization. This two-step process ensures clean separation between astrological calculations and chart rendering.

Tip: The optimized way to open the generated SVG files is with a web browser (e.g., Chrome, Firefox). To improve compatibility across different applications, you can use the remove_css_variables parameter when generating the SVG. This will inline all styles and eliminate CSS variables, resulting in an SVG that is more broadly supported.

Birth Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(john)

# Step 3: Create visualization
birth_chart_svg = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_svg(output_path=output_dir, filename="john-lennon-natal")

The SVG file is saved under charts_output/john-lennon-natal.svg. John Lennon Birth Chart

External Birth Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data for external natal chart
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization
birth_chart_svg = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_svg(output_path=output_dir, filename="john-lennon-natal-external")

John Lennon External Birth Chart

Synastry Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects
first = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)
second = AstrologicalSubjectFactory.from_birth_data(
    "Paul McCartney", 1942, 6, 18, 15, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute synastry chart data
chart_data = ChartDataFactory.create_synastry_chart_data(first, second)

# Step 3: Create visualization
synastry_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
synastry_chart.save_svg(output_path=output_dir, filename="lennon-mccartney-synastry")

John Lennon and Paul McCartney Synastry

Transit Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects
transit = AstrologicalSubjectFactory.from_birth_data(
    "Transit", 2025, 6, 8, 8, 45,
    lng=-84.3880,
    lat=33.7490,
    tz_str="America/New_York",
    online=False,
)
subject = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute transit chart data
chart_data = ChartDataFactory.create_transit_chart_data(subject, transit)

# Step 3: Create visualization
transit_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
transit_chart.save_svg(output_path=output_dir, filename="john-lennon-transit")

John Lennon Transit Chart

Solar Return Chart (Dual Wheel)

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.planetary_return_factory import PlanetaryReturnFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create natal subject
john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Calculate Solar Return subject (offline example with manual coordinates)
return_factory = PlanetaryReturnFactory(
    john,
    lng=-2.9833,
    lat=53.4000,
    tz_str="Europe/London",
    online=False
)
solar_return_subject = return_factory.next_return_from_date(1964, 10, 1, return_type="Solar")

# Step 3: Pre-compute return chart data (dual wheel: natal + solar return)
chart_data = ChartDataFactory.create_return_chart_data(john, solar_return_subject)

# Step 4: Create visualization
solar_return_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
solar_return_chart.save_svg(output_path=output_dir, filename="john-lennon-solar-return-dual")

John Lennon Solar Return Chart (Dual Wheel)

Solar Return Chart (Single Wheel)

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.planetary_return_factory import PlanetaryReturnFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create natal subject
john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Calculate Solar Return subject (offline example with manual coordinates)
return_factory = PlanetaryReturnFactory(
    john,
    lng=-2.9833,
    lat=53.4000,
    tz_str="Europe/London",
    online=False
)
solar_return_subject = return_factory.next_return_from_date(1964, 10, 1, return_type="Solar")

# Step 3: Build a single-wheel return chart
chart_data = ChartDataFactory.create_single_wheel_return_chart_data(solar_return_subject)

# Step 4: Create visualization
single_wheel_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
single_wheel_chart.save_svg(output_path=output_dir, filename="john-lennon-solar-return-single")

John Lennon Solar Return Chart (Single Wheel)

Lunar Return Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.planetary_return_factory import PlanetaryReturnFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create natal subject
john = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Calculate Lunar Return subject
return_factory = PlanetaryReturnFactory(
    john,
    lng=-2.9833,
    lat=53.4000,
    tz_str="Europe/London",
    online=False
)
lunar_return_subject = return_factory.next_return_from_date(1964, 1, 1, return_type="Lunar")

# Step 3: Build a dual wheel (natal + lunar return)
lunar_return_chart_data = ChartDataFactory.create_return_chart_data(john, lunar_return_subject)
dual_wheel_chart = ChartDrawer(chart_data=lunar_return_chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
dual_wheel_chart.save_svg(output_path=output_dir, filename="john-lennon-lunar-return-dual")

# Optional: create a single-wheel lunar return
single_wheel_data = ChartDataFactory.create_single_wheel_return_chart_data(lunar_return_subject)
single_wheel_chart = ChartDrawer(chart_data=single_wheel_data)
single_wheel_chart.save_svg(output_path=output_dir, filename="john-lennon-lunar-return-single")

John Lennon Lunar Return Chart (Dual Wheel)

John Lennon Lunar Return Chart (Single Wheel)

Composite Chart

from pathlib import Path
from kerykeion import CompositeSubjectFactory, AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects (offline configuration)
angelina = AstrologicalSubjectFactory.from_birth_data(
    "Angelina Jolie", 1975, 6, 4, 9, 9,
    lng=-118.2437,
    lat=34.0522,
    tz_str="America/Los_Angeles",
    online=False,
)

brad = AstrologicalSubjectFactory.from_birth_data(
    "Brad Pitt", 1963, 12, 18, 6, 31,
    lng=-96.7069,
    lat=35.3273,
    tz_str="America/Chicago",
    online=False,
)

# Step 2: Create composite subject
factory = CompositeSubjectFactory(angelina, brad)
composite_model = factory.get_midpoint_composite_subject_model()

# Step 3: Pre-compute composite chart data
chart_data = ChartDataFactory.create_composite_chart_data(composite_model)

# Step 4: Create visualization
composite_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
composite_chart.save_svg(output_path=output_dir, filename="jolie-pitt-composite")

Angelina Jolie and Brad Pitt Composite Chart

Wheel Only Charts

For _all_ the charts, you can generate a wheel-only chart by using the method makeWheelOnlySVG():

Birth Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization
birth_chart_svg = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_wheel_only_svg_file(output_path=output_dir, filename="john-lennon-natal-wheel")

John Lennon Birth Chart

Wheel Only Birth Chart (External)

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute external natal chart data
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization (external wheel view)
birth_chart_svg = ChartDrawer(chart_data=chart_data, external_view=True)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_wheel_only_svg_file(output_path=output_dir, filename="john-lennon-natal-wheel-external")

John Lennon Birth Chart

Synastry Chart

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects
first = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)
second = AstrologicalSubjectFactory.from_birth_data(
    "Paul McCartney", 1942, 6, 18, 15, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute synastry chart data
chart_data = ChartDataFactory.create_synastry_chart_data(first, second)

# Step 3: Create visualization
synastry_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
synastry_chart.save_wheel_only_svg_file(output_path=output_dir, filename="lennon-mccartney-synastry-wheel")

John Lennon and Paul McCartney Synastry

Change the Output Directory

To save the SVG file in a custom location, specify the output_path parameter in save_svg():

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects
first = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)
second = AstrologicalSubjectFactory.from_birth_data(
    "Paul McCartney", 1942, 6, 18, 15, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute synastry chart data
chart_data = ChartDataFactory.create_synastry_chart_data(first, second)

# Step 3: Create visualization with custom output directory
synastry_chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
synastry_chart.save_svg(output_path=output_dir)
print("Saved to", (output_dir / f"{synastry_chart.first_obj.name} - Synastry Chart.svg").resolve())

Change Language

You can switch chart language by passing chart_language to the ChartDrawer class:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization with Italian language
birth_chart_svg = ChartDrawer(
    chart_data=chart_data,
    chart_language="IT"  # Change to Italian
)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_svg(output_path=output_dir, filename="john-lennon-natal-it")

You can also provide custom labels (or introduce a brand-new language) by passing a dictionary to language_pack. Only the keys you supply are merged on top of the built-in strings:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

custom_labels = {
    "PT": {
        "info": "Informações",
        "celestial_points": {"Sun": "Sol", "Moon": "Lua"},
    }
}

custom_chart = ChartDrawer(
    chart_data=chart_data,
    chart_language="PT",
    language_pack=custom_labels["PT"],
)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
custom_chart.save_svg(output_path=output_dir, filename="john-lennon-natal-pt")

More details here.

The available languages are:

  • EN (English)
  • FR (French)
  • PT (Portuguese)
  • ES (Spanish)
  • TR (Turkish)
  • RU (Russian)
  • IT (Italian)
  • CN (Chinese)
  • DE (German)

Minified SVG

To generate a minified SVG, set minify_svg=True in the makeSVG() method:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization
birth_chart_svg = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_svg(
    output_path=output_dir,
    filename="john-lennon-natal-minified",
    minify=True,
)

SVG without CSS Variables

To generate an SVG without CSS variables, set remove_css_variables=True in the makeSVG() method:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(birth_chart)

# Step 3: Create visualization
birth_chart_svg = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
birth_chart_svg.save_svg(
    output_path=output_dir,
    filename="john-lennon-natal-no-css-variables",
    remove_css_variables=True,
)

This will inline all styles and eliminate CSS variables, resulting in an SVG that is more broadly supported.

Grid Only SVG

It's possible to generate a grid-only SVG, useful for creating a custom layout. To do this, use the save_aspect_grid_only_svg_file() method:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subjects
birth_chart = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)
second = AstrologicalSubjectFactory.from_birth_data(
    "Paul McCartney", 1942, 6, 18, 15, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute synastry chart data
chart_data = ChartDataFactory.create_synastry_chart_data(birth_chart, second)

# Step 3: Create visualization with dark theme
aspect_grid_chart = ChartDrawer(chart_data=chart_data, theme="dark")

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
aspect_grid_chart.save_aspect_grid_only_svg_file(output_path=output_dir, filename="lennon-mccartney-aspect-grid")

John Lennon Birth Chart

Report Generator

ReportGenerator mirrors the chart-type dispatch of ChartDrawer. It accepts raw AstrologicalSubjectModel instances as well as any ChartDataModel produced by ChartDataFactory—including natal, composite, synastry, transit, and planetary return charts—and renders the appropriate textual report automatically.

Quick Examples

from kerykeion import ReportGenerator, AstrologicalSubjectFactory, ChartDataFactory

# Subject-only report
subject = AstrologicalSubjectFactory.from_birth_data(
    "Sample Natal", 1990, 7, 21, 14, 45,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)
ReportGenerator(subject).print_report(include_aspects=False)

# Single-chart data (elements, qualities, aspects enabled)
natal_data = ChartDataFactory.create_natal_chart_data(subject)
ReportGenerator(natal_data).print_report(max_aspects=10)

# Dual-chart data (synastry, transit, dual return, …)
partner = AstrologicalSubjectFactory.from_birth_data(
    "Sample Partner", 1992, 11, 5, 9, 30,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)
synastry_data = ChartDataFactory.create_synastry_chart_data(subject, partner)
ReportGenerator(synastry_data).print_report(max_aspects=12)

Each report contains:

  • A chart-aware title summarising the subject(s) and chart type
  • Birth/event metadata and configuration settings
  • Celestial points with sign, position, daily motion, declination, retrograde flag, and house
  • House cusp tables for every subject involved
  • Lunar phase details when available
  • Element/quality distributions and active configuration summaries (for chart data)
  • Aspect listings tailored for single or dual charts, with symbols for type and movement
  • Dual-chart extras such as house comparisons and relationship scores (when provided by the data)

Section Access

All section helpers remain available for targeted output:

from kerykeion import ReportGenerator, AstrologicalSubjectFactory, ChartDataFactory

subject = AstrologicalSubjectFactory.from_birth_data(
    "Sample Natal", 1990, 7, 21, 14, 45,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)
natal_data = ChartDataFactory.create_natal_chart_data(subject)

report = ReportGenerator(natal_data)
sections = report.generate_report(max_aspects=5).split("\n\n")
for section in sections[:3]:
    print(section)

Refer to the refreshed Report Documentation for end-to-end examples covering every supported chart model.

AI Context Serializer

The context_serializer module transforms Kerykeion data models into precise, non-qualitative text optimized for LLM consumption. It provides the essential "ground truth" data needed for AI agents to generate accurate astrological interpretations.

Quick Example

from kerykeion import AstrologicalSubjectFactory, to_context

# Create a subject
subject = AstrologicalSubjectFactory.from_birth_data(
    "John Doe", 1990, 1, 1, 12, 0, "London", "GB"
)

# Generate AI-ready context
context = to_context(subject)
print(context)

Output:

Chart for John Doe
Birth data: 1990-01-01 12:00, London, GB
...
Celestial Points:
  - Sun at 10.81° in Capricorn in Tenth House, quality: Cardinal, element: Earth...
  - Moon at 25.60° in Aquarius in Eleventh House, quality: Fixed, element: Air...

Key Features:

  • Standardized Output: Consistent format for Natal, Synastry, Composite, and Return charts.
  • Non-Qualitative: Provides raw data (positions, aspects) without interpretive bias.
  • Prompt-Ready: Designed to be injected directly into system prompts.

For comprehensive documentation and examples, see site/docs/context_serializer.md.

Example: Retrieving Aspects

Kerykeion provides a unified AspectsFactory class for calculating astrological aspects within single charts or between two charts:

from kerykeion import AspectsFactory, AstrologicalSubjectFactory

# Create astrological subjects
jack = AstrologicalSubjectFactory.from_birth_data(
    "Jack", 1990, 6, 15, 15, 15,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)
jane = AstrologicalSubjectFactory.from_birth_data(
    "Jane", 1991, 10, 25, 21, 0,
    lng=12.4964,
    lat=41.9028,
    tz_str="Europe/Rome",
    online=False,
)

# For single chart aspects (natal, return, composite, etc.)
single_chart_result = AspectsFactory.single_chart_aspects(jack)
print(f"Found {len(single_chart_result.aspects)} aspects in Jack's chart")
print(single_chart_result.aspects[0])

# For dual chart aspects (synastry, transits, comparisons, etc.)
dual_chart_result = AspectsFactory.dual_chart_aspects(jack, jane)
print(f"Found {len(dual_chart_result.aspects)} aspects between Jack and Jane's charts")
print(dual_chart_result.aspects[0])

# Each AspectModel includes:
# - p1_name, p2_name: Planet/point names
# - aspect: Aspect type (conjunction, trine, square, etc.)
# - orbit: Orb tolerance in degrees
# - aspect_degrees: Exact degrees for the aspect (0, 60, 90, 120, 180, etc.)
# - color: Hex color code for visualization

Advanced Usage with Custom Settings:

# You can also customize aspect calculations with custom orb settings
from kerykeion.settings.config_constants import DEFAULT_ACTIVE_ASPECTS

# Modify aspect settings if needed
custom_aspects = DEFAULT_ACTIVE_ASPECTS.copy()
# ... modify as needed

# The factory automatically uses the configured settings for orb calculations
# and filters aspects based on relevance and orb thresholds

Element & Quality Distribution Strategies

ChartDataFactory now offers two strategies for calculating element and modality totals. The default "weighted" mode leans on a curated map that emphasises core factors (for example sun, moon, and ascendant weight 2.0, angles such as medium_coeli 1.5, personal planets 1.5, social planets 1.0, outers 0.5, and minor bodies 0.3–0.8). Provide distribution_method="pure_count" when you want every active point to contribute equally.

You can refine the weighting without rebuilding the dictionary: pass lowercase point names to custom_distribution_weights and use "__default__" to override the fallback value applied to entries that are not listed explicitly.

from kerykeion import AstrologicalSubjectFactory, ChartDataFactory

subject = AstrologicalSubjectFactory.from_birth_data(
    "Sample", 1986, 4, 12, 8, 45,
    lng=11.3426,
    lat=44.4949,
    tz_str="Europe/Rome",
    online=False,
)

# Equal weighting: every active point counts once
pure_data = ChartDataFactory.create_natal_chart_data(
    subject,
    distribution_method="pure_count",
)

# Custom emphasis: boost the Sun, soften everything else
weighted_data = ChartDataFactory.create_natal_chart_data(
    subject,
    distribution_method="weighted",
    custom_distribution_weights={
        "sun": 3.0,
        "__default__": 0.75,
    },
)

print(pure_data.element_distribution.fire)
print(weighted_data.element_distribution.fire)

All convenience helpers (create_synastry_chart_data, create_transit_chart_data, returns, and composites) forward the same keyword-only parameters, so you can keep a consistent weighting scheme across every chart type.

For an extended walkthrough (including category breakdowns of the default map), see site-docs/element_quality_distribution.md.

Ayanamsa (Sidereal Modes)

By default, the zodiac type is Tropical. To use Sidereal, specify the sidereal mode:

johnny = AstrologicalSubjectFactory.from_birth_data(
    "Johnny Depp", 1963, 6, 9, 0, 0,
    lng=-87.1112,
    lat=37.7719,
    tz_str="America/Chicago",
    online=False,
    zodiac_type="Sidereal",
    sidereal_mode="LAHIRI"
)

More examples here.

Full list of supported sidereal modes here.

House Systems

By default, houses are calculated using Placidus. Configure a different house system as follows:

johnny = AstrologicalSubjectFactory.from_birth_data(
    "Johnny Depp", 1963, 6, 9, 0, 0,
    lng=-87.1112,
    lat=37.7719,
    tz_str="America/Chicago",
    online=False,
    houses_system_identifier="M"
)

More examples here.

Full list of supported house systems here.

So far all the available houses system in the Swiss Ephemeris are supported but the Gauquelin Sectors.

Perspective Type

By default, Kerykeion uses the Apparent Geocentric perspective (the most standard in astrology). Other perspectives (e.g., Heliocentric) can be set this way:

johnny = AstrologicalSubjectFactory.from_birth_data(
    "Johnny Depp", 1963, 6, 9, 0, 0,
    lng=-87.1112,
    lat=37.7719,
    tz_str="America/Chicago",
    online=False,
    perspective_type="Heliocentric"
)

More examples here.

Full list of supported perspective types here.

Themes

Kerykeion provides several chart themes:

  • Classic (default)
  • Dark
  • Dark High Contrast
  • Light
  • Strawberry
  • Black & White (optimized for monochrome printing)

Each theme offers a distinct visual style, allowing you to choose the one that best suits your preferences or presentation needs. If you prefer more control over the appearance, you can opt not to set any theme, making it easier to customize the chart by overriding the default CSS variables. For more detailed instructions on how to apply themes, check the documentation

The Black & White theme renders glyphs, rings, and aspects in solid black on light backgrounds, designed for crisp B/W prints (PDF or paper) without sacrificing legibility.

Here's an example of how to set the theme:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
dark_theme_subject = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon - Dark Theme", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data
chart_data = ChartDataFactory.create_natal_chart_data(dark_theme_subject)

# Step 3: Create visualization with dark high contrast theme
dark_theme_natal_chart = ChartDrawer(chart_data=chart_data, theme="dark-high-contrast")

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
dark_theme_natal_chart.save_svg(output_path=output_dir, filename="john-lennon-natal-dark-high-contrast")

John Lennon

Alternative Initialization

Create an AstrologicalSubjectModel from a UTC ISO 8601 string:

from kerykeion import AstrologicalSubjectFactory

subject = AstrologicalSubjectFactory.from_iso_utc_time(
    name="Johnny Depp",
    iso_utc_time="1963-06-09T05:00:00Z",
    city="Owensboro",
    nation="US",
    lng=-87.1112,
    lat=37.7719,
    tz_str="America/Chicago",
    online=False,
)

print(subject.iso_formatted_local_datetime)

If you prefer automatic geocoding, set online=True and provide your GeoNames credentials via geonames_username.

Lunar Nodes (Rahu & Ketu)

Kerykeion supports both True and Mean Lunar Nodes:

  • True North Lunar Node: "true_node" (name kept without "north" for backward compatibility).
  • True South Lunar Node: "true_south_node".
  • Mean North Lunar Node: "mean_node" (name kept without "north" for backward compatibility).
  • Mean South Lunar Node: "mean_south_node".

In instances of the classes used to generate aspects and SVG charts, only the mean nodes are active. To activate the true nodes, you need to pass the active_points parameter to the ChartDataFactory methods.

Example:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory
from kerykeion.chart_data_factory import ChartDataFactory
from kerykeion.charts.chart_drawer import ChartDrawer

# Step 1: Create subject
subject = AstrologicalSubjectFactory.from_birth_data(
    "John Lennon", 1940, 10, 9, 18, 30,
    lng=-2.9833,
    lat=53.4,
    tz_str="Europe/London",
    online=False,
)

# Step 2: Pre-compute chart data with custom active points including true nodes
chart_data = ChartDataFactory.create_natal_chart_data(
    subject,
    active_points=[
        "Sun",
        "Moon",
        "Mercury",
        "Venus",
        "Mars",
        "Jupiter",
        "Saturn",
        "Uranus",
        "Neptune",
        "Pluto",
        "Mean_Node",
        "Mean_South_Node",
        "True_Node",       # Activates True North Node
        "True_South_Node", # Activates True South Node
        "Ascendant",
        "Medium_Coeli",
        "Descendant",
        "Imum_Coeli"
    ]
)

# Step 3: Create visualization
chart = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
chart.save_svg(output_path=output_dir, filename="johnny-depp-custom-points")

JSON Support

You can serialize the astrological subject (the base data used throughout the library) to JSON:

from kerykeion import AstrologicalSubjectFactory

johnny = AstrologicalSubjectFactory.from_birth_data(
    "Johnny Depp", 1963, 6, 9, 0, 0,
    lng=-87.1112,
    lat=37.7719,
    tz_str="America/Chicago",
    online=False,
)

print(johnny.model_dump_json(indent=2))

Auto Generated Documentation

You can find auto-generated documentation here. Most classes and functions include docstrings.

Development

Clone the repository or download the ZIP via the GitHub interface.

Kerykeion v5.0 – What's New

Kerykeion v5 is a complete redesign that modernizes the library with a data-first approach, factory-based architecture, and Pydantic 2 models. This version brings significant improvements in API design, type safety, and extensibility.

Looking to upgrade from v4? See the dedicated migration guide: MIGRATION_V4_TO_V5.md.

🎯 Key Highlights

Factory-Centered Architecture

The old class-based approach has been replaced with a modern factory pattern:

Old v4 API:

from pathlib import Path
from kerykeion import AstrologicalSubject, KerykeionChartSVG

# v4 - Class-based approach
output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)

subject = AstrologicalSubject(
    "John", 1990, 1, 1, 12, 0,
    lng=-0.1276,
    lat=51.5074,
    tz_str="Europe/London",
    online=False,
)
chart = KerykeionChartSVG(subject, new_output_directory=output_dir)
chart.makeSVG()

New v5 API:

from pathlib import Path
from kerykeion import AstrologicalSubjectFactory, ChartDataFactory, ChartDrawer

# v5 - Factory-based approach with separation of concerns
subject = AstrologicalSubjectFactory.from_birth_data(
    "John", 1990, 1, 1, 12, 0,
    lng=-0.1276,
    lat=51.5074,
    tz_str="Europe/London",
    online=False,
)
chart_data = ChartDataFactory.create_natal_chart_data(subject)
drawer = ChartDrawer(chart_data=chart_data)

output_dir = Path("charts_output")
output_dir.mkdir(exist_ok=True)
drawer.save_svg(output_path=output_dir, filename="john-factory-demo")

Pydantic 2 Models & Type Safety

All data structures are now strongly typed Pydantic models:

All models support:

  • JSON serialization/deserialization
  • Dictionary export
  • Subscript access
  • Full IDE autocomplete and type checking

Enhanced Features

  • Speed & Declination: All celestial points now include daily motion speed and declination
  • Element & Quality Analysis: Automatic calculation of element/quality distributions
  • Relationship Scoring: Built-in compatibility analysis for synastry
  • House Comparison: Detailed house overlay analysis
  • Transit Time Ranges: Advanced transit tracking over time periods
  • Report Module: Comprehensive text reports with ASCII tables
  • Axis Orb Control: Chart axes now share the same orb as planets by default; pass the keyword-only axis_orb_limit to return to a traditional, tighter axis filtering when you need it.
  • Element Weight Strategies: Element and quality stats now default to a curated weighted balance; pass distribution_method or custom_distribution_weights when you need equal counts or bespoke weightings (including a __default__ fallback) across any chart factory helper.

📦 Other Notable Changes

  • Packaging: Migrated from Poetry to PEP 621 + Hatchling with uv.lock
  • Settings: Centralized in kerykeion.schemas and kerykeion.settings
  • Configuration: Default chart presets consolidated in kerykeion/settings/chart_defaults.py
  • Type System: All literals consolidated in kr_literals.py
  • Performance: Caching improvements with functools.lru_cache
  • Testing: 376 tests with 87% coverage, regenerated fixtures for v5

🎨 New Themes

Additional chart themes added:

  • classic (default)
  • dark
  • dark-high-contrast
  • light
  • strawberry
  • black-and-white

📚 Resources

Integrating Kerykeion into Your Project

If you would like to incorporate Kerykeion's astrological features into your application, please reach out via email. Whether you need custom features, support, or specialized consulting, I am happy to discuss potential collaborations.

License

This project is covered under the AGPL-3.0 License. For detailed information, please see the LICENSE file. If you have questions, feel free to contact me at kerykeion.astrology@gmail.com.

As a rule of thumb, if you use this library in a project, you should open-source that project under a compatible license. Alternatively, if you wish to keep your source closed, consider using the AstrologerAPI, which is AGPL-3.0 compliant and also helps support the project.

Since the AstrologerAPI is an external third-party service, using it does _not_ require your code to be open-source.

Contributing

Contributions are welcome! Feel free to submit pull requests or report issues.

By submitting a contribution, you agree to assign the copyright of that contribution to the maintainer. The project stays openly available under the AGPL for everyone, while the re-licensing option helps sustain future development. Your authorship remains acknowledged in the commit history and release notes.

Citations

If using Kerykeion in published or academic work, please cite as follows:

Battaglia, G. (2025). Kerykeion: A Python Library for Astrological Calculations and Chart Generation.
https://github.com/g-battaglia/kerykeion
 1# -*- coding: utf-8 -*-
 2"""
 3This is part of Kerykeion (C) 2025 Giacomo Battaglia
 4
 5.. include:: ../README.md
 6"""
 7
 8# Local
 9from .aspects import AspectsFactory
10from .astrological_subject_factory import AstrologicalSubjectFactory
11from .chart_data_factory import ChartDataFactory
12from .context_serializer import to_context
13from .schemas import KerykeionException
14from .schemas.kr_models import (
15    ChartDataModel,
16    SingleChartDataModel,
17    DualChartDataModel,
18    ElementDistributionModel,
19    QualityDistributionModel,
20    HouseComparisonModel,
21)
22from .charts.chart_drawer import ChartDrawer
23from .composite_subject_factory import CompositeSubjectFactory
24from .ephemeris_data_factory import EphemerisDataFactory
25from .house_comparison.house_comparison_factory import HouseComparisonFactory
26from .planetary_return_factory import PlanetaryReturnFactory, PlanetReturnModel
27from .relationship_score_factory import RelationshipScoreFactory
28from .report import ReportGenerator
29from .settings import KerykeionSettingsModel
30from .transits_time_range_factory import TransitsTimeRangeFactory
31from .backword import (
32    AstrologicalSubject,  # Legacy wrapper
33    KerykeionChartSVG,    # Legacy wrapper
34    NatalAspects,         # Legacy wrapper
35    SynastryAspects,      # Legacy wrapper
36)
37
38__all__ = [
39    "AspectsFactory",
40    "AstrologicalSubjectFactory",
41    "ChartDataFactory",
42    "ChartDataModel",
43    "SingleChartDataModel",
44    "DualChartDataModel",
45    "ElementDistributionModel",
46    "QualityDistributionModel",
47    "ChartDrawer",
48    "CompositeSubjectFactory",
49    "EphemerisDataFactory",
50    "HouseComparisonFactory",
51    "HouseComparisonModel",
52    "KerykeionException",
53    "PlanetaryReturnFactory",
54    "PlanetReturnModel",
55    "RelationshipScoreFactory",
56    "ReportGenerator",
57    "KerykeionSettingsModel",
58    "TransitsTimeRangeFactory",
59    "to_context",
60    # Legacy (v4) exported names for backward compatibility
61    "AstrologicalSubject",
62    "KerykeionChartSVG",
63    "NatalAspects",
64    "SynastryAspects",
65]
class AspectsFactory:
 41class AspectsFactory:
 42    """
 43    Unified factory class for creating both single chart and dual chart aspects analysis.
 44
 45    This factory provides methods to calculate all aspects within a single chart or
 46    between two charts. It consolidates the common functionality between different
 47    types of aspect calculations while providing specialized methods for each type.
 48
 49    The factory provides both comprehensive and filtered aspect lists based on orb settings
 50    and relevance criteria.
 51
 52    Key Features:
 53        - Calculates aspects within a single chart (natal, returns, composite, etc.)
 54        - Calculates aspects between two charts (synastry, transits, comparisons, etc.)
 55        - Filters aspects based on orb thresholds
 56        - Applies stricter orb limits for chart axes (ASC, MC, DSC, IC)
 57        - Supports multiple subject types (natal, composite, planetary returns)
 58
 59    Example:
 60        >>> # For single chart aspects (natal, returns, etc.)
 61        >>> johnny = AstrologicalSubjectFactory.from_birth_data("Johnny", 1963, 6, 9, 0, 0, "Owensboro", "US")
 62        >>> single_chart_aspects = AspectsFactory.single_chart_aspects(johnny)
 63        >>>
 64        >>> # For dual chart aspects (synastry, comparisons, etc.)
 65        >>> john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
 66        >>> jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR")
 67        >>> dual_chart_aspects = AspectsFactory.dual_chart_aspects(john, jane)
 68    """
 69
 70    @staticmethod
 71    def single_chart_aspects(
 72        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
 73        *,
 74        active_points: Optional[List[AstrologicalPoint]] = None,
 75        active_aspects: Optional[List[ActiveAspect]] = None,
 76        axis_orb_limit: Optional[float] = None,
 77    ) -> SingleChartAspectsModel:
 78        """
 79        Create aspects analysis for a single astrological chart.
 80
 81        This method calculates all astrological aspects (angular relationships)
 82        within a single chart. Can be used for any type of chart including:
 83        - Natal charts
 84        - Planetary return charts
 85        - Composite charts
 86        - Any other single chart type
 87
 88        Args:
 89            subject: The astrological subject for aspect calculation
 90
 91        Kwargs:
 92            active_points: List of points to include in calculations
 93            active_aspects: List of aspects with their orb settings
 94            axis_orb_limit: Optional orb threshold applied to chart axes; when None, no special axis filter
 95
 96        Returns:
 97            SingleChartAspectsModel containing all calculated aspects data
 98
 99        Example:
100            >>> johnny = AstrologicalSubjectFactory.from_birth_data("Johnny", 1963, 6, 9, 0, 0, "Owensboro", "US")
101            >>> chart_aspects = AspectsFactory.single_chart_aspects(johnny)
102            >>> print(f"Found {len(chart_aspects.aspects)} aspects")
103        """
104        # Initialize settings and configurations
105        celestial_points = DEFAULT_CELESTIAL_POINTS_SETTINGS
106        aspects_settings = DEFAULT_CHART_ASPECTS_SETTINGS
107        # Set active aspects with default fallback
108        active_aspects_resolved = active_aspects if active_aspects is not None else DEFAULT_ACTIVE_ASPECTS
109
110        # Determine active points to use
111        if active_points is None:
112            active_points_resolved = subject.active_points
113        else:
114            active_points_resolved = find_common_active_points(
115                subject.active_points,
116                active_points,
117            )
118
119        return AspectsFactory._create_single_chart_aspects_model(
120            subject,
121            active_points_resolved,
122            active_aspects_resolved,
123            aspects_settings,
124            axis_orb_limit,
125            celestial_points,
126        )
127
128    @staticmethod
129    def dual_chart_aspects(
130        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
131        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
132        *,
133        active_points: Optional[List[AstrologicalPoint]] = None,
134        active_aspects: Optional[List[ActiveAspect]] = None,
135        axis_orb_limit: Optional[float] = None,
136        first_subject_is_fixed: bool = False,
137        second_subject_is_fixed: bool = False,
138    ) -> DualChartAspectsModel:
139        """
140        Create aspects analysis between two astrological charts.
141
142        This method calculates all astrological aspects (angular relationships)
143        between planets and points in two different charts. Can be used for:
144        - Synastry (relationship compatibility)
145        - Transit comparisons
146        - Composite vs natal comparisons
147        - Any other dual chart analysis
148
149        Args:
150            first_subject: The first astrological subject
151            second_subject: The second astrological subject to compare with the first
152
153        Kwargs:
154            active_points: Optional list of celestial points to include in calculations.
155                          If None, uses common points between both subjects.
156            active_aspects: Optional list of aspect types with their orb settings.
157                           If None, uses default aspect configuration.
158            axis_orb_limit: Optional orb threshold for chart axes (applied to single chart calculations only)
159
160        Returns:
161            DualChartAspectsModel: Complete model containing all calculated aspects data,
162                                  including both comprehensive and filtered relevant aspects.
163
164        Example:
165            >>> john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
166            >>> jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR")
167            >>> synastry = AspectsFactory.dual_chart_aspects(john, jane)
168            >>> print(f"Found {len(synastry.aspects)} aspects")
169        """
170        # Initialize settings and configurations
171        celestial_points = DEFAULT_CELESTIAL_POINTS_SETTINGS
172        aspects_settings = DEFAULT_CHART_ASPECTS_SETTINGS
173        # Set active aspects with default fallback
174        active_aspects_resolved = active_aspects if active_aspects is not None else DEFAULT_ACTIVE_ASPECTS
175
176        # Determine active points to use - find common points between both subjects
177        if active_points is None:
178            active_points_resolved = first_subject.active_points
179        else:
180            active_points_resolved = find_common_active_points(
181                first_subject.active_points,
182                active_points,
183            )
184
185        # Further filter with second subject's active points
186        active_points_resolved = find_common_active_points(
187            second_subject.active_points,
188            active_points_resolved,
189        )
190
191        return AspectsFactory._create_dual_chart_aspects_model(
192            first_subject,
193            second_subject,
194            active_points_resolved,
195            active_aspects_resolved,
196            aspects_settings,
197            axis_orb_limit,
198            celestial_points,
199            first_subject_is_fixed=first_subject_is_fixed,
200            second_subject_is_fixed=second_subject_is_fixed,
201        )
202
203    @staticmethod
204    def _create_single_chart_aspects_model(
205        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
206        active_points_resolved: List[AstrologicalPoint],
207        active_aspects_resolved: List[ActiveAspect],
208        aspects_settings: List[dict],
209        axis_orb_limit: Optional[float],
210        celestial_points: List[dict]
211    ) -> SingleChartAspectsModel:
212        """
213        Create the complete single chart aspects model with all calculations.
214
215        Returns:
216            SingleChartAspectsModel containing filtered aspects data
217        """
218        all_aspects = AspectsFactory._calculate_single_chart_aspects(
219            subject, active_points_resolved, active_aspects_resolved, aspects_settings, celestial_points
220        )
221        filtered_aspects = AspectsFactory._filter_relevant_aspects(
222            all_aspects,
223            axis_orb_limit,
224            apply_axis_orb_filter=axis_orb_limit is not None,
225        )
226
227        return SingleChartAspectsModel(
228            subject=subject,
229            aspects=filtered_aspects,
230            active_points=active_points_resolved,
231            active_aspects=active_aspects_resolved,
232        )
233
234    @staticmethod
235    def _create_dual_chart_aspects_model(
236        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
237        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
238        active_points_resolved: List[AstrologicalPoint],
239        active_aspects_resolved: List[ActiveAspect],
240        aspects_settings: List[dict],
241        axis_orb_limit: Optional[float],
242        celestial_points: List[dict],
243        first_subject_is_fixed: bool,
244        second_subject_is_fixed: bool,
245    ) -> DualChartAspectsModel:
246        """
247        Create the complete dual chart aspects model with all calculations.
248
249        Args:
250            first_subject: First astrological subject
251            second_subject: Second astrological subject
252            active_points_resolved: Resolved list of active celestial points
253            active_aspects_resolved: Resolved list of active aspects with orbs
254            aspects_settings: Chart aspect configuration settings
255            axis_orb_limit: Orb threshold for chart axes
256            celestial_points: Celestial points configuration
257
258        Returns:
259            DualChartAspectsModel: Complete model containing filtered aspects data
260        """
261        all_aspects = AspectsFactory._calculate_dual_chart_aspects(
262            first_subject, second_subject, active_points_resolved, active_aspects_resolved,
263            aspects_settings, celestial_points,
264            first_subject_is_fixed=first_subject_is_fixed,
265            second_subject_is_fixed=second_subject_is_fixed,
266        )
267        filtered_aspects = AspectsFactory._filter_relevant_aspects(
268            all_aspects,
269            axis_orb_limit,
270            apply_axis_orb_filter=False,
271        )
272
273        return DualChartAspectsModel(
274            first_subject=first_subject,
275            second_subject=second_subject,
276            aspects=filtered_aspects,
277            active_points=active_points_resolved,
278            active_aspects=active_aspects_resolved,
279        )
280
281    @staticmethod
282    def _calculate_single_chart_aspects(
283        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
284        active_points: List[AstrologicalPoint],
285        active_aspects: List[ActiveAspect],
286        aspects_settings: List[dict],
287        celestial_points: List[dict]
288    ) -> List[AspectModel]:
289        """
290        Calculate all aspects within a single chart.
291
292        This method handles all aspect calculations including settings updates,
293        opposite pair filtering, and planet ID resolution for single charts.
294        Works with any chart type (natal, return, composite, etc.).
295
296        Returns:
297            List of all calculated AspectModel instances
298        """
299        active_points_list = get_active_points_list(subject, active_points)
300
301        # Update aspects settings with active aspects orbs
302        filtered_settings = AspectsFactory._update_aspect_settings(aspects_settings, active_aspects)
303
304        # Create a lookup dictionary for planet IDs to optimize performance
305        planet_id_lookup = {planet["name"]: planet["id"] for planet in celestial_points}
306
307        # Define opposite pairs that should be skipped for single chart aspects
308        opposite_pairs = {
309            ("Ascendant", "Descendant"),
310            ("Descendant", "Ascendant"),
311            ("Medium_Coeli", "Imum_Coeli"),
312            ("Imum_Coeli", "Medium_Coeli"),
313            ("True_North_Lunar_Node", "True_South_Lunar_Node"),
314            ("Mean_North_Lunar_Node", "Mean_South_Lunar_Node"),
315            ("True_South_Lunar_Node", "True_North_Lunar_Node"),
316            ("Mean_South_Lunar_Node", "Mean_North_Lunar_Node"),
317        }
318
319        all_aspects_list = []
320
321        for first in range(len(active_points_list)):
322            # Generate aspects list without repetitions (single chart - same chart)
323            for second in range(first + 1, len(active_points_list)):
324                # Skip predefined opposite pairs (AC/DC, MC/IC, North/South nodes)
325                first_name = active_points_list[first]["name"]
326                second_name = active_points_list[second]["name"]
327
328                if (first_name, second_name) in opposite_pairs:
329                    continue
330
331                aspect = get_aspect_from_two_points(
332                    filtered_settings,
333                    active_points_list[first]["abs_pos"],
334                    active_points_list[second]["abs_pos"]
335                )
336
337                if aspect["verdict"]:
338                    # Get planet IDs using lookup dictionary for better performance
339                    first_planet_id = planet_id_lookup.get(first_name, 0)
340                    second_planet_id = planet_id_lookup.get(second_name, 0)
341
342                    # Get speeds first, fall back to 0.0 only if missing/None
343                    first_speed = active_points_list[first].get("speed") or 0.0
344                    second_speed = active_points_list[second].get("speed") or 0.0
345
346                    # Determine aspect movement.
347                    # If both points are chart axes, there is no meaningful
348                    # dynamic movement between them, so we mark the aspect as
349                    # "Static" regardless of any synthetic speeds.
350                    if first_name in AXES_LIST and second_name in AXES_LIST:
351                        aspect_movement = "Static"
352                    else:
353                        # Calculate aspect movement (applying/separating/fixed)
354                        aspect_movement = calculate_aspect_movement(
355                            active_points_list[first]["abs_pos"],
356                            active_points_list[second]["abs_pos"],
357                            aspect["aspect_degrees"],
358                            first_speed,
359                            second_speed
360                        )
361
362                    aspect_model = AspectModel(
363                        p1_name=first_name,
364                        p1_owner=subject.name,
365                        p1_abs_pos=active_points_list[first]["abs_pos"],
366                        p2_name=second_name,
367                        p2_owner=subject.name,
368                        p2_abs_pos=active_points_list[second]["abs_pos"],
369                        aspect=aspect["name"],
370                        orbit=aspect["orbit"],
371                        aspect_degrees=aspect["aspect_degrees"],
372                        diff=aspect["diff"],
373                        p1=first_planet_id,
374                        p2=second_planet_id,
375                        aspect_movement=aspect_movement,
376                        p1_speed=first_speed,
377                        p2_speed=second_speed,
378                    )
379                    all_aspects_list.append(aspect_model)
380
381        return all_aspects_list
382
383    @staticmethod
384    def _calculate_dual_chart_aspects(
385        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
386        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
387        active_points: List[AstrologicalPoint],
388        active_aspects: List[ActiveAspect],
389        aspects_settings: List[dict],
390        celestial_points: List[dict],
391        first_subject_is_fixed: bool,
392        second_subject_is_fixed: bool,
393    ) -> List[AspectModel]:
394        """
395        Calculate all aspects between two charts.
396
397        This method performs comprehensive aspect calculations between all active points
398        of both subjects, applying the specified orb settings and creating detailed
399        aspect models with planet IDs and positional information.
400        Works with any chart types (synastry, transits, comparisons, etc.).
401
402        Args:
403            first_subject: First astrological subject
404            second_subject: Second astrological subject
405            active_points: List of celestial points to include in calculations
406            active_aspects: List of aspect types with their orb settings
407            aspects_settings: Base aspect configuration settings
408            celestial_points: Celestial points configuration with IDs
409
410        Returns:
411            List[AspectModel]: Complete list of all calculated aspect instances
412        """
413        # Get active points lists for both subjects
414        first_active_points_list = get_active_points_list(first_subject, active_points)
415        second_active_points_list = get_active_points_list(second_subject, active_points)
416
417        # Create a lookup dictionary for planet IDs to optimize performance
418        planet_id_lookup = {planet["name"]: planet["id"] for planet in celestial_points}
419
420        # Update aspects settings with active aspects orbs
421        filtered_settings = AspectsFactory._update_aspect_settings(aspects_settings, active_aspects)
422
423        all_aspects_list = []
424        for first in range(len(first_active_points_list)):
425            # Generate aspects list between all points of first and second subjects
426            for second in range(len(second_active_points_list)):
427                aspect = get_aspect_from_two_points(
428                    filtered_settings,
429                    first_active_points_list[first]["abs_pos"],
430                    second_active_points_list[second]["abs_pos"],
431                )
432
433                if aspect["verdict"]:
434                    first_name = first_active_points_list[first]["name"]
435                    second_name = second_active_points_list[second]["name"]
436
437                    # Get planet IDs using lookup dictionary for better performance
438                    first_planet_id = planet_id_lookup.get(first_name, 0)
439                    second_planet_id = planet_id_lookup.get(second_name, 0)
440
441                    # For aspects between axes (ASC, MC, DSC, IC) in different charts
442                    # there is no meaningful dynamic movement between two house systems,
443                    # so we mark the movement as "Static".
444                    if first_name in AXES_LIST and second_name in AXES_LIST:
445                        aspect_movement = "Static"
446                    else:
447                        # Get speeds, fall back to 0.0 only if missing/None
448                        first_speed = first_active_points_list[first].get("speed") or 0.0
449                        second_speed = second_active_points_list[second].get("speed") or 0.0
450
451                        # Override speeds if subjects are fixed
452                        if first_subject_is_fixed:
453                            first_speed = 0.0
454                        if second_subject_is_fixed:
455                            second_speed = 0.0
456
457                        # Calculate aspect movement (applying/separating/fixed)
458                        aspect_movement = calculate_aspect_movement(
459                            first_active_points_list[first]["abs_pos"],
460                            second_active_points_list[second]["abs_pos"],
461                            aspect["aspect_degrees"],
462                            first_speed,
463                            second_speed
464                        )
465
466                    aspect_model = AspectModel(
467                        p1_name=first_name,
468                        p1_owner=first_subject.name,
469                        p1_abs_pos=first_active_points_list[first]["abs_pos"],
470                        p2_name=second_name,
471                        p2_owner=second_subject.name,
472                        p2_abs_pos=second_active_points_list[second]["abs_pos"],
473                        aspect=aspect["name"],
474                        orbit=aspect["orbit"],
475                        aspect_degrees=aspect["aspect_degrees"],
476                        diff=aspect["diff"],
477                        p1=first_planet_id,
478                        p2=second_planet_id,
479                        aspect_movement=aspect_movement,
480                        p1_speed=first_speed,
481                        p2_speed=second_speed,
482                    )
483                    all_aspects_list.append(aspect_model)
484
485        return all_aspects_list
486
487    @staticmethod
488    def _update_aspect_settings(
489        aspects_settings: List[dict],
490        active_aspects: List[ActiveAspect]
491    ) -> List[dict]:
492        """
493        Update aspects settings with active aspects orbs.
494
495        This is a common utility method used by both single chart and dual chart calculations.
496
497        Args:
498            aspects_settings: Base aspect settings
499            active_aspects: Active aspects with their orb configurations
500
501        Returns:
502            List of filtered and updated aspect settings
503        """
504        filtered_settings = []
505        for aspect_setting in aspects_settings:
506            for active_aspect in active_aspects:
507                if aspect_setting["name"] == active_aspect["name"]:
508                    aspect_setting = aspect_setting.copy()  # Don't modify original
509                    aspect_setting["orb"] = active_aspect["orb"]
510                    filtered_settings.append(aspect_setting)
511                    break
512        return filtered_settings
513
514    @staticmethod
515    def _filter_relevant_aspects(
516        all_aspects: List[AspectModel],
517        axis_orb_limit: Optional[float],
518        *,
519        apply_axis_orb_filter: bool,
520    ) -> List[AspectModel]:
521        """
522        Filter aspects based on orb thresholds for axes and comprehensive criteria.
523
524        This method consolidates all filtering logic including axes checks and orb thresholds
525        for both single chart and dual chart aspects in a single comprehensive filtering method.
526
527        Args:
528            all_aspects: Complete list of calculated aspects
529            axis_orb_limit: Optional orb threshold for axes aspects
530            apply_axis_orb_filter: Whether to apply the axis-specific orb filtering logic
531
532        Returns:
533            Filtered list of relevant aspects
534        """
535        logging.debug("Calculating relevant aspects by filtering orbs...")
536
537        relevant_aspects = []
538
539        if not apply_axis_orb_filter or axis_orb_limit is None:
540            return list(all_aspects)
541
542        for aspect in all_aspects:
543            # Check if aspect involves any of the chart axes and apply stricter orb limits
544            aspect_involves_axes = (aspect.p1_name in AXES_LIST or aspect.p2_name in AXES_LIST)
545
546            if aspect_involves_axes and abs(aspect.orbit) >= axis_orb_limit:
547                continue
548
549            relevant_aspects.append(aspect)
550
551        return relevant_aspects
552
553    # Legacy methods for temporary backward compatibility
554    @staticmethod
555    def natal_aspects(
556        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
557        *,
558        active_points: Optional[List[AstrologicalPoint]] = None,
559        active_aspects: Optional[List[ActiveAspect]] = None,
560        axis_orb_limit: Optional[float] = None,
561    ) -> NatalAspectsModel:
562        """
563        Legacy method - use single_chart_aspects() instead.
564
565        ⚠️  DEPRECATION WARNING ⚠️
566        This method is deprecated. Use AspectsFactory.single_chart_aspects() instead.
567        """
568        return AspectsFactory.single_chart_aspects(
569            subject,
570            active_points=active_points,
571            active_aspects=active_aspects,
572            axis_orb_limit=axis_orb_limit,
573        )
574
575    @staticmethod
576    def synastry_aspects(
577        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
578        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
579        *,
580        active_points: Optional[List[AstrologicalPoint]] = None,
581        active_aspects: Optional[List[ActiveAspect]] = None,
582        axis_orb_limit: Optional[float] = None,
583    ) -> SynastryAspectsModel:
584        """
585        Legacy method - use dual_chart_aspects() instead.
586
587        ⚠️  DEPRECATION WARNING ⚠️
588        This method is deprecated. Use AspectsFactory.dual_chart_aspects() instead.
589        """
590        return AspectsFactory.dual_chart_aspects(
591            first_subject,
592            second_subject,
593            active_points=active_points,
594            active_aspects=active_aspects,
595            axis_orb_limit=axis_orb_limit,
596        )

Unified factory class for creating both single chart and dual chart aspects analysis.

This factory provides methods to calculate all aspects within a single chart or between two charts. It consolidates the common functionality between different types of aspect calculations while providing specialized methods for each type.

The factory provides both comprehensive and filtered aspect lists based on orb settings and relevance criteria.

Key Features: - Calculates aspects within a single chart (natal, returns, composite, etc.) - Calculates aspects between two charts (synastry, transits, comparisons, etc.) - Filters aspects based on orb thresholds - Applies stricter orb limits for chart axes (ASC, MC, DSC, IC) - Supports multiple subject types (natal, composite, planetary returns)

Example:

For single chart aspects (natal, returns, etc.)

johnny = AstrologicalSubjectFactory.from_birth_data("Johnny", 1963, 6, 9, 0, 0, "Owensboro", "US") single_chart_aspects = AspectsFactory.single_chart_aspects(johnny)

For dual chart aspects (synastry, comparisons, etc.)

john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB") jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR") dual_chart_aspects = AspectsFactory.dual_chart_aspects(john, jane)

@staticmethod
def single_chart_aspects( subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, axis_orb_limit: Optional[float] = None) -> kerykeion.schemas.kr_models.SingleChartAspectsModel:
 70    @staticmethod
 71    def single_chart_aspects(
 72        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
 73        *,
 74        active_points: Optional[List[AstrologicalPoint]] = None,
 75        active_aspects: Optional[List[ActiveAspect]] = None,
 76        axis_orb_limit: Optional[float] = None,
 77    ) -> SingleChartAspectsModel:
 78        """
 79        Create aspects analysis for a single astrological chart.
 80
 81        This method calculates all astrological aspects (angular relationships)
 82        within a single chart. Can be used for any type of chart including:
 83        - Natal charts
 84        - Planetary return charts
 85        - Composite charts
 86        - Any other single chart type
 87
 88        Args:
 89            subject: The astrological subject for aspect calculation
 90
 91        Kwargs:
 92            active_points: List of points to include in calculations
 93            active_aspects: List of aspects with their orb settings
 94            axis_orb_limit: Optional orb threshold applied to chart axes; when None, no special axis filter
 95
 96        Returns:
 97            SingleChartAspectsModel containing all calculated aspects data
 98
 99        Example:
100            >>> johnny = AstrologicalSubjectFactory.from_birth_data("Johnny", 1963, 6, 9, 0, 0, "Owensboro", "US")
101            >>> chart_aspects = AspectsFactory.single_chart_aspects(johnny)
102            >>> print(f"Found {len(chart_aspects.aspects)} aspects")
103        """
104        # Initialize settings and configurations
105        celestial_points = DEFAULT_CELESTIAL_POINTS_SETTINGS
106        aspects_settings = DEFAULT_CHART_ASPECTS_SETTINGS
107        # Set active aspects with default fallback
108        active_aspects_resolved = active_aspects if active_aspects is not None else DEFAULT_ACTIVE_ASPECTS
109
110        # Determine active points to use
111        if active_points is None:
112            active_points_resolved = subject.active_points
113        else:
114            active_points_resolved = find_common_active_points(
115                subject.active_points,
116                active_points,
117            )
118
119        return AspectsFactory._create_single_chart_aspects_model(
120            subject,
121            active_points_resolved,
122            active_aspects_resolved,
123            aspects_settings,
124            axis_orb_limit,
125            celestial_points,
126        )

Create aspects analysis for a single astrological chart.

This method calculates all astrological aspects (angular relationships) within a single chart. Can be used for any type of chart including:

  • Natal charts
  • Planetary return charts
  • Composite charts
  • Any other single chart type

Args: subject: The astrological subject for aspect calculation

Kwargs: active_points: List of points to include in calculations active_aspects: List of aspects with their orb settings axis_orb_limit: Optional orb threshold applied to chart axes; when None, no special axis filter

Returns: SingleChartAspectsModel containing all calculated aspects data

Example:

johnny = AstrologicalSubjectFactory.from_birth_data("Johnny", 1963, 6, 9, 0, 0, "Owensboro", "US") chart_aspects = AspectsFactory.single_chart_aspects(johnny) print(f"Found {len(chart_aspects.aspects)} aspects")

@staticmethod
def dual_chart_aspects( first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, axis_orb_limit: Optional[float] = None, first_subject_is_fixed: bool = False, second_subject_is_fixed: bool = False) -> kerykeion.schemas.kr_models.DualChartAspectsModel:
128    @staticmethod
129    def dual_chart_aspects(
130        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
131        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
132        *,
133        active_points: Optional[List[AstrologicalPoint]] = None,
134        active_aspects: Optional[List[ActiveAspect]] = None,
135        axis_orb_limit: Optional[float] = None,
136        first_subject_is_fixed: bool = False,
137        second_subject_is_fixed: bool = False,
138    ) -> DualChartAspectsModel:
139        """
140        Create aspects analysis between two astrological charts.
141
142        This method calculates all astrological aspects (angular relationships)
143        between planets and points in two different charts. Can be used for:
144        - Synastry (relationship compatibility)
145        - Transit comparisons
146        - Composite vs natal comparisons
147        - Any other dual chart analysis
148
149        Args:
150            first_subject: The first astrological subject
151            second_subject: The second astrological subject to compare with the first
152
153        Kwargs:
154            active_points: Optional list of celestial points to include in calculations.
155                          If None, uses common points between both subjects.
156            active_aspects: Optional list of aspect types with their orb settings.
157                           If None, uses default aspect configuration.
158            axis_orb_limit: Optional orb threshold for chart axes (applied to single chart calculations only)
159
160        Returns:
161            DualChartAspectsModel: Complete model containing all calculated aspects data,
162                                  including both comprehensive and filtered relevant aspects.
163
164        Example:
165            >>> john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
166            >>> jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR")
167            >>> synastry = AspectsFactory.dual_chart_aspects(john, jane)
168            >>> print(f"Found {len(synastry.aspects)} aspects")
169        """
170        # Initialize settings and configurations
171        celestial_points = DEFAULT_CELESTIAL_POINTS_SETTINGS
172        aspects_settings = DEFAULT_CHART_ASPECTS_SETTINGS
173        # Set active aspects with default fallback
174        active_aspects_resolved = active_aspects if active_aspects is not None else DEFAULT_ACTIVE_ASPECTS
175
176        # Determine active points to use - find common points between both subjects
177        if active_points is None:
178            active_points_resolved = first_subject.active_points
179        else:
180            active_points_resolved = find_common_active_points(
181                first_subject.active_points,
182                active_points,
183            )
184
185        # Further filter with second subject's active points
186        active_points_resolved = find_common_active_points(
187            second_subject.active_points,
188            active_points_resolved,
189        )
190
191        return AspectsFactory._create_dual_chart_aspects_model(
192            first_subject,
193            second_subject,
194            active_points_resolved,
195            active_aspects_resolved,
196            aspects_settings,
197            axis_orb_limit,
198            celestial_points,
199            first_subject_is_fixed=first_subject_is_fixed,
200            second_subject_is_fixed=second_subject_is_fixed,
201        )

Create aspects analysis between two astrological charts.

This method calculates all astrological aspects (angular relationships) between planets and points in two different charts. Can be used for:

  • Synastry (relationship compatibility)
  • Transit comparisons
  • Composite vs natal comparisons
  • Any other dual chart analysis

Args: first_subject: The first astrological subject second_subject: The second astrological subject to compare with the first

Kwargs: active_points: Optional list of celestial points to include in calculations. If None, uses common points between both subjects. active_aspects: Optional list of aspect types with their orb settings. If None, uses default aspect configuration. axis_orb_limit: Optional orb threshold for chart axes (applied to single chart calculations only)

Returns: DualChartAspectsModel: Complete model containing all calculated aspects data, including both comprehensive and filtered relevant aspects.

Example:

john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB") jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR") synastry = AspectsFactory.dual_chart_aspects(john, jane) print(f"Found {len(synastry.aspects)} aspects")

@staticmethod
def natal_aspects( subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, axis_orb_limit: Optional[float] = None) -> kerykeion.schemas.kr_models.SingleChartAspectsModel:
554    @staticmethod
555    def natal_aspects(
556        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
557        *,
558        active_points: Optional[List[AstrologicalPoint]] = None,
559        active_aspects: Optional[List[ActiveAspect]] = None,
560        axis_orb_limit: Optional[float] = None,
561    ) -> NatalAspectsModel:
562        """
563        Legacy method - use single_chart_aspects() instead.
564
565        ⚠️  DEPRECATION WARNING ⚠️
566        This method is deprecated. Use AspectsFactory.single_chart_aspects() instead.
567        """
568        return AspectsFactory.single_chart_aspects(
569            subject,
570            active_points=active_points,
571            active_aspects=active_aspects,
572            axis_orb_limit=axis_orb_limit,
573        )

Legacy method - use single_chart_aspects() instead.

⚠️ DEPRECATION WARNING ⚠️ This method is deprecated. Use AspectsFactory.single_chart_aspects() instead.

@staticmethod
def synastry_aspects( first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, axis_orb_limit: Optional[float] = None) -> kerykeion.schemas.kr_models.DualChartAspectsModel:
575    @staticmethod
576    def synastry_aspects(
577        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
578        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
579        *,
580        active_points: Optional[List[AstrologicalPoint]] = None,
581        active_aspects: Optional[List[ActiveAspect]] = None,
582        axis_orb_limit: Optional[float] = None,
583    ) -> SynastryAspectsModel:
584        """
585        Legacy method - use dual_chart_aspects() instead.
586
587        ⚠️  DEPRECATION WARNING ⚠️
588        This method is deprecated. Use AspectsFactory.dual_chart_aspects() instead.
589        """
590        return AspectsFactory.dual_chart_aspects(
591            first_subject,
592            second_subject,
593            active_points=active_points,
594            active_aspects=active_aspects,
595            axis_orb_limit=axis_orb_limit,
596        )

Legacy method - use dual_chart_aspects() instead.

⚠️ DEPRECATION WARNING ⚠️ This method is deprecated. Use AspectsFactory.dual_chart_aspects() instead.

class AstrologicalSubjectFactory:
 358class AstrologicalSubjectFactory:
 359    """
 360    Factory class for creating comprehensive astrological subjects.
 361
 362    This factory creates AstrologicalSubjectModel instances with complete astrological
 363    information including planetary positions, house cusps, aspects, lunar phases, and
 364    various specialized astrological points. It provides multiple class methods for
 365    different initialization scenarios and supports both online and offline calculation modes.
 366
 367    The factory handles complex astrological calculations using the Swiss Ephemeris library,
 368    supports multiple coordinate systems and house systems, and can automatically fetch
 369    location data from online sources.
 370
 371    Supported Astrological Points:
 372        - Traditional Planets: Sun through Pluto
 373        - Lunar Nodes: Mean and True North/South Nodes
 374        - Lilith Points: Mean and True Black Moon
 375        - Asteroids: Ceres, Pallas, Juno, Vesta
 376        - Centaurs: Chiron, Pholus
 377        - Trans-Neptunian Objects: Eris, Sedna, Haumea, Makemake, Ixion, Orcus, Quaoar
 378        - Fixed Stars: Regulus, Spica (extensible)
 379        - Arabic Parts: Pars Fortunae, Pars Spiritus, Pars Amoris, Pars Fidei
 380        - Special Points: Vertex, Anti-Vertex, Earth (for heliocentric charts)
 381        - House Cusps: All 12 houses with configurable house systems
 382        - Angles: Ascendant, Medium Coeli, Descendant, Imum Coeli
 383
 384    Supported Features:
 385        - Multiple zodiac systems (Tropical/Sidereal with various ayanamshas)
 386        - Multiple house systems (Placidus, Koch, Equal, Whole Sign, etc.)
 387        - Multiple coordinate perspectives (Geocentric, Heliocentric, Topocentric)
 388        - Automatic timezone and coordinate resolution via GeoNames API
 389        - Lunar phase calculations
 390        - Day/night chart detection for Arabic parts
 391        - Performance optimization through selective point calculation
 392        - Comprehensive error handling and validation
 393
 394    Class Methods:
 395        from_birth_data: Create subject from standard birth data (most flexible)
 396        from_iso_utc_time: Create subject from ISO UTC timestamp
 397        from_current_time: Create subject for current moment
 398
 399    Example:
 400        >>> # Create natal chart
 401        >>> subject = AstrologicalSubjectFactory.from_birth_data(
 402        ...     name="John Doe",
 403        ...     year=1990, month=6, day=15,
 404        ...     hour=14, minute=30,
 405        ...     city="Rome", nation="IT",
 406        ...     online=True
 407        ... )
 408        >>> print(f"Sun: {subject.sun.sign} {subject.sun.abs_pos}°")
 409        >>> print(f"Active points: {len(subject.active_points)}")
 410
 411        >>> # Create chart for current time
 412        >>> now_subject = AstrologicalSubjectFactory.from_current_time(
 413        ...     name="Current Moment",
 414        ...     city="London", nation="GB"
 415        ... )
 416
 417    Thread Safety:
 418        This factory is not thread-safe due to its use of the Swiss Ephemeris library
 419        which maintains global state. Use separate instances in multi-threaded applications
 420        or implement appropriate locking mechanisms.
 421    """
 422
 423    @classmethod
 424    def from_birth_data(
 425        cls,
 426        name: str = "Now",
 427        year: Optional[int] = None,
 428        month: Optional[int] = None,
 429        day: Optional[int] = None,
 430        hour: Optional[int] = None,
 431        minute: Optional[int] = None,
 432        city: Optional[str] = None,
 433        nation: Optional[str] = None,
 434        lng: Optional[float] = None,
 435        lat: Optional[float] = None,
 436        tz_str: Optional[str] = None,
 437        geonames_username: Optional[str] = None,
 438        online: bool = True,
 439        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
 440        sidereal_mode: Optional[SiderealMode] = None,
 441        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
 442        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
 443        cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
 444        is_dst: Optional[bool] = None,
 445        altitude: Optional[float] = None,
 446        active_points: Optional[List[AstrologicalPoint]] = None,
 447        calculate_lunar_phase: bool = True,
 448        *,
 449        seconds: int = 0,
 450        suppress_geonames_warning: bool = False,
 451
 452    ) -> AstrologicalSubjectModel:
 453        """
 454        Create an astrological subject from standard birth or event data.
 455
 456        This is the most flexible and commonly used factory method. It creates a complete
 457        astrological subject with planetary positions, house cusps, and specialized points
 458        for a specific date, time, and location. Supports both online location resolution
 459        and offline calculation modes.
 460
 461        Args:
 462            name (str, optional): Name or identifier for the subject. Defaults to "Now".
 463            year (int, optional): Year of birth/event. Defaults to current year.
 464            month (int, optional): Month of birth/event (1-12). Defaults to current month.
 465            day (int, optional): Day of birth/event (1-31). Defaults to current day.
 466            hour (int, optional): Hour of birth/event (0-23). Defaults to current hour.
 467            minute (int, optional): Minute of birth/event (0-59). Defaults to current minute.
 468            seconds (int, optional): Seconds of birth/event (0-59). Defaults to 0.
 469            city (str, optional): City name for location lookup. Used with online=True.
 470                Defaults to None (Greenwich if not specified).
 471            nation (str, optional): ISO country code (e.g., 'US', 'GB', 'IT'). Used with
 472                online=True. Defaults to None ('GB' if not specified).
 473            lng (float, optional): Longitude in decimal degrees. East is positive, West
 474                is negative. If not provided and online=True, fetched from GeoNames.
 475            lat (float, optional): Latitude in decimal degrees. North is positive, South
 476                is negative. If not provided and online=True, fetched from GeoNames.
 477            tz_str (str, optional): IANA timezone identifier (e.g., 'Europe/London').
 478                If not provided and online=True, fetched from GeoNames.
 479            geonames_username (str, optional): Username for GeoNames API. Required for
 480                online location lookup. Get one free at geonames.org.
 481            online (bool, optional): Whether to fetch location data online. If False,
 482                lng, lat, and tz_str must be provided. Defaults to True.
 483            zodiac_type (ZodiacType, optional): Zodiac system - 'Tropical' or 'Sidereal'.
 484                Defaults to 'Tropical'.
 485            sidereal_mode (SiderealMode, optional): Sidereal calculation mode (e.g.,
 486                'FAGAN_BRADLEY', 'LAHIRI'). Only used with zodiac_type='Sidereal'.
 487            houses_system_identifier (HousesSystemIdentifier, optional): House system
 488                for cusp calculations (e.g., 'P'=Placidus, 'K'=Koch, 'E'=Equal).
 489                Defaults to 'P' (Placidus).
 490            perspective_type (PerspectiveType, optional): Calculation perspective:
 491                - 'Apparent Geocentric': Standard geocentric with light-time correction
 492                - 'True Geocentric': Geometric geocentric positions
 493                - 'Heliocentric': Sun-centered coordinates
 494                - 'Topocentric': Earth surface perspective (requires altitude)
 495                Defaults to 'Apparent Geocentric'.
 496            cache_expire_after_days (int, optional): Days to cache GeoNames data locally.
 497                Defaults to 30.
 498            is_dst (bool, optional): Daylight Saving Time flag for ambiguous times.
 499                If None, pytz attempts automatic detection. Set explicitly for
 500                times during DST transitions.
 501            altitude (float, optional): Altitude above sea level in meters. Used for
 502                topocentric calculations and atmospheric corrections. Defaults to None
 503                (sea level assumed).
 504            active_points (Optional[List[AstrologicalPoint]], optional): List of astrological
 505                points to calculate. Omitting points can improve performance for
 506                specialized applications. If None, uses DEFAULT_ACTIVE_POINTS.
 507            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
 508                Requires Sun and Moon in active_points. Defaults to True.
 509            suppress_geonames_warning (bool, optional): If True, suppresses the warning
 510                message when using the default GeoNames username. Useful for testing
 511                or automated processes. Defaults to False.
 512
 513        Returns:
 514            AstrologicalSubjectModel: Complete astrological subject with calculated
 515                positions, houses, and metadata. Access planetary positions via
 516                attributes like .sun, .moon, .mercury, etc.
 517
 518        Raises:
 519            KerykeionException:
 520                - If offline mode is used without required location data
 521                - If invalid zodiac/sidereal mode combinations are specified
 522                - If GeoNames data is missing or invalid
 523                - If timezone localization fails (ambiguous DST times)
 524
 525        Examples:
 526            >>> # Basic natal chart with online location lookup
 527            >>> chart = AstrologicalSubjectFactory.from_birth_data(
 528            ...     name="Jane Doe",
 529            ...     year=1985, month=3, day=21,
 530            ...     hour=15, minute=30,
 531            ...     city="Paris", nation="FR",
 532            ...     geonames_username="your_username"
 533            ... )
 534
 535            >>> # Offline calculation with manual coordinates
 536            >>> chart = AstrologicalSubjectFactory.from_birth_data(
 537            ...     name="John Smith",
 538            ...     year=1990, month=12, day=25,
 539            ...     hour=0, minute=0,
 540            ...     lng=-74.006, lat=40.7128, tz_str="America/New_York",
 541            ...     online=False
 542            ... )
 543
 544            >>> # Sidereal chart with specific points
 545            >>> chart = AstrologicalSubjectFactory.from_birth_data(
 546            ...     name="Vedic Chart",
 547            ...     year=2000, month=6, day=15, hour=12,
 548            ...     city="Mumbai", nation="IN",
 549            ...     zodiac_type="Sidereal",
 550            ...     sidereal_mode="LAHIRI",
 551            ...     active_points=["Sun", "Moon", "Mercury", "Venus", "Mars",
 552            ...                   "Jupiter", "Saturn", "Ascendant"]
 553            ... )
 554
 555        Note:
 556            - For high-precision calculations, consider providing seconds parameter
 557            - Use topocentric perspective for observer-specific calculations
 558            - Some Arabic parts automatically activate required base points
 559            - The method handles polar regions by adjusting extreme latitudes
 560            - Time zones are handled with full DST awareness via pytz
 561        """
 562        # Resolve time defaults using current time
 563        if year is None or month is None or day is None or hour is None or minute is None or seconds is None:
 564            now = datetime.now()
 565            year = year if year is not None else now.year
 566            month = month if month is not None else now.month
 567            day = day if day is not None else now.day
 568            hour = hour if hour is not None else now.hour
 569            minute = minute if minute is not None else now.minute
 570            seconds = seconds if seconds is not None else now.second
 571
 572        # Create a calculation data container
 573        calc_data: Dict[str, Any] = {}
 574
 575        # Basic identity
 576        calc_data["name"] = name
 577        calc_data["json_dir"] = str(Path.home())
 578
 579        # Create a deep copy of active points to avoid modifying the original list
 580        if active_points is None:
 581            active_points_list: List[AstrologicalPoint] = list(DEFAULT_ACTIVE_POINTS)
 582        else:
 583            active_points_list = list(active_points)
 584
 585        calc_data["active_points"] = active_points_list
 586
 587        # Initialize configuration
 588        config = ChartConfiguration(
 589            zodiac_type=zodiac_type,
 590            sidereal_mode=sidereal_mode,
 591            houses_system_identifier=houses_system_identifier,
 592            perspective_type=perspective_type,
 593        )
 594
 595        # Add configuration data to calculation data
 596        calc_data["zodiac_type"] = config.zodiac_type
 597        calc_data["sidereal_mode"] = config.sidereal_mode
 598        calc_data["houses_system_identifier"] = config.houses_system_identifier
 599        calc_data["perspective_type"] = config.perspective_type
 600
 601        # Set up geonames username if needed
 602        if geonames_username is None and online and (not lat or not lng or not tz_str):
 603            if not suppress_geonames_warning:
 604                logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
 605            geonames_username = DEFAULT_GEONAMES_USERNAME
 606
 607        # Initialize location data
 608        location = LocationData(
 609            city=city or "Greenwich",
 610            nation=nation or "GB",
 611            lat=lat if lat is not None else 51.5074,
 612            lng=lng if lng is not None else 0.0,
 613            tz_str=tz_str or "Etc/GMT",
 614            altitude=altitude
 615        )
 616
 617        # If offline mode is requested but required data is missing, raise error
 618        if not online and (not tz_str or lat is None or lng is None):
 619            raise KerykeionException(
 620                "For offline mode, you must provide timezone (tz_str) and coordinates (lat, lng)"
 621            )
 622
 623        # Fetch location data if needed
 624        if online and (not tz_str or lat is None or lng is None):
 625            location.fetch_from_geonames(
 626                username=geonames_username or DEFAULT_GEONAMES_USERNAME,
 627                cache_expire_after_days=cache_expire_after_days
 628            )
 629
 630        # Prepare location for calculations
 631        location.prepare_for_calculation()
 632
 633        # Add location data to calculation data
 634        calc_data["city"] = location.city
 635        calc_data["nation"] = location.nation
 636        calc_data["lat"] = location.lat
 637        calc_data["lng"] = location.lng
 638        calc_data["tz_str"] = location.tz_str
 639        calc_data["altitude"] = location.altitude
 640
 641        # Store calculation parameters
 642        calc_data["year"] = year
 643        calc_data["month"] = month
 644        calc_data["day"] = day
 645        calc_data["hour"] = hour
 646        calc_data["minute"] = minute
 647        calc_data["seconds"] = seconds
 648        calc_data["is_dst"] = is_dst
 649
 650        # Calculate time conversions
 651        AstrologicalSubjectFactory._calculate_time_conversions(calc_data, location)
 652        # Initialize Swiss Ephemeris and calculate houses and planets with context manager
 653        ephe_path = str(Path(__file__).parent.absolute() / "sweph")
 654        with ephemeris_context(
 655            ephe_path=ephe_path,
 656            config=config,
 657            lng=calc_data["lng"],
 658            lat=calc_data["lat"],
 659            alt=calc_data["altitude"],
 660        ) as iflag:
 661            calc_data["_iflag"] = iflag
 662            # House system name (previously set in _setup_ephemeris)
 663            calc_data["houses_system_name"] = swe.house_name(
 664                config.houses_system_identifier.encode("ascii")
 665            )
 666            calculated_axial_cusps = AstrologicalSubjectFactory._calculate_houses(
 667                calc_data, active_points_list
 668            )
 669            AstrologicalSubjectFactory._calculate_planets(
 670                calc_data, active_points_list, calculated_axial_cusps
 671            )
 672        AstrologicalSubjectFactory._calculate_day_of_week(calc_data)
 673
 674        # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
 675        if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
 676            calc_data["lunar_phase"] = calculate_moon_phase(
 677                calc_data["moon"].abs_pos,  # type: ignore[attr-defined,union-attr]
 678                calc_data["sun"].abs_pos  # type: ignore[attr-defined,union-attr]
 679            )
 680        else:
 681            calc_data["lunar_phase"] = None
 682
 683        # Create and return the AstrologicalSubjectModel
 684        return AstrologicalSubjectModel(**calc_data)
 685
 686    @classmethod
 687    def from_iso_utc_time(
 688        cls,
 689        name: str,
 690        iso_utc_time: str,
 691        city: str = "Greenwich",
 692        nation: str = "GB",
 693        tz_str: str = "Etc/GMT",
 694        online: bool = True,
 695        lng: float = 0.0,
 696        lat: float = 51.5074,
 697        geonames_username: str = DEFAULT_GEONAMES_USERNAME,
 698        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
 699        sidereal_mode: Optional[SiderealMode] = None,
 700        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
 701        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
 702        altitude: Optional[float] = None,
 703        active_points: Optional[List[AstrologicalPoint]] = None,
 704        calculate_lunar_phase: bool = True,
 705        suppress_geonames_warning: bool = False
 706    ) -> AstrologicalSubjectModel:
 707        """
 708        Create an astrological subject from an ISO formatted UTC timestamp.
 709
 710        This method is ideal for creating astrological subjects from standardized
 711        time formats, such as those stored in databases or received from APIs.
 712        It automatically handles timezone conversion from UTC to the specified
 713        local timezone.
 714
 715        Args:
 716            name (str): Name or identifier for the subject.
 717            iso_utc_time (str): ISO 8601 formatted UTC timestamp. Supported formats:
 718                - "2023-06-15T14:30:00Z" (with Z suffix)
 719                - "2023-06-15T14:30:00+00:00" (with UTC offset)
 720                - "2023-06-15T14:30:00.123Z" (with milliseconds)
 721            city (str, optional): City name for location. Defaults to "Greenwich".
 722            nation (str, optional): ISO country code. Defaults to "GB".
 723            tz_str (str, optional): IANA timezone identifier for result conversion.
 724                The ISO time is assumed to be in UTC and will be converted to this
 725                timezone. Defaults to "Etc/GMT".
 726            online (bool, optional): Whether to fetch coordinates online. If True,
 727                coordinates are fetched via GeoNames API. Defaults to True.
 728            lng (float, optional): Longitude in decimal degrees. Used when online=False
 729                or as fallback. Defaults to 0.0 (Greenwich).
 730            lat (float, optional): Latitude in decimal degrees. Used when online=False
 731                or as fallback. Defaults to 51.5074 (Greenwich).
 732            geonames_username (str, optional): GeoNames API username. Required when
 733                online=True. Defaults to DEFAULT_GEONAMES_USERNAME.
 734            zodiac_type (ZodiacType, optional): Zodiac system. Defaults to 'Tropical'.
 735            sidereal_mode (SiderealMode, optional): Sidereal mode when zodiac_type
 736                is 'Sidereal'. Defaults to None.
 737            houses_system_identifier (HousesSystemIdentifier, optional): House system.
 738                Defaults to 'P' (Placidus).
 739            perspective_type (PerspectiveType, optional): Calculation perspective.
 740                Defaults to 'Apparent Geocentric'.
 741            altitude (float, optional): Altitude in meters for topocentric calculations.
 742                Defaults to None (sea level).
 743            active_points (Optional[List[AstrologicalPoint]], optional): Points to calculate.
 744                If None, uses DEFAULT_ACTIVE_POINTS.
 745            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
 746                Defaults to True.
 747
 748        Returns:
 749            AstrologicalSubjectModel: Astrological subject with positions calculated
 750                for the specified UTC time converted to local timezone.
 751
 752        Raises:
 753            ValueError: If the ISO timestamp format is invalid or cannot be parsed.
 754            KerykeionException: If location data cannot be fetched or is invalid.
 755
 756        Examples:
 757            >>> # From API timestamp with online location lookup
 758            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
 759            ...     name="Event Chart",
 760            ...     iso_utc_time="2023-12-25T12:00:00Z",
 761            ...     city="Tokyo", nation="JP",
 762            ...     tz_str="Asia/Tokyo",
 763            ...     geonames_username="your_username"
 764            ... )
 765
 766            >>> # From database timestamp with manual coordinates
 767            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
 768            ...     name="Historical Event",
 769            ...     iso_utc_time="1969-07-20T20:17:00Z",
 770            ...     lng=-95.0969, lat=37.4419,  # Houston
 771            ...     tz_str="America/Chicago",
 772            ...     online=False
 773            ... )
 774
 775        Note:
 776            - The method assumes the input timestamp is in UTC
 777            - Local time conversion respects DST rules for the target timezone
 778            - Milliseconds in the timestamp are supported but truncated to seconds
 779            - When online=True, the city/nation parameters override lng/lat
 780        """
 781        # Parse the ISO time
 782        dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
 783
 784        # Get location data if online mode is enabled
 785        if online:
 786            if geonames_username == DEFAULT_GEONAMES_USERNAME:
 787                if not suppress_geonames_warning:
 788                    logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
 789
 790            geonames = FetchGeonames(
 791                city,
 792                nation,
 793                username=geonames_username,
 794            )
 795
 796            city_data = geonames.get_serialized_data()
 797            lng = float(city_data["lng"])
 798            lat = float(city_data["lat"])
 799
 800        # Convert UTC to local time
 801        local_time = pytz.timezone(tz_str)
 802        local_datetime = dt.astimezone(local_time)
 803
 804        # Create the subject with local time
 805        return cls.from_birth_data(
 806            name=name,
 807            year=local_datetime.year,
 808            month=local_datetime.month,
 809            day=local_datetime.day,
 810            hour=local_datetime.hour,
 811            minute=local_datetime.minute,
 812            seconds=local_datetime.second,
 813            city=city,
 814            nation=nation,
 815            lng=lng,
 816            lat=lat,
 817            tz_str=tz_str,
 818            online=False,  # Already fetched data if needed
 819            geonames_username=geonames_username,
 820            zodiac_type=zodiac_type,
 821            sidereal_mode=sidereal_mode,
 822            houses_system_identifier=houses_system_identifier,
 823            perspective_type=perspective_type,
 824            altitude=altitude,
 825            active_points=active_points,
 826            calculate_lunar_phase=calculate_lunar_phase,
 827            suppress_geonames_warning=suppress_geonames_warning
 828        )
 829
 830    @classmethod
 831    def from_current_time(
 832        cls,
 833        name: str = "Now",
 834        city: Optional[str] = None,
 835        nation: Optional[str] = None,
 836        lng: Optional[float] = None,
 837        lat: Optional[float] = None,
 838        tz_str: Optional[str] = None,
 839        geonames_username: Optional[str] = None,
 840        online: bool = True,
 841        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
 842        sidereal_mode: Optional[SiderealMode] = None,
 843        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
 844        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
 845        active_points: Optional[List[AstrologicalPoint]] = None,
 846        calculate_lunar_phase: bool = True,
 847        suppress_geonames_warning: bool = False
 848    ) -> AstrologicalSubjectModel:
 849        """
 850        Create an astrological subject for the current moment in time.
 851
 852        This convenience method creates a "now" chart, capturing the current
 853        astrological conditions at the moment of execution. Useful for horary
 854        astrology, electional astrology, or real-time astrological monitoring.
 855
 856        Args:
 857            name (str, optional): Name for the current moment chart.
 858                Defaults to "Now".
 859            city (str, optional): City name for location lookup. If not provided
 860                and online=True, defaults to Greenwich.
 861            nation (str, optional): ISO country code. If not provided and
 862                online=True, defaults to 'GB'.
 863            lng (float, optional): Longitude in decimal degrees. If not provided
 864                and online=True, fetched from GeoNames API.
 865            lat (float, optional): Latitude in decimal degrees. If not provided
 866                and online=True, fetched from GeoNames API.
 867            tz_str (str, optional): IANA timezone identifier. If not provided
 868                and online=True, fetched from GeoNames API.
 869            geonames_username (str, optional): GeoNames API username for location
 870                lookup. Required when online=True and location is not fully specified.
 871            online (bool, optional): Whether to fetch location data online.
 872                Defaults to True.
 873            zodiac_type (ZodiacType, optional): Zodiac system to use.
 874                Defaults to 'Tropical'.
 875            sidereal_mode (SiderealMode, optional): Sidereal calculation mode.
 876                Only used when zodiac_type is 'Sidereal'. Defaults to None.
 877            houses_system_identifier (HousesSystemIdentifier, optional): House
 878                system for calculations. Defaults to 'P' (Placidus).
 879            perspective_type (PerspectiveType, optional): Calculation perspective.
 880                Defaults to 'Apparent Geocentric'.
 881            active_points (Optional[List[AstrologicalPoint]], optional): Astrological points
 882                to calculate. If None, uses DEFAULT_ACTIVE_POINTS.
 883            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
 884                Defaults to True.
 885
 886        Returns:
 887            AstrologicalSubjectModel: Astrological subject representing current
 888                astrological conditions at the specified or default location.
 889
 890        Raises:
 891            KerykeionException: If online location lookup fails or if offline mode
 892                is used without sufficient location data.
 893
 894        Examples:
 895            >>> # Current moment for your location
 896            >>> now_chart = AstrologicalSubjectFactory.from_current_time(
 897            ...     name="Current Transits",
 898            ...     city="New York", nation="US",
 899            ...     geonames_username="your_username"
 900            ... )
 901
 902            >>> # Horary chart with specific coordinates
 903            >>> horary = AstrologicalSubjectFactory.from_current_time(
 904            ...     name="Horary Question",
 905            ...     lng=-0.1278, lat=51.5074,  # London
 906            ...     tz_str="Europe/London",
 907            ...     online=False
 908            ... )
 909
 910            >>> # Current sidereal positions
 911            >>> sidereal_now = AstrologicalSubjectFactory.from_current_time(
 912            ...     name="Sidereal Now",
 913            ...     city="Mumbai", nation="IN",
 914            ...     zodiac_type="Sidereal",
 915            ...     sidereal_mode="LAHIRI"
 916            ... )
 917
 918        Note:
 919            - The exact time is captured at method execution, including seconds
 920            - For horary astrology, consider the moment of understanding the question
 921            - System clock accuracy affects precision; ensure accurate system time
 922            - Time zone detection is automatic when using online location lookup
 923        """
 924        now = datetime.now()
 925
 926        return cls.from_birth_data(
 927            name=name,
 928            year=now.year,
 929            month=now.month,
 930            day=now.day,
 931            hour=now.hour,
 932            minute=now.minute,
 933            seconds=now.second,
 934            city=city,
 935            nation=nation,
 936            lng=lng,
 937            lat=lat,
 938            tz_str=tz_str,
 939            geonames_username=geonames_username,
 940            online=online,
 941            zodiac_type=zodiac_type,
 942            sidereal_mode=sidereal_mode,
 943            houses_system_identifier=houses_system_identifier,
 944            perspective_type=perspective_type,
 945            active_points=active_points,
 946            calculate_lunar_phase=calculate_lunar_phase,
 947            suppress_geonames_warning=suppress_geonames_warning
 948        )
 949
 950    @staticmethod
 951    def _calculate_time_conversions(data: Dict[str, Any], location: LocationData) -> None:
 952        """
 953        Calculate time conversions between local time, UTC, and Julian Day Number.
 954
 955        Handles timezone-aware conversion from local civil time to UTC and astronomical
 956        Julian Day Number, including proper DST handling and timezone localization.
 957
 958        Args:
 959            data (Dict[str, Any]): Calculation data dictionary containing time components
 960                (year, month, day, hour, minute, seconds) and optional DST flag.
 961            location (LocationData): Location data containing timezone information.
 962
 963        Raises:
 964            KerykeionException: If DST ambiguity occurs during timezone transitions
 965                and is_dst parameter is not explicitly set to resolve the ambiguity.
 966
 967        Side Effects:
 968            Updates data dictionary with:
 969            - iso_formatted_utc_datetime: ISO 8601 UTC timestamp
 970            - iso_formatted_local_datetime: ISO 8601 local timestamp
 971            - julian_day: Julian Day Number for astronomical calculations
 972
 973        Note:
 974            During DST transitions, times may be ambiguous (fall back) or non-existent
 975            (spring forward). The method raises an exception for ambiguous times unless
 976            the is_dst parameter is explicitly set to True or False.
 977        """
 978        # Convert local time to UTC
 979        local_timezone = pytz.timezone(location.tz_str)
 980        naive_datetime = datetime(
 981            data["year"], data["month"], data["day"],
 982            data["hour"], data["minute"], data["seconds"]
 983        )
 984
 985        try:
 986            local_datetime = local_timezone.localize(naive_datetime, is_dst=data.get("is_dst"))
 987        except pytz.exceptions.AmbiguousTimeError:
 988            raise KerykeionException(
 989                "Ambiguous time error! The time falls during a DST transition. "
 990                "Please specify is_dst=True or is_dst=False to clarify."
 991            )
 992        except pytz.exceptions.NonExistentTimeError:
 993            raise KerykeionException(
 994                "Non-existent time error! The time does not exist due to DST transition (spring forward). "
 995                "Please specify a valid time."
 996            )
 997
 998        # Store formatted times
 999        utc_datetime = local_datetime.astimezone(pytz.utc)
1000        data["iso_formatted_utc_datetime"] = utc_datetime.isoformat()
1001        data["iso_formatted_local_datetime"] = local_datetime.isoformat()
1002
1003        # Calculate Julian day
1004        data["julian_day"] = datetime_to_julian(utc_datetime)
1005
1006
1007    @staticmethod
1008    def _calculate_houses(data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> List[AstrologicalPoint]:
1009        """
1010        Calculate house cusps and angular points (Ascendant, MC, etc.).
1011
1012        Computes the 12 house cusps using the specified house system and calculates
1013        the four main angles of the chart. Only calculates angular points that are
1014        included in the active_points list for performance optimization.
1015
1016        Args:
1017            data (Dict[str, Any]): Calculation data dictionary containing configuration
1018                and location information. Updated with calculated house and angle data.
1019            active_points (Optional[List[AstrologicalPoint]]): List of points to calculate.
1020                If None, all points are calculated. Angular points not in this list
1021                are skipped for performance.
1022
1023        Side Effects:
1024            Updates data dictionary with:
1025            - House cusp objects: first_house through twelfth_house
1026            - Angular points: ascendant, medium_coeli, descendant, imum_coeli
1027            - houses_names_list: List of all house names
1028            - _houses_degree_ut: Raw house cusp degrees for internal use
1029
1030        House Systems Supported:
1031            All systems supported by Swiss Ephemeris including Placidus, Koch,
1032            Equal House, Whole Sign, Regiomontanus, Campanus, Topocentric, etc.
1033
1034        Angular Points Calculated:
1035            - Ascendant: Eastern horizon point (1st house cusp)
1036            - Medium Coeli (Midheaven): Southern meridian point (10th house cusp)
1037            - Descendant: Western horizon point (opposite Ascendant)
1038            - Imum Coeli: Northern meridian point (opposite Medium Coeli)
1039
1040        Note:
1041            House calculations respect the zodiac type (Tropical/Sidereal) and use
1042            the appropriate Swiss Ephemeris function. Angular points include house
1043            position and retrograde status (always False for angles).
1044        """
1045        # Skip calculation if point is not in active_points
1046        def should_calculate(point: AstrologicalPoint) -> bool:
1047            return not active_points or point in active_points
1048        # Track which axial cusps are actually calculated
1049        calculated_axial_cusps: List[AstrologicalPoint] = []
1050
1051        # Calculate houses based on zodiac type
1052        if data["zodiac_type"] == "Sidereal":
1053            cusps, ascmc = swe.houses_ex(
1054                tjdut=data["julian_day"],
1055                lat=data["lat"],
1056                lon=data["lng"],
1057                hsys=str.encode(data["houses_system_identifier"]),
1058                flags=swe.FLG_SIDEREAL
1059            )
1060        else:  # Tropical zodiac
1061            cusps, ascmc = swe.houses(
1062                tjdut=data["julian_day"],
1063                lat=data["lat"],
1064                lon=data["lng"],
1065                hsys=str.encode(data["houses_system_identifier"])
1066            )
1067
1068        # Store house degrees
1069        data["_houses_degree_ut"] = cusps
1070
1071        # Create house objects
1072        point_type: PointType = "House"
1073        data["first_house"] = get_kerykeion_point_from_degree(cusps[0], "First_House", point_type=point_type)
1074        data["second_house"] = get_kerykeion_point_from_degree(cusps[1], "Second_House", point_type=point_type)
1075        data["third_house"] = get_kerykeion_point_from_degree(cusps[2], "Third_House", point_type=point_type)
1076        data["fourth_house"] = get_kerykeion_point_from_degree(cusps[3], "Fourth_House", point_type=point_type)
1077        data["fifth_house"] = get_kerykeion_point_from_degree(cusps[4], "Fifth_House", point_type=point_type)
1078        data["sixth_house"] = get_kerykeion_point_from_degree(cusps[5], "Sixth_House", point_type=point_type)
1079        data["seventh_house"] = get_kerykeion_point_from_degree(cusps[6], "Seventh_House", point_type=point_type)
1080        data["eighth_house"] = get_kerykeion_point_from_degree(cusps[7], "Eighth_House", point_type=point_type)
1081        data["ninth_house"] = get_kerykeion_point_from_degree(cusps[8], "Ninth_House", point_type=point_type)
1082        data["tenth_house"] = get_kerykeion_point_from_degree(cusps[9], "Tenth_House", point_type=point_type)
1083        data["eleventh_house"] = get_kerykeion_point_from_degree(cusps[10], "Eleventh_House", point_type=point_type)
1084        data["twelfth_house"] = get_kerykeion_point_from_degree(cusps[11], "Twelfth_House", point_type=point_type)
1085
1086        # Store house names
1087        data["houses_names_list"] = list(get_args(Houses))
1088
1089        # Calculate axis points
1090        point_type = "AstrologicalPoint"
1091
1092        # NOTE: Swiss Ephemeris does not provide direct speeds for angles (ASC/MC),
1093        # but in realtà si muovono molto velocemente rispetto ai pianeti.
1094        # Per rappresentare questo in modo coerente, assegniamo ai quattro assi
1095        # una speed sintetica fissa, molto più alta di quella planetaria tipica.
1096        # Questo permette a calculate_aspect_movement di considerarli sempre come
1097        # "più veloci" rispetto ai pianeti quando serve.
1098        axis_speed = 360.0  # gradi/giorno, valore simbolico ma coerente
1099
1100        # Calculate Ascendant if needed
1101        if should_calculate("Ascendant"):
1102            data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type, speed=axis_speed)
1103            data["ascendant"].house = get_planet_house(data["ascendant"].abs_pos, data["_houses_degree_ut"])
1104            data["ascendant"].retrograde = False
1105            calculated_axial_cusps.append("Ascendant")
1106
1107        # Calculate Medium Coeli if needed
1108        if should_calculate("Medium_Coeli"):
1109            data["medium_coeli"] = get_kerykeion_point_from_degree(ascmc[1], "Medium_Coeli", point_type=point_type, speed=axis_speed)
1110            data["medium_coeli"].house = get_planet_house(data["medium_coeli"].abs_pos, data["_houses_degree_ut"])
1111            data["medium_coeli"].retrograde = False
1112            calculated_axial_cusps.append("Medium_Coeli")
1113
1114        # Calculate Descendant if needed
1115        if should_calculate("Descendant"):
1116            dsc_deg = math.fmod(ascmc[0] + 180, 360)
1117            data["descendant"] = get_kerykeion_point_from_degree(dsc_deg, "Descendant", point_type=point_type, speed=axis_speed)
1118            data["descendant"].house = get_planet_house(data["descendant"].abs_pos, data["_houses_degree_ut"])
1119            data["descendant"].retrograde = False
1120            calculated_axial_cusps.append("Descendant")
1121
1122        # Calculate Imum Coeli if needed
1123        if should_calculate("Imum_Coeli"):
1124            ic_deg = math.fmod(ascmc[1] + 180, 360)
1125            data["imum_coeli"] = get_kerykeion_point_from_degree(ic_deg, "Imum_Coeli", point_type=point_type, speed=axis_speed)
1126            data["imum_coeli"].house = get_planet_house(data["imum_coeli"].abs_pos, data["_houses_degree_ut"])
1127            data["imum_coeli"].retrograde = False
1128            calculated_axial_cusps.append("Imum_Coeli")
1129
1130        return calculated_axial_cusps
1131
1132    @staticmethod
1133    def _calculate_single_planet(
1134        data: Dict[str, Any],
1135        planet_name: AstrologicalPoint,
1136        planet_id: int,
1137        julian_day: float,
1138        iflag: int,
1139        houses_degree_ut: List[float],
1140        point_type: PointType,
1141        calculated_planets: List[AstrologicalPoint],
1142        active_points: List[AstrologicalPoint]
1143    ) -> None:
1144        """
1145        Calculate a single celestial body's position with comprehensive error handling.
1146
1147        Computes the position of a single planet, asteroid, or other celestial object
1148        using Swiss Ephemeris, creates a Kerykeion point object, determines house
1149        position, and assesses retrograde status. Handles calculation errors gracefully
1150        by logging and removing failed points from the active list.
1151
1152        Args:
1153            data (Dict[str, Any]): Main calculation data dictionary to store results.
1154            planet_name (AstrologicalPoint): Name identifier for the celestial body.
1155            planet_id (int): Swiss Ephemeris numerical identifier for the object.
1156            julian_day (float): Julian Day Number for the calculation moment.
1157            iflag (int): Swiss Ephemeris calculation flags (perspective, zodiac, etc.).
1158            houses_degree_ut (List[float]): House cusp degrees for house determination.
1159            point_type (PointType): Classification of the point type for the object.
1160            calculated_planets (List[str]): Running list of successfully calculated objects.
1161            active_points (List[AstrologicalPoint]): Active points list (modified on error).
1162
1163        Side Effects:
1164            - Adds calculated object to data dictionary using lowercase planet_name as key
1165            - Appends planet_name to calculated_planets list on success
1166            - Removes planet_name from active_points list on calculation failure
1167            - Logs error messages for calculation failures
1168
1169        Calculated Properties:
1170            - Zodiacal position (longitude) in degrees
1171            - House position based on house cusp positions
1172            - Retrograde status based on velocity (negative = retrograde)
1173            - Sign, degree, and minute components
1174
1175        Error Handling:
1176            If Swiss Ephemeris calculation fails (e.g., for distant asteroids outside
1177            ephemeris range), the method logs the error and removes the object from
1178            active_points to prevent cascade failures.
1179
1180        Note:
1181            The method uses the Swiss Ephemeris calc_ut function which returns position
1182            and velocity data. Retrograde determination is based on the velocity
1183            component being negative (element index 3).
1184        """
1185        try:
1186            # Calculate planet position using Swiss Ephemeris (ecliptic coordinates)
1187            planet_calc = swe.calc_ut(julian_day, planet_id, iflag)[0]
1188
1189            # Get declination from equatorial coordinates
1190            planet_eq = swe.calc_ut(julian_day, planet_id, iflag | swe.FLG_EQUATORIAL)[0]
1191            declination = planet_eq[1]  # Declination from equatorial coordinates
1192
1193            # Create Kerykeion point from degree
1194            data[planet_name.lower()] = get_kerykeion_point_from_degree(
1195                planet_calc[0], planet_name, point_type=point_type, speed=planet_calc[3], declination=declination
1196            )
1197
1198            # Calculate house position
1199            data[planet_name.lower()].house = get_planet_house(planet_calc[0], houses_degree_ut)
1200
1201            # Determine if planet is retrograde
1202            data[planet_name.lower()].retrograde = planet_calc[3] < 0
1203
1204            # Track calculated planet
1205            calculated_planets.append(planet_name)
1206
1207        except Exception as e:
1208            logging.error(f"Error calculating {planet_name}: {e}")
1209            if planet_name in active_points:
1210                active_points.remove(planet_name)
1211
1212    @staticmethod
1213    def _calculate_planets(data: Dict[str, Any], active_points: List[AstrologicalPoint], calculated_axial_cusps: Optional[List[AstrologicalPoint]] = None) -> None:
1214        """
1215        Calculate positions for all requested celestial bodies and special points.
1216
1217        This comprehensive method calculates positions for a wide range of astrological
1218        points including traditional planets, lunar nodes, asteroids, trans-Neptunian
1219        objects, fixed stars, Arabic parts, and specialized points like Vertex.
1220
1221        The calculation is performed selectively based on the active_points list for
1222        performance optimization. Some Arabic parts automatically activate their
1223        prerequisite points if needed.
1224
1225        Args:
1226            data (Dict[str, Any]): Main calculation data dictionary. Updated with all
1227                calculated planetary positions and related metadata.
1228            active_points (List[AstrologicalPoint]): Mutable list of points to calculate.
1229                Modified during execution to remove failed calculations and add
1230                automatically required points for Arabic parts.
1231
1232        Celestial Bodies Calculated:
1233            Traditional Planets:
1234                - Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn
1235                - Uranus, Neptune, Pluto
1236
1237            Lunar Nodes:
1238                - Mean Node, True Node (North nodes)
1239                - Mean South Node, True South Node (calculated as opposites)
1240
1241            Lilith Points:
1242                - Mean Lilith (Mean Black Moon Lilith)
1243                - True Lilith (Osculating Black Moon Lilith)
1244
1245            Asteroids:
1246                - Ceres, Pallas, Juno, Vesta (main belt asteroids)
1247
1248            Centaurs:
1249                - Chiron, Pholus
1250
1251            Trans-Neptunian Objects:
1252                - Eris, Sedna, Haumea, Makemake
1253                - Ixion, Orcus, Quaoar
1254
1255            Fixed Stars:
1256                - Regulus, Spica (examples, extensible)
1257
1258            Arabic Parts (Lots):
1259                - Pars Fortunae (Part of Fortune)
1260                - Pars Spiritus (Part of Spirit)
1261                - Pars Amoris (Part of Love/Eros)
1262                - Pars Fidei (Part of Faith)
1263
1264            Special Points:
1265                - Earth (for heliocentric perspectives)
1266                - Vertex and Anti-Vertex
1267
1268        Side Effects:
1269            - Updates data dictionary with all calculated positions
1270            - Modifies active_points list by removing failed calculations
1271            - Adds prerequisite points for Arabic parts calculations
1272            - Updates data["active_points"] with successfully calculated objects
1273
1274        Error Handling:
1275            Individual calculation failures (e.g., asteroids outside ephemeris range)
1276            are handled gracefully with logging and removal from active_points.
1277            This prevents cascade failures while maintaining calculation integrity.
1278
1279        Arabic Parts Logic:
1280            - Day/night birth detection based on Sun's house position
1281            - Automatic activation of required base points (Sun, Moon, Ascendant, etc.)
1282            - Classical formulae with day/night variations where applicable
1283            - All parts marked as non-retrograde (conceptual points)
1284
1285        Performance Notes:
1286            - Only points in active_points are calculated (selective computation)
1287            - Failed calculations are removed to prevent repeated attempts
1288            - Some expensive calculations (like distant TNOs) may timeout
1289            - Fixed stars use different calculation methods than planets
1290
1291        Note:
1292            The method maintains a running list of successfully calculated planets
1293            and updates the active_points list to reflect actual availability.
1294            This ensures that dependent calculations and aspects only use valid data.
1295        """
1296        # Skip calculation if point is not in active_points
1297        def should_calculate(point: AstrologicalPoint) -> bool:
1298            return not active_points or point in active_points
1299
1300        point_type: PointType = "AstrologicalPoint"
1301        julian_day = data["julian_day"]
1302        iflag = data["_iflag"]
1303        houses_degree_ut = data["_houses_degree_ut"]
1304
1305        # Track which planets are actually calculated
1306        calculated_planets: List[AstrologicalPoint] = []
1307
1308        # ==================
1309        # MAIN PLANETS
1310        # ==================
1311
1312        # Calculate Sun
1313        if should_calculate("Sun"):
1314            AstrologicalSubjectFactory._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1315
1316        # Calculate Moon
1317        if should_calculate("Moon"):
1318            AstrologicalSubjectFactory._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1319
1320        # Calculate Mercury
1321        if should_calculate("Mercury"):
1322            AstrologicalSubjectFactory._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1323
1324        # Calculate Venus
1325        if should_calculate("Venus"):
1326            AstrologicalSubjectFactory._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1327
1328        # Calculate Mars
1329        if should_calculate("Mars"):
1330            AstrologicalSubjectFactory._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1331
1332        # Calculate Jupiter
1333        if should_calculate("Jupiter"):
1334            AstrologicalSubjectFactory._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1335
1336        # Calculate Saturn
1337        if should_calculate("Saturn"):
1338            AstrologicalSubjectFactory._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1339
1340        # Calculate Uranus
1341        if should_calculate("Uranus"):
1342            AstrologicalSubjectFactory._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1343
1344        # Calculate Neptune
1345        if should_calculate("Neptune"):
1346            AstrologicalSubjectFactory._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1347
1348        # Calculate Pluto
1349        if should_calculate("Pluto"):
1350            AstrologicalSubjectFactory._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1351
1352        # ==================
1353        # LUNAR NODES
1354        # ==================
1355
1356        # Calculate Mean North Lunar Node
1357        if should_calculate("Mean_North_Lunar_Node"):
1358            AstrologicalSubjectFactory._calculate_single_planet(data, "Mean_North_Lunar_Node", 10, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1359            # Get correct declination using equatorial coordinates
1360            if "mean_north_lunar_node" in data:
1361                mean_north_lunar_node_eq = swe.calc_ut(julian_day, 10, iflag | swe.FLG_EQUATORIAL)[0]
1362                data["mean_north_lunar_node"].declination = mean_north_lunar_node_eq[1]  # Declination from equatorial coordinates
1363
1364        # Calculate True North Lunar Node
1365        if should_calculate("True_North_Lunar_Node"):
1366            AstrologicalSubjectFactory._calculate_single_planet(data, "True_North_Lunar_Node", 11, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1367            # Get correct declination using equatorial coordinates
1368            if "true_north_lunar_node" in data:
1369                true_north_lunar_node_eq = swe.calc_ut(julian_day, 11, iflag | swe.FLG_EQUATORIAL)[0]
1370                data["true_north_lunar_node"].declination = true_north_lunar_node_eq[1]  # Declination from equatorial coordinates
1371
1372        # Calculate Mean South Lunar Node (opposite to Mean North Lunar Node)
1373        if should_calculate("Mean_South_Lunar_Node") and "mean_north_lunar_node" in data:
1374            mean_south_lunar_node_deg = math.fmod(data["mean_north_lunar_node"].abs_pos + 180, 360)
1375            data["mean_south_lunar_node"] = get_kerykeion_point_from_degree(
1376                mean_south_lunar_node_deg, "Mean_South_Lunar_Node", point_type=point_type,
1377                speed=-data["mean_north_lunar_node"].speed if data["mean_north_lunar_node"].speed is not None else None,
1378                declination=-data["mean_north_lunar_node"].declination if data["mean_north_lunar_node"].declination is not None else None
1379            )
1380            data["mean_south_lunar_node"].house = get_planet_house(mean_south_lunar_node_deg, houses_degree_ut)
1381            data["mean_south_lunar_node"].retrograde = data["mean_north_lunar_node"].retrograde
1382            calculated_planets.append("Mean_South_Lunar_Node")
1383
1384        # Calculate True South Lunar Node (opposite to True North Lunar Node)
1385        if should_calculate("True_South_Lunar_Node") and "true_north_lunar_node" in data:
1386            true_south_lunar_node_deg = math.fmod(data["true_north_lunar_node"].abs_pos + 180, 360)
1387            data["true_south_lunar_node"] = get_kerykeion_point_from_degree(
1388                true_south_lunar_node_deg, "True_South_Lunar_Node", point_type=point_type,
1389                speed=-data["true_north_lunar_node"].speed if data["true_north_lunar_node"].speed is not None else None,
1390                declination=-data["true_north_lunar_node"].declination if data["true_north_lunar_node"].declination is not None else None
1391            )
1392            data["true_south_lunar_node"].house = get_planet_house(true_south_lunar_node_deg, houses_degree_ut)
1393            data["true_south_lunar_node"].retrograde = data["true_north_lunar_node"].retrograde
1394            calculated_planets.append("True_South_Lunar_Node")
1395
1396        # ==================
1397        # LILITH POINTS
1398        # ==================
1399
1400        # Calculate Mean Lilith (Mean Black Moon)
1401        if should_calculate("Mean_Lilith"):
1402            AstrologicalSubjectFactory._calculate_single_planet(
1403                data, "Mean_Lilith", 12, julian_day, iflag, houses_degree_ut,
1404                point_type, calculated_planets, active_points
1405            )
1406
1407        # Calculate True Lilith (Osculating Black Moon)
1408        if should_calculate("True_Lilith"):
1409            AstrologicalSubjectFactory._calculate_single_planet(
1410                data, "True_Lilith", 13, julian_day, iflag, houses_degree_ut,
1411                point_type, calculated_planets, active_points
1412            )
1413
1414        # ==================
1415        # SPECIAL POINTS
1416        # ==================
1417
1418        # Calculate Earth - useful for heliocentric charts
1419        if should_calculate("Earth"):
1420            AstrologicalSubjectFactory._calculate_single_planet(
1421                data, "Earth", 14, julian_day, iflag, houses_degree_ut,
1422                point_type, calculated_planets, active_points
1423            )
1424
1425        # Calculate Chiron
1426        if should_calculate("Chiron"):
1427            AstrologicalSubjectFactory._calculate_single_planet(
1428                data, "Chiron", 15, julian_day, iflag, houses_degree_ut,
1429                point_type, calculated_planets, active_points
1430            )
1431
1432        # Calculate Pholus
1433        if should_calculate("Pholus"):
1434            AstrologicalSubjectFactory._calculate_single_planet(
1435                data, "Pholus", 16, julian_day, iflag, houses_degree_ut,
1436                point_type, calculated_planets, active_points
1437            )
1438
1439        # ==================
1440        # ASTEROIDS
1441        # ==================
1442
1443        # Calculate Ceres
1444        if should_calculate("Ceres"):
1445            AstrologicalSubjectFactory._calculate_single_planet(
1446                data, "Ceres", 17, julian_day, iflag, houses_degree_ut,
1447                point_type, calculated_planets, active_points
1448            )
1449
1450        # Calculate Pallas
1451        if should_calculate("Pallas"):
1452            AstrologicalSubjectFactory._calculate_single_planet(
1453                data, "Pallas", 18, julian_day, iflag, houses_degree_ut,
1454                point_type, calculated_planets, active_points
1455            )
1456
1457        # Calculate Juno
1458        if should_calculate("Juno"):
1459            AstrologicalSubjectFactory._calculate_single_planet(
1460                data, "Juno", 19, julian_day, iflag, houses_degree_ut,
1461                point_type, calculated_planets, active_points
1462            )
1463
1464        # Calculate Vesta
1465        if should_calculate("Vesta"):
1466            AstrologicalSubjectFactory._calculate_single_planet(
1467                data, "Vesta", 20, julian_day, iflag, houses_degree_ut,
1468                point_type, calculated_planets, active_points
1469            )
1470
1471        # ==================
1472        # TRANS-NEPTUNIAN OBJECTS
1473        # ==================
1474
1475        # For TNOs we compute ecliptic longitude for zodiac placement and
1476        # declination from equatorial coordinates, same as other bodies.
1477
1478        # Calculate Eris
1479        if should_calculate("Eris"):
1480            try:
1481                AstrologicalSubjectFactory._calculate_single_planet(
1482                    data, "Eris", swe.AST_OFFSET + 136199, julian_day, iflag,
1483                    houses_degree_ut, point_type, calculated_planets, active_points
1484                )
1485            except Exception as e:
1486                logging.warning(f"Could not calculate Eris position: {e}")
1487                if "Eris" in active_points:
1488                    active_points.remove("Eris")  # Remove if not calculated
1489
1490        # Calculate Sedna
1491        if should_calculate("Sedna"):
1492            try:
1493                AstrologicalSubjectFactory._calculate_single_planet(
1494                    data, "Sedna", swe.AST_OFFSET + 90377, julian_day, iflag,
1495                    houses_degree_ut, point_type, calculated_planets, active_points
1496                )
1497            except Exception as e:
1498                logging.warning(f"Could not calculate Sedna position: {e}")
1499                if "Sedna" in active_points:
1500                    active_points.remove("Sedna")
1501
1502        # Calculate Haumea
1503        if should_calculate("Haumea"):
1504            try:
1505                AstrologicalSubjectFactory._calculate_single_planet(
1506                    data, "Haumea", swe.AST_OFFSET + 136108, julian_day, iflag,
1507                    houses_degree_ut, point_type, calculated_planets, active_points
1508                )
1509            except Exception as e:
1510                logging.warning(f"Could not calculate Haumea position: {e}")
1511                if "Haumea" in active_points:
1512                    active_points.remove("Haumea")  # Remove if not calculated
1513
1514        # Calculate Makemake
1515        if should_calculate("Makemake"):
1516            try:
1517                AstrologicalSubjectFactory._calculate_single_planet(
1518                    data, "Makemake", swe.AST_OFFSET + 136472, julian_day, iflag,
1519                    houses_degree_ut, point_type, calculated_planets, active_points
1520                )
1521            except Exception as e:
1522                logging.warning(f"Could not calculate Makemake position: {e}")
1523                if "Makemake" in active_points:
1524                    active_points.remove("Makemake")  # Remove if not calculated
1525
1526        # Calculate Ixion
1527        if should_calculate("Ixion"):
1528            try:
1529                AstrologicalSubjectFactory._calculate_single_planet(
1530                    data, "Ixion", swe.AST_OFFSET + 28978, julian_day, iflag,
1531                    houses_degree_ut, point_type, calculated_planets, active_points
1532                )
1533            except Exception as e:
1534                logging.warning(f"Could not calculate Ixion position: {e}")
1535                if "Ixion" in active_points:
1536                    active_points.remove("Ixion")  # Remove if not calculated
1537
1538        # Calculate Orcus
1539        if should_calculate("Orcus"):
1540            try:
1541                AstrologicalSubjectFactory._calculate_single_planet(
1542                    data, "Orcus", swe.AST_OFFSET + 90482, julian_day, iflag,
1543                    houses_degree_ut, point_type, calculated_planets, active_points
1544                )
1545            except Exception as e:
1546                logging.warning(f"Could not calculate Orcus position: {e}")
1547                if "Orcus" in active_points:
1548                    active_points.remove("Orcus")  # Remove if not calculated
1549
1550        # Calculate Quaoar
1551        if should_calculate("Quaoar"):
1552            try:
1553                AstrologicalSubjectFactory._calculate_single_planet(
1554                    data, "Quaoar", swe.AST_OFFSET + 50000, julian_day, iflag,
1555                    houses_degree_ut, point_type, calculated_planets, active_points
1556                )
1557            except Exception as e:
1558                logging.warning(f"Could not calculate Quaoar position: {e}")
1559                if "Quaoar" in active_points:
1560                    active_points.remove("Quaoar")  # Remove if not calculated
1561
1562        # ==================
1563        # FIXED STARS
1564        # ==================
1565
1566        # Calculate Regulus (example fixed star)
1567        if should_calculate("Regulus"):
1568            try:
1569                star_name = "Regulus"
1570                # Ecliptic longitude for zodiac placement
1571                pos_ecl = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1572                regulus_deg = pos_ecl[0]
1573                regulus_speed = pos_ecl[3] if len(pos_ecl) > 3 else 0.0  # Fixed stars have very slow speed
1574                # Equatorial coordinates for true declination
1575                pos_eq = swe.fixstar_ut(star_name, julian_day, iflag | swe.FLG_EQUATORIAL)[0]
1576                regulus_dec = pos_eq[1] if len(pos_eq) > 1 else None
1577                data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type, speed=regulus_speed, declination=regulus_dec)
1578                data["regulus"].house = get_planet_house(regulus_deg, houses_degree_ut)
1579                data["regulus"].retrograde = False  # Fixed stars are never retrograde
1580                calculated_planets.append("Regulus")
1581            except Exception as e:
1582                logging.warning(f"Could not calculate Regulus position: {e}")
1583                if "Regulus" in active_points:
1584                    active_points.remove("Regulus")  # Remove if not calculated
1585
1586        # Calculate Spica (example fixed star)
1587        if should_calculate("Spica"):
1588            try:
1589                star_name = "Spica"
1590                # Ecliptic longitude for zodiac placement
1591                pos_ecl = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1592                spica_deg = pos_ecl[0]
1593                spica_speed = pos_ecl[3] if len(pos_ecl) > 3 else 0.0  # Fixed stars have very slow speed
1594                # Equatorial coordinates for true declination
1595                pos_eq = swe.fixstar_ut(star_name, julian_day, iflag | swe.FLG_EQUATORIAL)[0]
1596                spica_dec = pos_eq[1] if len(pos_eq) > 1 else None
1597                data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type, speed=spica_speed, declination=spica_dec)
1598                data["spica"].house = get_planet_house(spica_deg, houses_degree_ut)
1599                data["spica"].retrograde = False  # Fixed stars are never retrograde
1600                calculated_planets.append("Spica")
1601            except Exception as e:
1602                logging.warning(f"Could not calculate Spica position: {e}")
1603                if "Spica" in active_points:
1604                    active_points.remove("Spica")  # Remove if not calculated
1605
1606        # ==================
1607        # ARABIC PARTS / LOTS
1608        # ==================
1609
1610        # Calculate Pars Fortunae (Part of Fortune)
1611        if should_calculate("Pars_Fortunae"):
1612            # Auto-activate required points with notification
1613            pars_fortunae_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1614            missing_points = [point for point in pars_fortunae_required_points if point not in active_points]
1615            if missing_points:
1616                logging.info(f"Automatically adding required points for Pars_Fortunae: {missing_points}")
1617                active_points.extend(cast(List[AstrologicalPoint], missing_points))
1618                # Recalculate the missing points
1619                for point in missing_points:
1620                    if point == "Ascendant" and "ascendant" not in data:
1621                        # Calculate Ascendant from houses data if needed
1622                        if data["zodiac_type"] == "Sidereal":
1623                            cusps, ascmc = swe.houses_ex(
1624                                tjdut=data["julian_day"],
1625                                lat=data["lat"],
1626                                lon=data["lng"],
1627                                hsys=str.encode(data["houses_system_identifier"]),
1628                                flags=swe.FLG_SIDEREAL
1629                            )
1630                        else:
1631                            cusps, ascmc = swe.houses(
1632                                tjdut=data["julian_day"],
1633                                lat=data["lat"],
1634                                lon=data["lng"],
1635                                hsys=str.encode(data["houses_system_identifier"])
1636                            )
1637                        data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1638                        data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1639                        data["ascendant"].retrograde = False
1640                    elif point == "Sun" and "sun" not in data:
1641                        sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1642                        data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1643                        data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1644                        data["sun"].retrograde = sun_calc[3] < 0
1645                    elif point == "Moon" and "moon" not in data:
1646                        moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1647                        data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1648                        data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1649                        data["moon"].retrograde = moon_calc[3] < 0
1650
1651            # Check if required points are available
1652            if all(k in data for k in ["ascendant", "sun", "moon"]):
1653                # Different calculation for day and night charts
1654                # Day birth (Sun above horizon): ASC + Moon - Sun
1655                # Night birth (Sun below horizon): ASC + Sun - Moon
1656                if data["sun"].house:
1657                    is_day_chart = get_house_number(data["sun"].house) < 7  # Houses 1-6 are above horizon
1658                else:
1659                    is_day_chart = True  # Default to day chart if house is None
1660
1661                if is_day_chart:
1662                    fortune_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1663                else:
1664                    fortune_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1665
1666                data["pars_fortunae"] = get_kerykeion_point_from_degree(fortune_deg, "Pars_Fortunae", point_type=point_type)
1667                data["pars_fortunae"].house = get_planet_house(fortune_deg, houses_degree_ut)
1668                data["pars_fortunae"].retrograde = False  # Parts are never retrograde
1669                calculated_planets.append("Pars_Fortunae")
1670
1671        # Calculate Pars Spiritus (Part of Spirit)
1672        if should_calculate("Pars_Spiritus"):
1673            # Auto-activate required points with notification
1674            pars_spiritus_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1675            missing_points = [point for point in pars_spiritus_required_points if point not in active_points]
1676            if missing_points:
1677                logging.info(f"Automatically adding required points for Pars_Spiritus: {missing_points}")
1678                active_points.extend(cast(List[AstrologicalPoint], missing_points))
1679                # Recalculate the missing points
1680                for point in missing_points:
1681                    if point == "Ascendant" and "ascendant" not in data:
1682                        # Calculate Ascendant from houses data if needed
1683                        if data["zodiac_type"] == "Sidereal":
1684                            cusps, ascmc = swe.houses_ex(
1685                                tjdut=data["julian_day"],
1686                                lat=data["lat"],
1687                                lon=data["lng"],
1688                                hsys=str.encode(data["houses_system_identifier"]),
1689                                flags=swe.FLG_SIDEREAL
1690                            )
1691                        else:
1692                            cusps, ascmc = swe.houses(
1693                                tjdut=data["julian_day"],
1694                                lat=data["lat"],
1695                                lon=data["lng"],
1696                                hsys=str.encode(data["houses_system_identifier"])
1697                            )
1698                        data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1699                        data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1700                        data["ascendant"].retrograde = False
1701                    elif point == "Sun" and "sun" not in data:
1702                        sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1703                        data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1704                        data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1705                        data["sun"].retrograde = sun_calc[3] < 0
1706                    elif point == "Moon" and "moon" not in data:
1707                        moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1708                        data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1709                        data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1710                        data["moon"].retrograde = moon_calc[3] < 0
1711
1712            # Check if required points are available
1713            if all(k in data for k in ["ascendant", "sun", "moon"]):
1714                # Day birth: ASC + Sun - Moon
1715                # Night birth: ASC + Moon - Sun
1716                if data["sun"].house:
1717                    is_day_chart = get_house_number(data["sun"].house) < 7
1718                else:
1719                    is_day_chart = True  # Default to day chart if house is None
1720
1721                if is_day_chart:
1722                    spirit_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1723                else:
1724                    spirit_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1725
1726                data["pars_spiritus"] = get_kerykeion_point_from_degree(spirit_deg, "Pars_Spiritus", point_type=point_type)
1727                data["pars_spiritus"].house = get_planet_house(spirit_deg, houses_degree_ut)
1728                data["pars_spiritus"].retrograde = False
1729                calculated_planets.append("Pars_Spiritus")
1730
1731        # Calculate Pars Amoris (Part of Eros/Love)
1732        if should_calculate("Pars_Amoris"):
1733            # Auto-activate required points with notification
1734            pars_amoris_required_points: List[AstrologicalPoint] = ["Ascendant", "Venus", "Sun"]
1735            missing_points = [point for point in pars_amoris_required_points if point not in active_points]
1736            if missing_points:
1737                logging.info(f"Automatically adding required points for Pars_Amoris: {missing_points}")
1738                active_points.extend(cast(List[AstrologicalPoint], missing_points))
1739                # Recalculate the missing points
1740                for point in missing_points:
1741                    if point == "Ascendant" and "ascendant" not in data:
1742                        # Calculate Ascendant from houses data if needed
1743                        if data["zodiac_type"] == "Sidereal":
1744                            cusps, ascmc = swe.houses_ex(
1745                                tjdut=data["julian_day"],
1746                                lat=data["lat"],
1747                                lon=data["lng"],
1748                                hsys=str.encode(data["houses_system_identifier"]),
1749                                flags=swe.FLG_SIDEREAL
1750                            )
1751                        else:
1752                            cusps, ascmc = swe.houses(
1753                                tjdut=data["julian_day"],
1754                                lat=data["lat"],
1755                                lon=data["lng"],
1756                                hsys=str.encode(data["houses_system_identifier"])
1757                            )
1758                        data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1759                        data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1760                        data["ascendant"].retrograde = False
1761                    elif point == "Sun" and "sun" not in data:
1762                        sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1763                        data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1764                        data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1765                        data["sun"].retrograde = sun_calc[3] < 0
1766                    elif point == "Venus" and "venus" not in data:
1767                        venus_calc = swe.calc_ut(julian_day, 3, iflag)[0]
1768                        data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type, speed=venus_calc[3], declination=venus_calc[1])
1769                        data["venus"].house = get_planet_house(venus_calc[0], houses_degree_ut)
1770                        data["venus"].retrograde = venus_calc[3] < 0
1771
1772            # Check if required points are available
1773            if all(k in data for k in ["ascendant", "venus", "sun"]):
1774                # ASC + Venus - Sun
1775                amoris_deg = math.fmod(data["ascendant"].abs_pos + data["venus"].abs_pos - data["sun"].abs_pos, 360)
1776
1777                data["pars_amoris"] = get_kerykeion_point_from_degree(amoris_deg, "Pars_Amoris", point_type=point_type)
1778                data["pars_amoris"].house = get_planet_house(amoris_deg, houses_degree_ut)
1779                data["pars_amoris"].retrograde = False
1780                calculated_planets.append("Pars_Amoris")
1781
1782        # Calculate Pars Fidei (Part of Faith)
1783        if should_calculate("Pars_Fidei"):
1784            # Auto-activate required points with notification
1785            pars_fidei_required_points: List[AstrologicalPoint] = ["Ascendant", "Jupiter", "Saturn"]
1786            missing_points = [point for point in pars_fidei_required_points if point not in active_points]
1787            if missing_points:
1788                logging.info(f"Automatically adding required points for Pars_Fidei: {missing_points}")
1789                active_points.extend(cast(List[AstrologicalPoint], missing_points))
1790                # Recalculate the missing points
1791                for point in missing_points:
1792                    if point == "Ascendant" and "ascendant" not in data:
1793                        # Calculate Ascendant from houses data if needed
1794                        if data["zodiac_type"] == "Sidereal":
1795                            cusps, ascmc = swe.houses_ex(
1796                                tjdut=data["julian_day"],
1797                                lat=data["lat"],
1798                                lon=data["lng"],
1799                                hsys=str.encode(data["houses_system_identifier"]),
1800                                flags=swe.FLG_SIDEREAL
1801                            )
1802                        else:
1803                            cusps, ascmc = swe.houses(
1804                                tjdut=data["julian_day"],
1805                                lat=data["lat"],
1806                                lon=data["lng"],
1807                                hsys=str.encode(data["houses_system_identifier"])
1808                            )
1809                        data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1810                        data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1811                        data["ascendant"].retrograde = False
1812                    elif point == "Jupiter" and "jupiter" not in data:
1813                        jupiter_calc = swe.calc_ut(julian_day, 5, iflag)[0]
1814                        data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type, speed=jupiter_calc[3], declination=jupiter_calc[1])
1815                        data["jupiter"].house = get_planet_house(jupiter_calc[0], houses_degree_ut)
1816                        data["jupiter"].retrograde = jupiter_calc[3] < 0
1817                    elif point == "Saturn" and "saturn" not in data:
1818                        saturn_calc = swe.calc_ut(julian_day, 6, iflag)[0]
1819                        data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type, speed=saturn_calc[3], declination=saturn_calc[1])
1820                        data["saturn"].house = get_planet_house(saturn_calc[0], houses_degree_ut)
1821                        data["saturn"].retrograde = saturn_calc[3] < 0
1822
1823            # Check if required points are available
1824            if all(k in data for k in ["ascendant", "jupiter", "saturn"]):
1825                # ASC + Jupiter - Saturn
1826                fidei_deg = math.fmod(data["ascendant"].abs_pos + data["jupiter"].abs_pos - data["saturn"].abs_pos, 360)
1827
1828                data["pars_fidei"] = get_kerykeion_point_from_degree(fidei_deg, "Pars_Fidei", point_type=point_type)
1829                data["pars_fidei"].house = get_planet_house(fidei_deg, houses_degree_ut)
1830                data["pars_fidei"].retrograde = False
1831                calculated_planets.append("Pars_Fidei")
1832
1833        # Calculate Vertex and/or Anti-Vertex
1834        if should_calculate("Vertex") or should_calculate("Anti_Vertex"):
1835            try:
1836                # Vertex is at ascmc[3] in Swiss Ephemeris
1837                if data["zodiac_type"] == "Sidereal":
1838                    _, ascmc = swe.houses_ex(
1839                        tjdut=data["julian_day"],
1840                        lat=data["lat"],
1841                        lon=data["lng"],
1842                        hsys=str.encode("V"),  # Vertex works best with Vehlow system
1843                        flags=swe.FLG_SIDEREAL
1844                    )
1845                else:
1846                    _, ascmc = swe.houses(
1847                        tjdut=data["julian_day"],
1848                        lat=data["lat"],
1849                        lon=data["lng"],
1850                        hsys=str.encode("V")
1851                    )
1852
1853                vertex_deg = ascmc[3]
1854
1855                # Calculate Vertex if requested
1856                if should_calculate("Vertex"):
1857                    data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1858                    data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1859                    data["vertex"].retrograde = False
1860                    calculated_planets.append("Vertex")
1861
1862                # Calculate Anti-Vertex if requested
1863                if should_calculate("Anti_Vertex"):
1864                    anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1865                    data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1866                    data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1867                    data["anti_vertex"].retrograde = False
1868                    calculated_planets.append("Anti_Vertex")
1869
1870            except Exception as e:
1871                logging.warning("Could not calculate Vertex/Anti-Vertex position, error: %s", e)
1872                if "Vertex" in active_points:
1873                    active_points.remove("Vertex")
1874                if "Anti_Vertex" in active_points:
1875                    active_points.remove("Anti_Vertex")
1876
1877        # Store only the planets that were actually calculated
1878        all_calculated_points = calculated_planets.copy()
1879        if calculated_axial_cusps:
1880            all_calculated_points.extend(calculated_axial_cusps)
1881        data["active_points"] = all_calculated_points
1882
1883    @staticmethod
1884    def _calculate_day_of_week(data: Dict[str, Any]) -> None:
1885        """
1886        Calculate the day of the week for the given astronomical event.
1887
1888        Determines the day of the week corresponding to the local datetime
1889        using the standard library for consistency.
1890
1891        Args:
1892            data (Dict[str, Any]): Calculation data dictionary containing
1893                iso_formatted_local_datetime. Updated with the calculated day_of_week string.
1894
1895        Side Effects:
1896            Updates data dictionary with:
1897            - day_of_week: Human-readable day name (e.g., "Monday", "Tuesday")
1898        """
1899        dt = datetime.fromisoformat(data["iso_formatted_local_datetime"])
1900        data["day_of_week"] = dt.strftime("%A")

Factory class for creating comprehensive astrological subjects.

This factory creates AstrologicalSubjectModel instances with complete astrological information including planetary positions, house cusps, aspects, lunar phases, and various specialized astrological points. It provides multiple class methods for different initialization scenarios and supports both online and offline calculation modes.

The factory handles complex astrological calculations using the Swiss Ephemeris library, supports multiple coordinate systems and house systems, and can automatically fetch location data from online sources.

Supported Astrological Points: - Traditional Planets: Sun through Pluto - Lunar Nodes: Mean and True North/South Nodes - Lilith Points: Mean and True Black Moon - Asteroids: Ceres, Pallas, Juno, Vesta - Centaurs: Chiron, Pholus - Trans-Neptunian Objects: Eris, Sedna, Haumea, Makemake, Ixion, Orcus, Quaoar - Fixed Stars: Regulus, Spica (extensible) - Arabic Parts: Pars Fortunae, Pars Spiritus, Pars Amoris, Pars Fidei - Special Points: Vertex, Anti-Vertex, Earth (for heliocentric charts) - House Cusps: All 12 houses with configurable house systems - Angles: Ascendant, Medium Coeli, Descendant, Imum Coeli

Supported Features: - Multiple zodiac systems (Tropical/Sidereal with various ayanamshas) - Multiple house systems (Placidus, Koch, Equal, Whole Sign, etc.) - Multiple coordinate perspectives (Geocentric, Heliocentric, Topocentric) - Automatic timezone and coordinate resolution via GeoNames API - Lunar phase calculations - Day/night chart detection for Arabic parts - Performance optimization through selective point calculation - Comprehensive error handling and validation

Class Methods: from_birth_data: Create subject from standard birth data (most flexible) from_iso_utc_time: Create subject from ISO UTC timestamp from_current_time: Create subject for current moment

Example:

Create natal chart

subject = AstrologicalSubjectFactory.from_birth_data( ... name="John Doe", ... year=1990, month=6, day=15, ... hour=14, minute=30, ... city="Rome", nation="IT", ... online=True ... ) print(f"Sun: {subject.sun.sign} {subject.sun.abs_pos}°") print(f"Active points: {len(subject.active_points)}")

>>> # Create chart for current time
>>> now_subject = AstrologicalSubjectFactory.from_current_time(
...     name="Current Moment",
...     city="London", nation="GB"
... )

Thread Safety: This factory is not thread-safe due to its use of the Swiss Ephemeris library which maintains global state. Use separate instances in multi-threaded applications or implement appropriate locking mechanisms.

@classmethod
def from_birth_data( cls, name: str = 'Now', year: Optional[int] = None, month: Optional[int] = None, day: Optional[int] = None, hour: Optional[int] = None, minute: Optional[int] = None, city: Optional[str] = None, nation: Optional[str] = None, lng: Optional[float] = None, lat: Optional[float] = None, tz_str: Optional[str] = None, geonames_username: Optional[str] = None, online: bool = True, zodiac_type: Literal['Tropical', 'Sidereal'] = 'Tropical', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', cache_expire_after_days: int = 30, is_dst: Optional[bool] = None, altitude: Optional[float] = None, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, calculate_lunar_phase: bool = True, *, seconds: int = 0, suppress_geonames_warning: bool = False) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
423    @classmethod
424    def from_birth_data(
425        cls,
426        name: str = "Now",
427        year: Optional[int] = None,
428        month: Optional[int] = None,
429        day: Optional[int] = None,
430        hour: Optional[int] = None,
431        minute: Optional[int] = None,
432        city: Optional[str] = None,
433        nation: Optional[str] = None,
434        lng: Optional[float] = None,
435        lat: Optional[float] = None,
436        tz_str: Optional[str] = None,
437        geonames_username: Optional[str] = None,
438        online: bool = True,
439        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
440        sidereal_mode: Optional[SiderealMode] = None,
441        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
442        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
443        cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
444        is_dst: Optional[bool] = None,
445        altitude: Optional[float] = None,
446        active_points: Optional[List[AstrologicalPoint]] = None,
447        calculate_lunar_phase: bool = True,
448        *,
449        seconds: int = 0,
450        suppress_geonames_warning: bool = False,
451
452    ) -> AstrologicalSubjectModel:
453        """
454        Create an astrological subject from standard birth or event data.
455
456        This is the most flexible and commonly used factory method. It creates a complete
457        astrological subject with planetary positions, house cusps, and specialized points
458        for a specific date, time, and location. Supports both online location resolution
459        and offline calculation modes.
460
461        Args:
462            name (str, optional): Name or identifier for the subject. Defaults to "Now".
463            year (int, optional): Year of birth/event. Defaults to current year.
464            month (int, optional): Month of birth/event (1-12). Defaults to current month.
465            day (int, optional): Day of birth/event (1-31). Defaults to current day.
466            hour (int, optional): Hour of birth/event (0-23). Defaults to current hour.
467            minute (int, optional): Minute of birth/event (0-59). Defaults to current minute.
468            seconds (int, optional): Seconds of birth/event (0-59). Defaults to 0.
469            city (str, optional): City name for location lookup. Used with online=True.
470                Defaults to None (Greenwich if not specified).
471            nation (str, optional): ISO country code (e.g., 'US', 'GB', 'IT'). Used with
472                online=True. Defaults to None ('GB' if not specified).
473            lng (float, optional): Longitude in decimal degrees. East is positive, West
474                is negative. If not provided and online=True, fetched from GeoNames.
475            lat (float, optional): Latitude in decimal degrees. North is positive, South
476                is negative. If not provided and online=True, fetched from GeoNames.
477            tz_str (str, optional): IANA timezone identifier (e.g., 'Europe/London').
478                If not provided and online=True, fetched from GeoNames.
479            geonames_username (str, optional): Username for GeoNames API. Required for
480                online location lookup. Get one free at geonames.org.
481            online (bool, optional): Whether to fetch location data online. If False,
482                lng, lat, and tz_str must be provided. Defaults to True.
483            zodiac_type (ZodiacType, optional): Zodiac system - 'Tropical' or 'Sidereal'.
484                Defaults to 'Tropical'.
485            sidereal_mode (SiderealMode, optional): Sidereal calculation mode (e.g.,
486                'FAGAN_BRADLEY', 'LAHIRI'). Only used with zodiac_type='Sidereal'.
487            houses_system_identifier (HousesSystemIdentifier, optional): House system
488                for cusp calculations (e.g., 'P'=Placidus, 'K'=Koch, 'E'=Equal).
489                Defaults to 'P' (Placidus).
490            perspective_type (PerspectiveType, optional): Calculation perspective:
491                - 'Apparent Geocentric': Standard geocentric with light-time correction
492                - 'True Geocentric': Geometric geocentric positions
493                - 'Heliocentric': Sun-centered coordinates
494                - 'Topocentric': Earth surface perspective (requires altitude)
495                Defaults to 'Apparent Geocentric'.
496            cache_expire_after_days (int, optional): Days to cache GeoNames data locally.
497                Defaults to 30.
498            is_dst (bool, optional): Daylight Saving Time flag for ambiguous times.
499                If None, pytz attempts automatic detection. Set explicitly for
500                times during DST transitions.
501            altitude (float, optional): Altitude above sea level in meters. Used for
502                topocentric calculations and atmospheric corrections. Defaults to None
503                (sea level assumed).
504            active_points (Optional[List[AstrologicalPoint]], optional): List of astrological
505                points to calculate. Omitting points can improve performance for
506                specialized applications. If None, uses DEFAULT_ACTIVE_POINTS.
507            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
508                Requires Sun and Moon in active_points. Defaults to True.
509            suppress_geonames_warning (bool, optional): If True, suppresses the warning
510                message when using the default GeoNames username. Useful for testing
511                or automated processes. Defaults to False.
512
513        Returns:
514            AstrologicalSubjectModel: Complete astrological subject with calculated
515                positions, houses, and metadata. Access planetary positions via
516                attributes like .sun, .moon, .mercury, etc.
517
518        Raises:
519            KerykeionException:
520                - If offline mode is used without required location data
521                - If invalid zodiac/sidereal mode combinations are specified
522                - If GeoNames data is missing or invalid
523                - If timezone localization fails (ambiguous DST times)
524
525        Examples:
526            >>> # Basic natal chart with online location lookup
527            >>> chart = AstrologicalSubjectFactory.from_birth_data(
528            ...     name="Jane Doe",
529            ...     year=1985, month=3, day=21,
530            ...     hour=15, minute=30,
531            ...     city="Paris", nation="FR",
532            ...     geonames_username="your_username"
533            ... )
534
535            >>> # Offline calculation with manual coordinates
536            >>> chart = AstrologicalSubjectFactory.from_birth_data(
537            ...     name="John Smith",
538            ...     year=1990, month=12, day=25,
539            ...     hour=0, minute=0,
540            ...     lng=-74.006, lat=40.7128, tz_str="America/New_York",
541            ...     online=False
542            ... )
543
544            >>> # Sidereal chart with specific points
545            >>> chart = AstrologicalSubjectFactory.from_birth_data(
546            ...     name="Vedic Chart",
547            ...     year=2000, month=6, day=15, hour=12,
548            ...     city="Mumbai", nation="IN",
549            ...     zodiac_type="Sidereal",
550            ...     sidereal_mode="LAHIRI",
551            ...     active_points=["Sun", "Moon", "Mercury", "Venus", "Mars",
552            ...                   "Jupiter", "Saturn", "Ascendant"]
553            ... )
554
555        Note:
556            - For high-precision calculations, consider providing seconds parameter
557            - Use topocentric perspective for observer-specific calculations
558            - Some Arabic parts automatically activate required base points
559            - The method handles polar regions by adjusting extreme latitudes
560            - Time zones are handled with full DST awareness via pytz
561        """
562        # Resolve time defaults using current time
563        if year is None or month is None or day is None or hour is None or minute is None or seconds is None:
564            now = datetime.now()
565            year = year if year is not None else now.year
566            month = month if month is not None else now.month
567            day = day if day is not None else now.day
568            hour = hour if hour is not None else now.hour
569            minute = minute if minute is not None else now.minute
570            seconds = seconds if seconds is not None else now.second
571
572        # Create a calculation data container
573        calc_data: Dict[str, Any] = {}
574
575        # Basic identity
576        calc_data["name"] = name
577        calc_data["json_dir"] = str(Path.home())
578
579        # Create a deep copy of active points to avoid modifying the original list
580        if active_points is None:
581            active_points_list: List[AstrologicalPoint] = list(DEFAULT_ACTIVE_POINTS)
582        else:
583            active_points_list = list(active_points)
584
585        calc_data["active_points"] = active_points_list
586
587        # Initialize configuration
588        config = ChartConfiguration(
589            zodiac_type=zodiac_type,
590            sidereal_mode=sidereal_mode,
591            houses_system_identifier=houses_system_identifier,
592            perspective_type=perspective_type,
593        )
594
595        # Add configuration data to calculation data
596        calc_data["zodiac_type"] = config.zodiac_type
597        calc_data["sidereal_mode"] = config.sidereal_mode
598        calc_data["houses_system_identifier"] = config.houses_system_identifier
599        calc_data["perspective_type"] = config.perspective_type
600
601        # Set up geonames username if needed
602        if geonames_username is None and online and (not lat or not lng or not tz_str):
603            if not suppress_geonames_warning:
604                logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
605            geonames_username = DEFAULT_GEONAMES_USERNAME
606
607        # Initialize location data
608        location = LocationData(
609            city=city or "Greenwich",
610            nation=nation or "GB",
611            lat=lat if lat is not None else 51.5074,
612            lng=lng if lng is not None else 0.0,
613            tz_str=tz_str or "Etc/GMT",
614            altitude=altitude
615        )
616
617        # If offline mode is requested but required data is missing, raise error
618        if not online and (not tz_str or lat is None or lng is None):
619            raise KerykeionException(
620                "For offline mode, you must provide timezone (tz_str) and coordinates (lat, lng)"
621            )
622
623        # Fetch location data if needed
624        if online and (not tz_str or lat is None or lng is None):
625            location.fetch_from_geonames(
626                username=geonames_username or DEFAULT_GEONAMES_USERNAME,
627                cache_expire_after_days=cache_expire_after_days
628            )
629
630        # Prepare location for calculations
631        location.prepare_for_calculation()
632
633        # Add location data to calculation data
634        calc_data["city"] = location.city
635        calc_data["nation"] = location.nation
636        calc_data["lat"] = location.lat
637        calc_data["lng"] = location.lng
638        calc_data["tz_str"] = location.tz_str
639        calc_data["altitude"] = location.altitude
640
641        # Store calculation parameters
642        calc_data["year"] = year
643        calc_data["month"] = month
644        calc_data["day"] = day
645        calc_data["hour"] = hour
646        calc_data["minute"] = minute
647        calc_data["seconds"] = seconds
648        calc_data["is_dst"] = is_dst
649
650        # Calculate time conversions
651        AstrologicalSubjectFactory._calculate_time_conversions(calc_data, location)
652        # Initialize Swiss Ephemeris and calculate houses and planets with context manager
653        ephe_path = str(Path(__file__).parent.absolute() / "sweph")
654        with ephemeris_context(
655            ephe_path=ephe_path,
656            config=config,
657            lng=calc_data["lng"],
658            lat=calc_data["lat"],
659            alt=calc_data["altitude"],
660        ) as iflag:
661            calc_data["_iflag"] = iflag
662            # House system name (previously set in _setup_ephemeris)
663            calc_data["houses_system_name"] = swe.house_name(
664                config.houses_system_identifier.encode("ascii")
665            )
666            calculated_axial_cusps = AstrologicalSubjectFactory._calculate_houses(
667                calc_data, active_points_list
668            )
669            AstrologicalSubjectFactory._calculate_planets(
670                calc_data, active_points_list, calculated_axial_cusps
671            )
672        AstrologicalSubjectFactory._calculate_day_of_week(calc_data)
673
674        # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
675        if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
676            calc_data["lunar_phase"] = calculate_moon_phase(
677                calc_data["moon"].abs_pos,  # type: ignore[attr-defined,union-attr]
678                calc_data["sun"].abs_pos  # type: ignore[attr-defined,union-attr]
679            )
680        else:
681            calc_data["lunar_phase"] = None
682
683        # Create and return the AstrologicalSubjectModel
684        return AstrologicalSubjectModel(**calc_data)

Create an astrological subject from standard birth or event data.

This is the most flexible and commonly used factory method. It creates a complete astrological subject with planetary positions, house cusps, and specialized points for a specific date, time, and location. Supports both online location resolution and offline calculation modes.

Args: name (str, optional): Name or identifier for the subject. Defaults to "Now". year (int, optional): Year of birth/event. Defaults to current year. month (int, optional): Month of birth/event (1-12). Defaults to current month. day (int, optional): Day of birth/event (1-31). Defaults to current day. hour (int, optional): Hour of birth/event (0-23). Defaults to current hour. minute (int, optional): Minute of birth/event (0-59). Defaults to current minute. seconds (int, optional): Seconds of birth/event (0-59). Defaults to 0. city (str, optional): City name for location lookup. Used with online=True. Defaults to None (Greenwich if not specified). nation (str, optional): ISO country code (e.g., 'US', 'GB', 'IT'). Used with online=True. Defaults to None ('GB' if not specified). lng (float, optional): Longitude in decimal degrees. East is positive, West is negative. If not provided and online=True, fetched from GeoNames. lat (float, optional): Latitude in decimal degrees. North is positive, South is negative. If not provided and online=True, fetched from GeoNames. tz_str (str, optional): IANA timezone identifier (e.g., 'Europe/London'). If not provided and online=True, fetched from GeoNames. geonames_username (str, optional): Username for GeoNames API. Required for online location lookup. Get one free at geonames.org. online (bool, optional): Whether to fetch location data online. If False, lng, lat, and tz_str must be provided. Defaults to True. zodiac_type (ZodiacType, optional): Zodiac system - 'Tropical' or 'Sidereal'. Defaults to 'Tropical'. sidereal_mode (SiderealMode, optional): Sidereal calculation mode (e.g., 'FAGAN_BRADLEY', 'LAHIRI'). Only used with zodiac_type='Sidereal'. houses_system_identifier (HousesSystemIdentifier, optional): House system for cusp calculations (e.g., 'P'=Placidus, 'K'=Koch, 'E'=Equal). Defaults to 'P' (Placidus). perspective_type (PerspectiveType, optional): Calculation perspective: - 'Apparent Geocentric': Standard geocentric with light-time correction - 'True Geocentric': Geometric geocentric positions - 'Heliocentric': Sun-centered coordinates - 'Topocentric': Earth surface perspective (requires altitude) Defaults to 'Apparent Geocentric'. cache_expire_after_days (int, optional): Days to cache GeoNames data locally. Defaults to 30. is_dst (bool, optional): Daylight Saving Time flag for ambiguous times. If None, pytz attempts automatic detection. Set explicitly for times during DST transitions. altitude (float, optional): Altitude above sea level in meters. Used for topocentric calculations and atmospheric corrections. Defaults to None (sea level assumed). active_points (Optional[List[AstrologicalPoint]], optional): List of astrological points to calculate. Omitting points can improve performance for specialized applications. If None, uses DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Requires Sun and Moon in active_points. Defaults to True. suppress_geonames_warning (bool, optional): If True, suppresses the warning message when using the default GeoNames username. Useful for testing or automated processes. Defaults to False.

Returns: AstrologicalSubjectModel: Complete astrological subject with calculated positions, houses, and metadata. Access planetary positions via attributes like .sun, .moon, .mercury, etc.

Raises: KerykeionException: - If offline mode is used without required location data - If invalid zodiac/sidereal mode combinations are specified - If GeoNames data is missing or invalid - If timezone localization fails (ambiguous DST times)

Examples:

Basic natal chart with online location lookup

chart = AstrologicalSubjectFactory.from_birth_data( ... name="Jane Doe", ... year=1985, month=3, day=21, ... hour=15, minute=30, ... city="Paris", nation="FR", ... geonames_username="your_username" ... )

>>> # Offline calculation with manual coordinates
>>> chart = AstrologicalSubjectFactory.from_birth_data(
...     name="John Smith",
...     year=1990, month=12, day=25,
...     hour=0, minute=0,
...     lng=-74.006, lat=40.7128, tz_str="America/New_York",
...     online=False
... )

>>> # Sidereal chart with specific points
>>> chart = AstrologicalSubjectFactory.from_birth_data(
...     name="Vedic Chart",
...     year=2000, month=6, day=15, hour=12,
...     city="Mumbai", nation="IN",
...     zodiac_type="Sidereal",
...     sidereal_mode="LAHIRI",
...     active_points=["Sun", "Moon", "Mercury", "Venus", "Mars",
...                   "Jupiter", "Saturn", "Ascendant"]
... )

Note: - For high-precision calculations, consider providing seconds parameter - Use topocentric perspective for observer-specific calculations - Some Arabic parts automatically activate required base points - The method handles polar regions by adjusting extreme latitudes - Time zones are handled with full DST awareness via pytz

@classmethod
def from_iso_utc_time( cls, name: str, iso_utc_time: str, city: str = 'Greenwich', nation: str = 'GB', tz_str: str = 'Etc/GMT', online: bool = True, lng: float = 0.0, lat: float = 51.5074, geonames_username: str = 'century.boy', zodiac_type: Literal['Tropical', 'Sidereal'] = 'Tropical', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', altitude: Optional[float] = None, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, calculate_lunar_phase: bool = True, suppress_geonames_warning: bool = False) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
686    @classmethod
687    def from_iso_utc_time(
688        cls,
689        name: str,
690        iso_utc_time: str,
691        city: str = "Greenwich",
692        nation: str = "GB",
693        tz_str: str = "Etc/GMT",
694        online: bool = True,
695        lng: float = 0.0,
696        lat: float = 51.5074,
697        geonames_username: str = DEFAULT_GEONAMES_USERNAME,
698        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
699        sidereal_mode: Optional[SiderealMode] = None,
700        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
701        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
702        altitude: Optional[float] = None,
703        active_points: Optional[List[AstrologicalPoint]] = None,
704        calculate_lunar_phase: bool = True,
705        suppress_geonames_warning: bool = False
706    ) -> AstrologicalSubjectModel:
707        """
708        Create an astrological subject from an ISO formatted UTC timestamp.
709
710        This method is ideal for creating astrological subjects from standardized
711        time formats, such as those stored in databases or received from APIs.
712        It automatically handles timezone conversion from UTC to the specified
713        local timezone.
714
715        Args:
716            name (str): Name or identifier for the subject.
717            iso_utc_time (str): ISO 8601 formatted UTC timestamp. Supported formats:
718                - "2023-06-15T14:30:00Z" (with Z suffix)
719                - "2023-06-15T14:30:00+00:00" (with UTC offset)
720                - "2023-06-15T14:30:00.123Z" (with milliseconds)
721            city (str, optional): City name for location. Defaults to "Greenwich".
722            nation (str, optional): ISO country code. Defaults to "GB".
723            tz_str (str, optional): IANA timezone identifier for result conversion.
724                The ISO time is assumed to be in UTC and will be converted to this
725                timezone. Defaults to "Etc/GMT".
726            online (bool, optional): Whether to fetch coordinates online. If True,
727                coordinates are fetched via GeoNames API. Defaults to True.
728            lng (float, optional): Longitude in decimal degrees. Used when online=False
729                or as fallback. Defaults to 0.0 (Greenwich).
730            lat (float, optional): Latitude in decimal degrees. Used when online=False
731                or as fallback. Defaults to 51.5074 (Greenwich).
732            geonames_username (str, optional): GeoNames API username. Required when
733                online=True. Defaults to DEFAULT_GEONAMES_USERNAME.
734            zodiac_type (ZodiacType, optional): Zodiac system. Defaults to 'Tropical'.
735            sidereal_mode (SiderealMode, optional): Sidereal mode when zodiac_type
736                is 'Sidereal'. Defaults to None.
737            houses_system_identifier (HousesSystemIdentifier, optional): House system.
738                Defaults to 'P' (Placidus).
739            perspective_type (PerspectiveType, optional): Calculation perspective.
740                Defaults to 'Apparent Geocentric'.
741            altitude (float, optional): Altitude in meters for topocentric calculations.
742                Defaults to None (sea level).
743            active_points (Optional[List[AstrologicalPoint]], optional): Points to calculate.
744                If None, uses DEFAULT_ACTIVE_POINTS.
745            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
746                Defaults to True.
747
748        Returns:
749            AstrologicalSubjectModel: Astrological subject with positions calculated
750                for the specified UTC time converted to local timezone.
751
752        Raises:
753            ValueError: If the ISO timestamp format is invalid or cannot be parsed.
754            KerykeionException: If location data cannot be fetched or is invalid.
755
756        Examples:
757            >>> # From API timestamp with online location lookup
758            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
759            ...     name="Event Chart",
760            ...     iso_utc_time="2023-12-25T12:00:00Z",
761            ...     city="Tokyo", nation="JP",
762            ...     tz_str="Asia/Tokyo",
763            ...     geonames_username="your_username"
764            ... )
765
766            >>> # From database timestamp with manual coordinates
767            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
768            ...     name="Historical Event",
769            ...     iso_utc_time="1969-07-20T20:17:00Z",
770            ...     lng=-95.0969, lat=37.4419,  # Houston
771            ...     tz_str="America/Chicago",
772            ...     online=False
773            ... )
774
775        Note:
776            - The method assumes the input timestamp is in UTC
777            - Local time conversion respects DST rules for the target timezone
778            - Milliseconds in the timestamp are supported but truncated to seconds
779            - When online=True, the city/nation parameters override lng/lat
780        """
781        # Parse the ISO time
782        dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
783
784        # Get location data if online mode is enabled
785        if online:
786            if geonames_username == DEFAULT_GEONAMES_USERNAME:
787                if not suppress_geonames_warning:
788                    logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
789
790            geonames = FetchGeonames(
791                city,
792                nation,
793                username=geonames_username,
794            )
795
796            city_data = geonames.get_serialized_data()
797            lng = float(city_data["lng"])
798            lat = float(city_data["lat"])
799
800        # Convert UTC to local time
801        local_time = pytz.timezone(tz_str)
802        local_datetime = dt.astimezone(local_time)
803
804        # Create the subject with local time
805        return cls.from_birth_data(
806            name=name,
807            year=local_datetime.year,
808            month=local_datetime.month,
809            day=local_datetime.day,
810            hour=local_datetime.hour,
811            minute=local_datetime.minute,
812            seconds=local_datetime.second,
813            city=city,
814            nation=nation,
815            lng=lng,
816            lat=lat,
817            tz_str=tz_str,
818            online=False,  # Already fetched data if needed
819            geonames_username=geonames_username,
820            zodiac_type=zodiac_type,
821            sidereal_mode=sidereal_mode,
822            houses_system_identifier=houses_system_identifier,
823            perspective_type=perspective_type,
824            altitude=altitude,
825            active_points=active_points,
826            calculate_lunar_phase=calculate_lunar_phase,
827            suppress_geonames_warning=suppress_geonames_warning
828        )

Create an astrological subject from an ISO formatted UTC timestamp.

This method is ideal for creating astrological subjects from standardized time formats, such as those stored in databases or received from APIs. It automatically handles timezone conversion from UTC to the specified local timezone.

Args: name (str): Name or identifier for the subject. iso_utc_time (str): ISO 8601 formatted UTC timestamp. Supported formats: - "2023-06-15T14:30:00Z" (with Z suffix) - "2023-06-15T14:30:00+00:00" (with UTC offset) - "2023-06-15T14:30:00.123Z" (with milliseconds) city (str, optional): City name for location. Defaults to "Greenwich". nation (str, optional): ISO country code. Defaults to "GB". tz_str (str, optional): IANA timezone identifier for result conversion. The ISO time is assumed to be in UTC and will be converted to this timezone. Defaults to "Etc/GMT". online (bool, optional): Whether to fetch coordinates online. If True, coordinates are fetched via GeoNames API. Defaults to True. lng (float, optional): Longitude in decimal degrees. Used when online=False or as fallback. Defaults to 0.0 (Greenwich). lat (float, optional): Latitude in decimal degrees. Used when online=False or as fallback. Defaults to 51.5074 (Greenwich). geonames_username (str, optional): GeoNames API username. Required when online=True. Defaults to DEFAULT_GEONAMES_USERNAME. zodiac_type (ZodiacType, optional): Zodiac system. Defaults to 'Tropical'. sidereal_mode (SiderealMode, optional): Sidereal mode when zodiac_type is 'Sidereal'. Defaults to None. houses_system_identifier (HousesSystemIdentifier, optional): House system. Defaults to 'P' (Placidus). perspective_type (PerspectiveType, optional): Calculation perspective. Defaults to 'Apparent Geocentric'. altitude (float, optional): Altitude in meters for topocentric calculations. Defaults to None (sea level). active_points (Optional[List[AstrologicalPoint]], optional): Points to calculate. If None, uses DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Defaults to True.

Returns: AstrologicalSubjectModel: Astrological subject with positions calculated for the specified UTC time converted to local timezone.

Raises: ValueError: If the ISO timestamp format is invalid or cannot be parsed. KerykeionException: If location data cannot be fetched or is invalid.

Examples:

From API timestamp with online location lookup

subject = AstrologicalSubjectFactory.from_iso_utc_time( ... name="Event Chart", ... iso_utc_time="2023-12-25T12:00:00Z", ... city="Tokyo", nation="JP", ... tz_str="Asia/Tokyo", ... geonames_username="your_username" ... )

>>> # From database timestamp with manual coordinates
>>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
...     name="Historical Event",
...     iso_utc_time="1969-07-20T20:17:00Z",
...     lng=-95.0969, lat=37.4419,  # Houston
...     tz_str="America/Chicago",
...     online=False
... )

Note: - The method assumes the input timestamp is in UTC - Local time conversion respects DST rules for the target timezone - Milliseconds in the timestamp are supported but truncated to seconds - When online=True, the city/nation parameters override lng/lat

@classmethod
def from_current_time( cls, name: str = 'Now', city: Optional[str] = None, nation: Optional[str] = None, lng: Optional[float] = None, lat: Optional[float] = None, tz_str: Optional[str] = None, geonames_username: Optional[str] = None, online: bool = True, zodiac_type: Literal['Tropical', 'Sidereal'] = 'Tropical', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, calculate_lunar_phase: bool = True, suppress_geonames_warning: bool = False) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
830    @classmethod
831    def from_current_time(
832        cls,
833        name: str = "Now",
834        city: Optional[str] = None,
835        nation: Optional[str] = None,
836        lng: Optional[float] = None,
837        lat: Optional[float] = None,
838        tz_str: Optional[str] = None,
839        geonames_username: Optional[str] = None,
840        online: bool = True,
841        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
842        sidereal_mode: Optional[SiderealMode] = None,
843        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
844        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
845        active_points: Optional[List[AstrologicalPoint]] = None,
846        calculate_lunar_phase: bool = True,
847        suppress_geonames_warning: bool = False
848    ) -> AstrologicalSubjectModel:
849        """
850        Create an astrological subject for the current moment in time.
851
852        This convenience method creates a "now" chart, capturing the current
853        astrological conditions at the moment of execution. Useful for horary
854        astrology, electional astrology, or real-time astrological monitoring.
855
856        Args:
857            name (str, optional): Name for the current moment chart.
858                Defaults to "Now".
859            city (str, optional): City name for location lookup. If not provided
860                and online=True, defaults to Greenwich.
861            nation (str, optional): ISO country code. If not provided and
862                online=True, defaults to 'GB'.
863            lng (float, optional): Longitude in decimal degrees. If not provided
864                and online=True, fetched from GeoNames API.
865            lat (float, optional): Latitude in decimal degrees. If not provided
866                and online=True, fetched from GeoNames API.
867            tz_str (str, optional): IANA timezone identifier. If not provided
868                and online=True, fetched from GeoNames API.
869            geonames_username (str, optional): GeoNames API username for location
870                lookup. Required when online=True and location is not fully specified.
871            online (bool, optional): Whether to fetch location data online.
872                Defaults to True.
873            zodiac_type (ZodiacType, optional): Zodiac system to use.
874                Defaults to 'Tropical'.
875            sidereal_mode (SiderealMode, optional): Sidereal calculation mode.
876                Only used when zodiac_type is 'Sidereal'. Defaults to None.
877            houses_system_identifier (HousesSystemIdentifier, optional): House
878                system for calculations. Defaults to 'P' (Placidus).
879            perspective_type (PerspectiveType, optional): Calculation perspective.
880                Defaults to 'Apparent Geocentric'.
881            active_points (Optional[List[AstrologicalPoint]], optional): Astrological points
882                to calculate. If None, uses DEFAULT_ACTIVE_POINTS.
883            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
884                Defaults to True.
885
886        Returns:
887            AstrologicalSubjectModel: Astrological subject representing current
888                astrological conditions at the specified or default location.
889
890        Raises:
891            KerykeionException: If online location lookup fails or if offline mode
892                is used without sufficient location data.
893
894        Examples:
895            >>> # Current moment for your location
896            >>> now_chart = AstrologicalSubjectFactory.from_current_time(
897            ...     name="Current Transits",
898            ...     city="New York", nation="US",
899            ...     geonames_username="your_username"
900            ... )
901
902            >>> # Horary chart with specific coordinates
903            >>> horary = AstrologicalSubjectFactory.from_current_time(
904            ...     name="Horary Question",
905            ...     lng=-0.1278, lat=51.5074,  # London
906            ...     tz_str="Europe/London",
907            ...     online=False
908            ... )
909
910            >>> # Current sidereal positions
911            >>> sidereal_now = AstrologicalSubjectFactory.from_current_time(
912            ...     name="Sidereal Now",
913            ...     city="Mumbai", nation="IN",
914            ...     zodiac_type="Sidereal",
915            ...     sidereal_mode="LAHIRI"
916            ... )
917
918        Note:
919            - The exact time is captured at method execution, including seconds
920            - For horary astrology, consider the moment of understanding the question
921            - System clock accuracy affects precision; ensure accurate system time
922            - Time zone detection is automatic when using online location lookup
923        """
924        now = datetime.now()
925
926        return cls.from_birth_data(
927            name=name,
928            year=now.year,
929            month=now.month,
930            day=now.day,
931            hour=now.hour,
932            minute=now.minute,
933            seconds=now.second,
934            city=city,
935            nation=nation,
936            lng=lng,
937            lat=lat,
938            tz_str=tz_str,
939            geonames_username=geonames_username,
940            online=online,
941            zodiac_type=zodiac_type,
942            sidereal_mode=sidereal_mode,
943            houses_system_identifier=houses_system_identifier,
944            perspective_type=perspective_type,
945            active_points=active_points,
946            calculate_lunar_phase=calculate_lunar_phase,
947            suppress_geonames_warning=suppress_geonames_warning
948        )

Create an astrological subject for the current moment in time.

This convenience method creates a "now" chart, capturing the current astrological conditions at the moment of execution. Useful for horary astrology, electional astrology, or real-time astrological monitoring.

Args: name (str, optional): Name for the current moment chart. Defaults to "Now". city (str, optional): City name for location lookup. If not provided and online=True, defaults to Greenwich. nation (str, optional): ISO country code. If not provided and online=True, defaults to 'GB'. lng (float, optional): Longitude in decimal degrees. If not provided and online=True, fetched from GeoNames API. lat (float, optional): Latitude in decimal degrees. If not provided and online=True, fetched from GeoNames API. tz_str (str, optional): IANA timezone identifier. If not provided and online=True, fetched from GeoNames API. geonames_username (str, optional): GeoNames API username for location lookup. Required when online=True and location is not fully specified. online (bool, optional): Whether to fetch location data online. Defaults to True. zodiac_type (ZodiacType, optional): Zodiac system to use. Defaults to 'Tropical'. sidereal_mode (SiderealMode, optional): Sidereal calculation mode. Only used when zodiac_type is 'Sidereal'. Defaults to None. houses_system_identifier (HousesSystemIdentifier, optional): House system for calculations. Defaults to 'P' (Placidus). perspective_type (PerspectiveType, optional): Calculation perspective. Defaults to 'Apparent Geocentric'. active_points (Optional[List[AstrologicalPoint]], optional): Astrological points to calculate. If None, uses DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Defaults to True.

Returns: AstrologicalSubjectModel: Astrological subject representing current astrological conditions at the specified or default location.

Raises: KerykeionException: If online location lookup fails or if offline mode is used without sufficient location data.

Examples:

Current moment for your location

now_chart = AstrologicalSubjectFactory.from_current_time( ... name="Current Transits", ... city="New York", nation="US", ... geonames_username="your_username" ... )

>>> # Horary chart with specific coordinates
>>> horary = AstrologicalSubjectFactory.from_current_time(
...     name="Horary Question",
...     lng=-0.1278, lat=51.5074,  # London
...     tz_str="Europe/London",
...     online=False
... )

>>> # Current sidereal positions
>>> sidereal_now = AstrologicalSubjectFactory.from_current_time(
...     name="Sidereal Now",
...     city="Mumbai", nation="IN",
...     zodiac_type="Sidereal",
...     sidereal_mode="LAHIRI"
... )

Note: - The exact time is captured at method execution, including seconds - For horary astrology, consider the moment of understanding the question - System clock accuracy affects precision; ensure accurate system time - Time zone detection is automatic when using online location lookup

class ChartDataFactory:
 72class ChartDataFactory:
 73    """
 74    Factory class for creating comprehensive chart data models.
 75
 76    This factory creates ChartDataModel instances containing all the pure data
 77    from astrological charts, including subjects, aspects, house comparisons,
 78    and analytical metrics. It provides the structured data equivalent of
 79    ChartDrawer's visual output.
 80
 81    The factory handles all chart types and automatically includes relevant
 82    analyses based on chart type (e.g., house comparison for dual charts,
 83    relationship scoring for synastry charts).
 84
 85    Example:
 86        >>> # Create natal chart data
 87        >>> john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
 88        >>> natal_data = ChartDataFactory.create_chart_data("Natal", john)
 89        >>> print(f"Elements: Fire {natal_data.element_distribution.fire_percentage}%")
 90        >>>
 91        >>> # Create synastry chart data
 92        >>> jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR")
 93        >>> synastry_data = ChartDataFactory.create_chart_data("Synastry", john, jane)
 94        >>> print(f"Relationship score: {synastry_data.relationship_score.score_value}")
 95    """
 96
 97    @staticmethod
 98    def create_chart_data(
 99        chart_type: ChartType,
100        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
101        second_subject: Optional[Union[AstrologicalSubjectModel, PlanetReturnModel]] = None,
102        active_points: Optional[List[AstrologicalPoint]] = None,
103        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
104        include_house_comparison: bool = True,
105        include_relationship_score: bool = False,
106        *,
107        axis_orb_limit: Optional[float] = None,
108        distribution_method: ElementQualityDistributionMethod = "weighted",
109        custom_distribution_weights: Optional[Mapping[str, float]] = None,
110    ) -> ChartDataModel:
111        """
112        Create comprehensive chart data for the specified chart type.
113
114        Args:
115            chart_type: Type of chart to create data for
116            first_subject: Primary astrological subject
117            second_subject: Secondary subject (required for dual charts)
118            active_points: Points to include in calculations (defaults to first_subject.active_points)
119            active_aspects: Aspect types and orbs to use
120            include_house_comparison: Whether to include house comparison for dual charts
121            include_relationship_score: Whether to include relationship scoring for synastry
122            axis_orb_limit: Optional orb threshold for chart axes (applies only to single chart aspects)
123            distribution_method: Strategy for element/modality weighting ("pure_count" or "weighted")
124            custom_distribution_weights: Optional overrides for the distribution weights
125
126        Returns:
127            ChartDataModel: Comprehensive chart data model
128
129        Raises:
130            KerykeionException: If chart type requirements are not met
131        """
132
133        # Validate chart type requirements
134        if chart_type in ["Transit", "Synastry", "DualReturnChart"] and not second_subject:
135            raise KerykeionException(f"Second subject is required for {chart_type} charts.")
136
137        if chart_type == "Composite" and not isinstance(first_subject, CompositeSubjectModel):
138            raise KerykeionException("First subject must be a CompositeSubjectModel for Composite charts.")
139
140        if chart_type == "Return" and not isinstance(second_subject, PlanetReturnModel):
141            raise KerykeionException("Second subject must be a PlanetReturnModel for Return charts.")
142
143        if chart_type == "SingleReturnChart" and not isinstance(first_subject, PlanetReturnModel):
144            raise KerykeionException("First subject must be a PlanetReturnModel for SingleReturnChart charts.")
145
146        # Determine active points
147        if not active_points:
148            effective_active_points = first_subject.active_points
149        else:
150            effective_active_points = find_common_active_points(
151                active_points,
152                first_subject.active_points
153            )
154
155        # For dual charts, further filter by second subject's active points
156        if second_subject:
157            effective_active_points = find_common_active_points(
158                effective_active_points,
159                second_subject.active_points
160            )
161
162        # Calculate aspects based on chart type
163        aspects_model: Union[SingleChartAspectsModel, DualChartAspectsModel]
164        if chart_type in ["Natal", "Composite", "SingleReturnChart"]:
165            # Single chart aspects
166            aspects_model = AspectsFactory.single_chart_aspects(
167                first_subject,
168                active_points=effective_active_points,
169                active_aspects=active_aspects,
170                axis_orb_limit=axis_orb_limit,
171            )
172        else:
173            # Dual chart aspects - second_subject is guaranteed to exist here due to validation above
174            if second_subject is None:
175                raise KerykeionException(f"Second subject is required for {chart_type} charts.")
176
177            # Determine if subjects are fixed based on chart type
178            first_subject_is_fixed = False
179            second_subject_is_fixed = False
180
181            if chart_type == "Synastry":
182                first_subject_is_fixed = True
183                second_subject_is_fixed = True
184            elif chart_type == "Transit":
185                first_subject_is_fixed = True  # Natal chart is fixed
186                second_subject_is_fixed = False # Transit chart is moving
187            elif chart_type == "DualReturnChart":
188                first_subject_is_fixed = True  # Natal chart is fixed
189                second_subject_is_fixed = False  # Return chart is moving (like transits)
190
191            aspects_model = AspectsFactory.dual_chart_aspects(
192                first_subject,
193                second_subject,
194                active_points=effective_active_points,
195                active_aspects=active_aspects,
196                axis_orb_limit=axis_orb_limit,
197                first_subject_is_fixed=first_subject_is_fixed,
198                second_subject_is_fixed=second_subject_is_fixed,
199            )
200
201        # Calculate house comparison for dual charts
202        house_comparison = None
203        if second_subject and include_house_comparison and chart_type in ["Transit", "Synastry", "DualReturnChart"]:
204            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, (AstrologicalSubjectModel, PlanetReturnModel)):
205                house_comparison_factory = HouseComparisonFactory(
206                    first_subject,
207                    second_subject,
208                    active_points=effective_active_points
209                )
210                house_comparison = house_comparison_factory.get_house_comparison()
211
212        # Calculate relationship score for synastry
213        relationship_score = None
214        if chart_type == "Synastry" and include_relationship_score and second_subject:
215            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, AstrologicalSubjectModel):
216                relationship_score_factory = RelationshipScoreFactory(
217                    first_subject,
218                    second_subject,
219                    axis_orb_limit=axis_orb_limit,
220                )
221                relationship_score = relationship_score_factory.get_relationship_score()
222
223        # Calculate element and quality distributions
224        available_planets_setting_dicts: list[dict[str, object]] = []
225        for body in DEFAULT_CELESTIAL_POINTS_SETTINGS:
226            if body["name"] in effective_active_points:
227                body_dict = dict(body)
228                body_dict["is_active"] = True
229                available_planets_setting_dicts.append(body_dict)
230
231        # Convert to models for type safety
232        available_planets_setting: list[KerykeionSettingsCelestialPointModel] = [
233            KerykeionSettingsCelestialPointModel(**body) for body in available_planets_setting_dicts  # type: ignore
234        ]
235
236        celestial_points_names = [body.name.lower() for body in available_planets_setting]
237
238        if chart_type == "Synastry" and second_subject:
239            # Calculate combined element/quality points for synastry
240            # Type narrowing: ensure both subjects are AstrologicalSubjectModel for synastry
241            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, AstrologicalSubjectModel):
242                element_totals = calculate_synastry_element_points(
243                    available_planets_setting,
244                    celestial_points_names,
245                    first_subject,
246                    second_subject,
247                    method=distribution_method,
248                    custom_weights=custom_distribution_weights,
249                )
250                quality_totals = calculate_synastry_quality_points(
251                    available_planets_setting,
252                    celestial_points_names,
253                    first_subject,
254                    second_subject,
255                    method=distribution_method,
256                    custom_weights=custom_distribution_weights,
257                )
258            else:
259                # Fallback to single chart calculation for incompatible types
260                element_totals = calculate_element_points(
261                    available_planets_setting,
262                    celestial_points_names,
263                    first_subject,
264                    method=distribution_method,
265                    custom_weights=custom_distribution_weights,
266                )
267                quality_totals = calculate_quality_points(
268                    available_planets_setting,
269                    celestial_points_names,
270                    first_subject,
271                    method=distribution_method,
272                    custom_weights=custom_distribution_weights,
273                )
274        else:
275            # Calculate element/quality points for single chart
276            element_totals = calculate_element_points(
277                available_planets_setting,
278                celestial_points_names,
279                first_subject,
280                method=distribution_method,
281                custom_weights=custom_distribution_weights,
282            )
283            quality_totals = calculate_quality_points(
284                available_planets_setting,
285                celestial_points_names,
286                first_subject,
287                method=distribution_method,
288                custom_weights=custom_distribution_weights,
289            )
290
291        # Calculate percentages
292        total_elements = element_totals["fire"] + element_totals["water"] + element_totals["earth"] + element_totals["air"]
293        element_percentages = distribute_percentages_to_100(element_totals) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
294        element_distribution = ElementDistributionModel(
295            fire=element_totals["fire"],
296            earth=element_totals["earth"],
297            air=element_totals["air"],
298            water=element_totals["water"],
299            fire_percentage=element_percentages["fire"],
300            earth_percentage=element_percentages["earth"],
301            air_percentage=element_percentages["air"],
302            water_percentage=element_percentages["water"],
303        )
304
305        total_qualities = quality_totals["cardinal"] + quality_totals["fixed"] + quality_totals["mutable"]
306        quality_percentages = distribute_percentages_to_100(quality_totals) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
307        quality_distribution = QualityDistributionModel(
308            cardinal=quality_totals["cardinal"],
309            fixed=quality_totals["fixed"],
310            mutable=quality_totals["mutable"],
311            cardinal_percentage=quality_percentages["cardinal"],
312            fixed_percentage=quality_percentages["fixed"],
313            mutable_percentage=quality_percentages["mutable"],
314        )
315
316        # Create and return the appropriate chart data model
317        if chart_type in ["Natal", "Composite", "SingleReturnChart"]:
318            # Single chart data model - cast types since they're already validated
319            return SingleChartDataModel(
320                chart_type=cast(Literal["Natal", "Composite", "SingleReturnChart"], chart_type),
321                subject=first_subject,
322                aspects=cast(SingleChartAspectsModel, aspects_model).aspects,
323                element_distribution=element_distribution,
324                quality_distribution=quality_distribution,
325                active_points=effective_active_points,
326                active_aspects=active_aspects,
327            )
328        else:
329            # Dual chart data model - cast types since they're already validated
330            if second_subject is None:
331                raise KerykeionException(f"Second subject is required for {chart_type} charts.")
332            return DualChartDataModel(
333                chart_type=cast(Literal["Transit", "Synastry", "DualReturnChart"], chart_type),
334                first_subject=first_subject,
335                second_subject=second_subject,
336                aspects=cast(DualChartAspectsModel, aspects_model).aspects,
337                house_comparison=house_comparison,
338                relationship_score=relationship_score,
339                element_distribution=element_distribution,
340                quality_distribution=quality_distribution,
341                active_points=effective_active_points,
342                active_aspects=active_aspects,
343            )
344
345    @staticmethod
346    def create_natal_chart_data(
347        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
348        active_points: Optional[List[AstrologicalPoint]] = None,
349        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
350        *,
351        distribution_method: ElementQualityDistributionMethod = "weighted",
352        custom_distribution_weights: Optional[Mapping[str, float]] = None,
353    ) -> ChartDataModel:
354        """
355        Convenience method for creating natal chart data.
356
357        Args:
358            subject: Astrological subject
359            active_points: Points to include in calculations
360            active_aspects: Aspect types and orbs to use
361            distribution_method: Strategy for element/modality weighting
362            custom_distribution_weights: Optional overrides for distribution weights
363
364        Returns:
365            ChartDataModel: Natal chart data
366        """
367        return ChartDataFactory.create_chart_data(
368            first_subject=subject,
369            chart_type="Natal",
370            active_points=active_points,
371            active_aspects=active_aspects,
372            distribution_method=distribution_method,
373            custom_distribution_weights=custom_distribution_weights,
374        )
375
376    @staticmethod
377    def create_synastry_chart_data(
378        first_subject: AstrologicalSubjectModel,
379        second_subject: AstrologicalSubjectModel,
380        active_points: Optional[List[AstrologicalPoint]] = None,
381        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
382        include_house_comparison: bool = True,
383        include_relationship_score: bool = True,
384        *,
385        distribution_method: ElementQualityDistributionMethod = "weighted",
386        custom_distribution_weights: Optional[Mapping[str, float]] = None,
387    ) -> ChartDataModel:
388        """
389        Convenience method for creating synastry chart data.
390
391        Args:
392            first_subject: First astrological subject
393            second_subject: Second astrological subject
394            active_points: Points to include in calculations
395            active_aspects: Aspect types and orbs to use
396            include_house_comparison: Whether to include house comparison
397            include_relationship_score: Whether to include relationship scoring
398            distribution_method: Strategy for element/modality weighting
399            custom_distribution_weights: Optional overrides for distribution weights
400
401        Returns:
402            ChartDataModel: Synastry chart data
403        """
404        return ChartDataFactory.create_chart_data(
405            first_subject=first_subject,
406            chart_type="Synastry",
407            second_subject=second_subject,
408            active_points=active_points,
409            active_aspects=active_aspects,
410            include_house_comparison=include_house_comparison,
411            include_relationship_score=include_relationship_score,
412            distribution_method=distribution_method,
413            custom_distribution_weights=custom_distribution_weights,
414        )
415
416    @staticmethod
417    def create_transit_chart_data(
418        natal_subject: AstrologicalSubjectModel,
419        transit_subject: AstrologicalSubjectModel,
420        active_points: Optional[List[AstrologicalPoint]] = None,
421        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
422        include_house_comparison: bool = True,
423        *,
424        distribution_method: ElementQualityDistributionMethod = "weighted",
425        custom_distribution_weights: Optional[Mapping[str, float]] = None,
426    ) -> ChartDataModel:
427        """
428        Convenience method for creating transit chart data.
429
430        Args:
431            natal_subject: Natal astrological subject
432            transit_subject: Transit astrological subject
433            active_points: Points to include in calculations
434            active_aspects: Aspect types and orbs to use
435            include_house_comparison: Whether to include house comparison
436            distribution_method: Strategy for element/modality weighting
437            custom_distribution_weights: Optional overrides for distribution weights
438
439        Returns:
440            ChartDataModel: Transit chart data
441        """
442        return ChartDataFactory.create_chart_data(
443            first_subject=natal_subject,
444            chart_type="Transit",
445            second_subject=transit_subject,
446            active_points=active_points,
447            active_aspects=active_aspects,
448            include_house_comparison=include_house_comparison,
449            distribution_method=distribution_method,
450            custom_distribution_weights=custom_distribution_weights,
451        )
452
453    @staticmethod
454    def create_composite_chart_data(
455        composite_subject: CompositeSubjectModel,
456        active_points: Optional[List[AstrologicalPoint]] = None,
457        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
458        *,
459        distribution_method: ElementQualityDistributionMethod = "weighted",
460        custom_distribution_weights: Optional[Mapping[str, float]] = None,
461    ) -> ChartDataModel:
462        """
463        Convenience method for creating composite chart data.
464
465        Args:
466            composite_subject: Composite astrological subject
467            active_points: Points to include in calculations
468            active_aspects: Aspect types and orbs to use
469            distribution_method: Strategy for element/modality weighting
470            custom_distribution_weights: Optional overrides for distribution weights
471
472        Returns:
473            ChartDataModel: Composite chart data
474        """
475        return ChartDataFactory.create_chart_data(
476            first_subject=composite_subject,
477            chart_type="Composite",
478            active_points=active_points,
479            active_aspects=active_aspects,
480            distribution_method=distribution_method,
481            custom_distribution_weights=custom_distribution_weights,
482        )
483
484    @staticmethod
485    def create_return_chart_data(
486        natal_subject: AstrologicalSubjectModel,
487        return_subject: PlanetReturnModel,
488        active_points: Optional[List[AstrologicalPoint]] = None,
489        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
490        include_house_comparison: bool = True,
491        *,
492        distribution_method: ElementQualityDistributionMethod = "weighted",
493        custom_distribution_weights: Optional[Mapping[str, float]] = None,
494    ) -> ChartDataModel:
495        """
496        Convenience method for creating planetary return chart data.
497
498        Args:
499            natal_subject: Natal astrological subject
500            return_subject: Planetary return subject
501            active_points: Points to include in calculations
502            active_aspects: Aspect types and orbs to use
503            include_house_comparison: Whether to include house comparison
504            distribution_method: Strategy for element/modality weighting
505            custom_distribution_weights: Optional overrides for distribution weights
506
507        Returns:
508            ChartDataModel: Return chart data
509        """
510        return ChartDataFactory.create_chart_data(
511            first_subject=natal_subject,
512            chart_type="DualReturnChart",
513            second_subject=return_subject,
514            active_points=active_points,
515            active_aspects=active_aspects,
516            include_house_comparison=include_house_comparison,
517            distribution_method=distribution_method,
518            custom_distribution_weights=custom_distribution_weights,
519        )
520
521    @staticmethod
522    def create_single_wheel_return_chart_data(
523        return_subject: PlanetReturnModel,
524        active_points: Optional[List[AstrologicalPoint]] = None,
525        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
526        *,
527        distribution_method: ElementQualityDistributionMethod = "weighted",
528        custom_distribution_weights: Optional[Mapping[str, float]] = None,
529    ) -> ChartDataModel:
530        """
531        Convenience method for creating single wheel planetary return chart data.
532
533        Args:
534            return_subject: Planetary return subject
535            active_points: Points to include in calculations
536            active_aspects: Aspect types and orbs to use
537            distribution_method: Strategy for element/modality weighting
538            custom_distribution_weights: Optional overrides for distribution weights
539
540        Returns:
541            ChartDataModel: Single wheel return chart data
542        """
543        return ChartDataFactory.create_chart_data(
544            first_subject=return_subject,
545            chart_type="SingleReturnChart",
546            active_points=active_points,
547            active_aspects=active_aspects,
548            distribution_method=distribution_method,
549            custom_distribution_weights=custom_distribution_weights,
550        )

Factory class for creating comprehensive chart data models.

This factory creates ChartDataModel instances containing all the pure data from astrological charts, including subjects, aspects, house comparisons, and analytical metrics. It provides the structured data equivalent of ChartDrawer's visual output.

The factory handles all chart types and automatically includes relevant analyses based on chart type (e.g., house comparison for dual charts, relationship scoring for synastry charts).

Example:

Create natal chart data

john = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB") natal_data = ChartDataFactory.create_chart_data("Natal", john) print(f"Elements: Fire {natal_data.element_distribution.fire_percentage}%")

Create synastry chart data

jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1992, 6, 15, 14, 30, "Paris", "FR") synastry_data = ChartDataFactory.create_chart_data("Synastry", john, jane) print(f"Relationship score: {synastry_data.relationship_score.score_value}")

@staticmethod
def create_chart_data( chart_type: Literal['Natal', 'Synastry', 'Transit', 'Composite', 'DualReturnChart', 'SingleReturnChart'], first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel, NoneType] = None, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], include_house_comparison: bool = True, include_relationship_score: bool = False, *, axis_orb_limit: Optional[float] = None, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
 97    @staticmethod
 98    def create_chart_data(
 99        chart_type: ChartType,
100        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
101        second_subject: Optional[Union[AstrologicalSubjectModel, PlanetReturnModel]] = None,
102        active_points: Optional[List[AstrologicalPoint]] = None,
103        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
104        include_house_comparison: bool = True,
105        include_relationship_score: bool = False,
106        *,
107        axis_orb_limit: Optional[float] = None,
108        distribution_method: ElementQualityDistributionMethod = "weighted",
109        custom_distribution_weights: Optional[Mapping[str, float]] = None,
110    ) -> ChartDataModel:
111        """
112        Create comprehensive chart data for the specified chart type.
113
114        Args:
115            chart_type: Type of chart to create data for
116            first_subject: Primary astrological subject
117            second_subject: Secondary subject (required for dual charts)
118            active_points: Points to include in calculations (defaults to first_subject.active_points)
119            active_aspects: Aspect types and orbs to use
120            include_house_comparison: Whether to include house comparison for dual charts
121            include_relationship_score: Whether to include relationship scoring for synastry
122            axis_orb_limit: Optional orb threshold for chart axes (applies only to single chart aspects)
123            distribution_method: Strategy for element/modality weighting ("pure_count" or "weighted")
124            custom_distribution_weights: Optional overrides for the distribution weights
125
126        Returns:
127            ChartDataModel: Comprehensive chart data model
128
129        Raises:
130            KerykeionException: If chart type requirements are not met
131        """
132
133        # Validate chart type requirements
134        if chart_type in ["Transit", "Synastry", "DualReturnChart"] and not second_subject:
135            raise KerykeionException(f"Second subject is required for {chart_type} charts.")
136
137        if chart_type == "Composite" and not isinstance(first_subject, CompositeSubjectModel):
138            raise KerykeionException("First subject must be a CompositeSubjectModel for Composite charts.")
139
140        if chart_type == "Return" and not isinstance(second_subject, PlanetReturnModel):
141            raise KerykeionException("Second subject must be a PlanetReturnModel for Return charts.")
142
143        if chart_type == "SingleReturnChart" and not isinstance(first_subject, PlanetReturnModel):
144            raise KerykeionException("First subject must be a PlanetReturnModel for SingleReturnChart charts.")
145
146        # Determine active points
147        if not active_points:
148            effective_active_points = first_subject.active_points
149        else:
150            effective_active_points = find_common_active_points(
151                active_points,
152                first_subject.active_points
153            )
154
155        # For dual charts, further filter by second subject's active points
156        if second_subject:
157            effective_active_points = find_common_active_points(
158                effective_active_points,
159                second_subject.active_points
160            )
161
162        # Calculate aspects based on chart type
163        aspects_model: Union[SingleChartAspectsModel, DualChartAspectsModel]
164        if chart_type in ["Natal", "Composite", "SingleReturnChart"]:
165            # Single chart aspects
166            aspects_model = AspectsFactory.single_chart_aspects(
167                first_subject,
168                active_points=effective_active_points,
169                active_aspects=active_aspects,
170                axis_orb_limit=axis_orb_limit,
171            )
172        else:
173            # Dual chart aspects - second_subject is guaranteed to exist here due to validation above
174            if second_subject is None:
175                raise KerykeionException(f"Second subject is required for {chart_type} charts.")
176
177            # Determine if subjects are fixed based on chart type
178            first_subject_is_fixed = False
179            second_subject_is_fixed = False
180
181            if chart_type == "Synastry":
182                first_subject_is_fixed = True
183                second_subject_is_fixed = True
184            elif chart_type == "Transit":
185                first_subject_is_fixed = True  # Natal chart is fixed
186                second_subject_is_fixed = False # Transit chart is moving
187            elif chart_type == "DualReturnChart":
188                first_subject_is_fixed = True  # Natal chart is fixed
189                second_subject_is_fixed = False  # Return chart is moving (like transits)
190
191            aspects_model = AspectsFactory.dual_chart_aspects(
192                first_subject,
193                second_subject,
194                active_points=effective_active_points,
195                active_aspects=active_aspects,
196                axis_orb_limit=axis_orb_limit,
197                first_subject_is_fixed=first_subject_is_fixed,
198                second_subject_is_fixed=second_subject_is_fixed,
199            )
200
201        # Calculate house comparison for dual charts
202        house_comparison = None
203        if second_subject and include_house_comparison and chart_type in ["Transit", "Synastry", "DualReturnChart"]:
204            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, (AstrologicalSubjectModel, PlanetReturnModel)):
205                house_comparison_factory = HouseComparisonFactory(
206                    first_subject,
207                    second_subject,
208                    active_points=effective_active_points
209                )
210                house_comparison = house_comparison_factory.get_house_comparison()
211
212        # Calculate relationship score for synastry
213        relationship_score = None
214        if chart_type == "Synastry" and include_relationship_score and second_subject:
215            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, AstrologicalSubjectModel):
216                relationship_score_factory = RelationshipScoreFactory(
217                    first_subject,
218                    second_subject,
219                    axis_orb_limit=axis_orb_limit,
220                )
221                relationship_score = relationship_score_factory.get_relationship_score()
222
223        # Calculate element and quality distributions
224        available_planets_setting_dicts: list[dict[str, object]] = []
225        for body in DEFAULT_CELESTIAL_POINTS_SETTINGS:
226            if body["name"] in effective_active_points:
227                body_dict = dict(body)
228                body_dict["is_active"] = True
229                available_planets_setting_dicts.append(body_dict)
230
231        # Convert to models for type safety
232        available_planets_setting: list[KerykeionSettingsCelestialPointModel] = [
233            KerykeionSettingsCelestialPointModel(**body) for body in available_planets_setting_dicts  # type: ignore
234        ]
235
236        celestial_points_names = [body.name.lower() for body in available_planets_setting]
237
238        if chart_type == "Synastry" and second_subject:
239            # Calculate combined element/quality points for synastry
240            # Type narrowing: ensure both subjects are AstrologicalSubjectModel for synastry
241            if isinstance(first_subject, AstrologicalSubjectModel) and isinstance(second_subject, AstrologicalSubjectModel):
242                element_totals = calculate_synastry_element_points(
243                    available_planets_setting,
244                    celestial_points_names,
245                    first_subject,
246                    second_subject,
247                    method=distribution_method,
248                    custom_weights=custom_distribution_weights,
249                )
250                quality_totals = calculate_synastry_quality_points(
251                    available_planets_setting,
252                    celestial_points_names,
253                    first_subject,
254                    second_subject,
255                    method=distribution_method,
256                    custom_weights=custom_distribution_weights,
257                )
258            else:
259                # Fallback to single chart calculation for incompatible types
260                element_totals = calculate_element_points(
261                    available_planets_setting,
262                    celestial_points_names,
263                    first_subject,
264                    method=distribution_method,
265                    custom_weights=custom_distribution_weights,
266                )
267                quality_totals = calculate_quality_points(
268                    available_planets_setting,
269                    celestial_points_names,
270                    first_subject,
271                    method=distribution_method,
272                    custom_weights=custom_distribution_weights,
273                )
274        else:
275            # Calculate element/quality points for single chart
276            element_totals = calculate_element_points(
277                available_planets_setting,
278                celestial_points_names,
279                first_subject,
280                method=distribution_method,
281                custom_weights=custom_distribution_weights,
282            )
283            quality_totals = calculate_quality_points(
284                available_planets_setting,
285                celestial_points_names,
286                first_subject,
287                method=distribution_method,
288                custom_weights=custom_distribution_weights,
289            )
290
291        # Calculate percentages
292        total_elements = element_totals["fire"] + element_totals["water"] + element_totals["earth"] + element_totals["air"]
293        element_percentages = distribute_percentages_to_100(element_totals) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
294        element_distribution = ElementDistributionModel(
295            fire=element_totals["fire"],
296            earth=element_totals["earth"],
297            air=element_totals["air"],
298            water=element_totals["water"],
299            fire_percentage=element_percentages["fire"],
300            earth_percentage=element_percentages["earth"],
301            air_percentage=element_percentages["air"],
302            water_percentage=element_percentages["water"],
303        )
304
305        total_qualities = quality_totals["cardinal"] + quality_totals["fixed"] + quality_totals["mutable"]
306        quality_percentages = distribute_percentages_to_100(quality_totals) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
307        quality_distribution = QualityDistributionModel(
308            cardinal=quality_totals["cardinal"],
309            fixed=quality_totals["fixed"],
310            mutable=quality_totals["mutable"],
311            cardinal_percentage=quality_percentages["cardinal"],
312            fixed_percentage=quality_percentages["fixed"],
313            mutable_percentage=quality_percentages["mutable"],
314        )
315
316        # Create and return the appropriate chart data model
317        if chart_type in ["Natal", "Composite", "SingleReturnChart"]:
318            # Single chart data model - cast types since they're already validated
319            return SingleChartDataModel(
320                chart_type=cast(Literal["Natal", "Composite", "SingleReturnChart"], chart_type),
321                subject=first_subject,
322                aspects=cast(SingleChartAspectsModel, aspects_model).aspects,
323                element_distribution=element_distribution,
324                quality_distribution=quality_distribution,
325                active_points=effective_active_points,
326                active_aspects=active_aspects,
327            )
328        else:
329            # Dual chart data model - cast types since they're already validated
330            if second_subject is None:
331                raise KerykeionException(f"Second subject is required for {chart_type} charts.")
332            return DualChartDataModel(
333                chart_type=cast(Literal["Transit", "Synastry", "DualReturnChart"], chart_type),
334                first_subject=first_subject,
335                second_subject=second_subject,
336                aspects=cast(DualChartAspectsModel, aspects_model).aspects,
337                house_comparison=house_comparison,
338                relationship_score=relationship_score,
339                element_distribution=element_distribution,
340                quality_distribution=quality_distribution,
341                active_points=effective_active_points,
342                active_aspects=active_aspects,
343            )

Create comprehensive chart data for the specified chart type.

Args: chart_type: Type of chart to create data for first_subject: Primary astrological subject second_subject: Secondary subject (required for dual charts) active_points: Points to include in calculations (defaults to first_subject.active_points) active_aspects: Aspect types and orbs to use include_house_comparison: Whether to include house comparison for dual charts include_relationship_score: Whether to include relationship scoring for synastry axis_orb_limit: Optional orb threshold for chart axes (applies only to single chart aspects) distribution_method: Strategy for element/modality weighting ("pure_count" or "weighted") custom_distribution_weights: Optional overrides for the distribution weights

Returns: ChartDataModel: Comprehensive chart data model

Raises: KerykeionException: If chart type requirements are not met

@staticmethod
def create_natal_chart_data( subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
345    @staticmethod
346    def create_natal_chart_data(
347        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
348        active_points: Optional[List[AstrologicalPoint]] = None,
349        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
350        *,
351        distribution_method: ElementQualityDistributionMethod = "weighted",
352        custom_distribution_weights: Optional[Mapping[str, float]] = None,
353    ) -> ChartDataModel:
354        """
355        Convenience method for creating natal chart data.
356
357        Args:
358            subject: Astrological subject
359            active_points: Points to include in calculations
360            active_aspects: Aspect types and orbs to use
361            distribution_method: Strategy for element/modality weighting
362            custom_distribution_weights: Optional overrides for distribution weights
363
364        Returns:
365            ChartDataModel: Natal chart data
366        """
367        return ChartDataFactory.create_chart_data(
368            first_subject=subject,
369            chart_type="Natal",
370            active_points=active_points,
371            active_aspects=active_aspects,
372            distribution_method=distribution_method,
373            custom_distribution_weights=custom_distribution_weights,
374        )

Convenience method for creating natal chart data.

Args: subject: Astrological subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Natal chart data

@staticmethod
def create_synastry_chart_data( first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], include_house_comparison: bool = True, include_relationship_score: bool = True, *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
376    @staticmethod
377    def create_synastry_chart_data(
378        first_subject: AstrologicalSubjectModel,
379        second_subject: AstrologicalSubjectModel,
380        active_points: Optional[List[AstrologicalPoint]] = None,
381        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
382        include_house_comparison: bool = True,
383        include_relationship_score: bool = True,
384        *,
385        distribution_method: ElementQualityDistributionMethod = "weighted",
386        custom_distribution_weights: Optional[Mapping[str, float]] = None,
387    ) -> ChartDataModel:
388        """
389        Convenience method for creating synastry chart data.
390
391        Args:
392            first_subject: First astrological subject
393            second_subject: Second astrological subject
394            active_points: Points to include in calculations
395            active_aspects: Aspect types and orbs to use
396            include_house_comparison: Whether to include house comparison
397            include_relationship_score: Whether to include relationship scoring
398            distribution_method: Strategy for element/modality weighting
399            custom_distribution_weights: Optional overrides for distribution weights
400
401        Returns:
402            ChartDataModel: Synastry chart data
403        """
404        return ChartDataFactory.create_chart_data(
405            first_subject=first_subject,
406            chart_type="Synastry",
407            second_subject=second_subject,
408            active_points=active_points,
409            active_aspects=active_aspects,
410            include_house_comparison=include_house_comparison,
411            include_relationship_score=include_relationship_score,
412            distribution_method=distribution_method,
413            custom_distribution_weights=custom_distribution_weights,
414        )

Convenience method for creating synastry chart data.

Args: first_subject: First astrological subject second_subject: Second astrological subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use include_house_comparison: Whether to include house comparison include_relationship_score: Whether to include relationship scoring distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Synastry chart data

@staticmethod
def create_transit_chart_data( natal_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, transit_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], include_house_comparison: bool = True, *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
416    @staticmethod
417    def create_transit_chart_data(
418        natal_subject: AstrologicalSubjectModel,
419        transit_subject: AstrologicalSubjectModel,
420        active_points: Optional[List[AstrologicalPoint]] = None,
421        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
422        include_house_comparison: bool = True,
423        *,
424        distribution_method: ElementQualityDistributionMethod = "weighted",
425        custom_distribution_weights: Optional[Mapping[str, float]] = None,
426    ) -> ChartDataModel:
427        """
428        Convenience method for creating transit chart data.
429
430        Args:
431            natal_subject: Natal astrological subject
432            transit_subject: Transit astrological subject
433            active_points: Points to include in calculations
434            active_aspects: Aspect types and orbs to use
435            include_house_comparison: Whether to include house comparison
436            distribution_method: Strategy for element/modality weighting
437            custom_distribution_weights: Optional overrides for distribution weights
438
439        Returns:
440            ChartDataModel: Transit chart data
441        """
442        return ChartDataFactory.create_chart_data(
443            first_subject=natal_subject,
444            chart_type="Transit",
445            second_subject=transit_subject,
446            active_points=active_points,
447            active_aspects=active_aspects,
448            include_house_comparison=include_house_comparison,
449            distribution_method=distribution_method,
450            custom_distribution_weights=custom_distribution_weights,
451        )

Convenience method for creating transit chart data.

Args: natal_subject: Natal astrological subject transit_subject: Transit astrological subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use include_house_comparison: Whether to include house comparison distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Transit chart data

@staticmethod
def create_composite_chart_data( composite_subject: kerykeion.schemas.kr_models.CompositeSubjectModel, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
453    @staticmethod
454    def create_composite_chart_data(
455        composite_subject: CompositeSubjectModel,
456        active_points: Optional[List[AstrologicalPoint]] = None,
457        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
458        *,
459        distribution_method: ElementQualityDistributionMethod = "weighted",
460        custom_distribution_weights: Optional[Mapping[str, float]] = None,
461    ) -> ChartDataModel:
462        """
463        Convenience method for creating composite chart data.
464
465        Args:
466            composite_subject: Composite astrological subject
467            active_points: Points to include in calculations
468            active_aspects: Aspect types and orbs to use
469            distribution_method: Strategy for element/modality weighting
470            custom_distribution_weights: Optional overrides for distribution weights
471
472        Returns:
473            ChartDataModel: Composite chart data
474        """
475        return ChartDataFactory.create_chart_data(
476            first_subject=composite_subject,
477            chart_type="Composite",
478            active_points=active_points,
479            active_aspects=active_aspects,
480            distribution_method=distribution_method,
481            custom_distribution_weights=custom_distribution_weights,
482        )

Convenience method for creating composite chart data.

Args: composite_subject: Composite astrological subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Composite chart data

@staticmethod
def create_return_chart_data( natal_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, return_subject: PlanetReturnModel, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], include_house_comparison: bool = True, *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
484    @staticmethod
485    def create_return_chart_data(
486        natal_subject: AstrologicalSubjectModel,
487        return_subject: PlanetReturnModel,
488        active_points: Optional[List[AstrologicalPoint]] = None,
489        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
490        include_house_comparison: bool = True,
491        *,
492        distribution_method: ElementQualityDistributionMethod = "weighted",
493        custom_distribution_weights: Optional[Mapping[str, float]] = None,
494    ) -> ChartDataModel:
495        """
496        Convenience method for creating planetary return chart data.
497
498        Args:
499            natal_subject: Natal astrological subject
500            return_subject: Planetary return subject
501            active_points: Points to include in calculations
502            active_aspects: Aspect types and orbs to use
503            include_house_comparison: Whether to include house comparison
504            distribution_method: Strategy for element/modality weighting
505            custom_distribution_weights: Optional overrides for distribution weights
506
507        Returns:
508            ChartDataModel: Return chart data
509        """
510        return ChartDataFactory.create_chart_data(
511            first_subject=natal_subject,
512            chart_type="DualReturnChart",
513            second_subject=return_subject,
514            active_points=active_points,
515            active_aspects=active_aspects,
516            include_house_comparison=include_house_comparison,
517            distribution_method=distribution_method,
518            custom_distribution_weights=custom_distribution_weights,
519        )

Convenience method for creating planetary return chart data.

Args: natal_subject: Natal astrological subject return_subject: Planetary return subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use include_house_comparison: Whether to include house comparison distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Return chart data

@staticmethod
def create_single_wheel_return_chart_data( return_subject: PlanetReturnModel, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], *, distribution_method: Literal['pure_count', 'weighted'] = 'weighted', custom_distribution_weights: Optional[Mapping[str, float]] = None) -> Union[SingleChartDataModel, DualChartDataModel]:
521    @staticmethod
522    def create_single_wheel_return_chart_data(
523        return_subject: PlanetReturnModel,
524        active_points: Optional[List[AstrologicalPoint]] = None,
525        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
526        *,
527        distribution_method: ElementQualityDistributionMethod = "weighted",
528        custom_distribution_weights: Optional[Mapping[str, float]] = None,
529    ) -> ChartDataModel:
530        """
531        Convenience method for creating single wheel planetary return chart data.
532
533        Args:
534            return_subject: Planetary return subject
535            active_points: Points to include in calculations
536            active_aspects: Aspect types and orbs to use
537            distribution_method: Strategy for element/modality weighting
538            custom_distribution_weights: Optional overrides for distribution weights
539
540        Returns:
541            ChartDataModel: Single wheel return chart data
542        """
543        return ChartDataFactory.create_chart_data(
544            first_subject=return_subject,
545            chart_type="SingleReturnChart",
546            active_points=active_points,
547            active_aspects=active_aspects,
548            distribution_method=distribution_method,
549            custom_distribution_weights=custom_distribution_weights,
550        )

Convenience method for creating single wheel planetary return chart data.

Args: return_subject: Planetary return subject active_points: Points to include in calculations active_aspects: Aspect types and orbs to use distribution_method: Strategy for element/modality weighting custom_distribution_weights: Optional overrides for distribution weights

Returns: ChartDataModel: Single wheel return chart data

ChartDataModel = typing.Union[SingleChartDataModel, DualChartDataModel]
class SingleChartDataModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
525class SingleChartDataModel(SubscriptableBaseModel):
526    """
527    Chart data model for single-subject astrological charts.
528
529    This model contains all pure data from single-subject charts including planetary
530    positions, internal aspects, element/quality distributions, and location data.
531    Used for chart types that analyze a single astrological subject.
532
533    Supported chart types:
534    - Natal: Birth chart with internal planetary aspects
535    - Composite: Midpoint relationship chart with internal aspects
536    - SingleReturnChart: Single planetary return with internal aspects
537
538    Attributes:
539        chart_type: Type of single chart (Natal, Composite, SingleReturnChart)
540        subject: The astrological subject being analyzed
541        aspects: Internal aspects within the chart
542        element_distribution: Distribution of elemental energies
543        quality_distribution: Distribution of modal qualities
544        active_points: Celestial points included in calculations
545        active_aspects: Aspect types and orb settings used
546    """
547
548    # Chart identification
549    chart_type: Literal["Natal", "Composite", "SingleReturnChart"]
550
551    # Single chart subject
552    subject: Union["AstrologicalSubjectModel", "CompositeSubjectModel", "PlanetReturnModel"]
553
554    # Internal aspects analysis
555    aspects: List[AspectModel]
556
557    # Element and quality distributions
558    element_distribution: "ElementDistributionModel"
559    quality_distribution: "QualityDistributionModel"
560
561    # Configuration and metadata
562    active_points: List[AstrologicalPoint]
563    active_aspects: List["ActiveAspect"]

Chart data model for single-subject astrological charts.

This model contains all pure data from single-subject charts including planetary positions, internal aspects, element/quality distributions, and location data. Used for chart types that analyze a single astrological subject.

Supported chart types:

  • Natal: Birth chart with internal planetary aspects
  • Composite: Midpoint relationship chart with internal aspects
  • SingleReturnChart: Single planetary return with internal aspects

Attributes: chart_type: Type of single chart (Natal, Composite, SingleReturnChart) subject: The astrological subject being analyzed aspects: Internal aspects within the chart element_distribution: Distribution of elemental energies quality_distribution: Distribution of modal qualities active_points: Celestial points included in calculations active_aspects: Aspect types and orb settings used

chart_type: Literal['Natal', 'Composite', 'SingleReturnChart']
subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel]
aspects: List[kerykeion.schemas.kr_models.AspectModel]
element_distribution: ElementDistributionModel
quality_distribution: QualityDistributionModel
active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]
active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class DualChartDataModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
566class DualChartDataModel(SubscriptableBaseModel):
567    """
568    Chart data model for dual-subject astrological charts.
569
570    This model contains all pure data from dual-subject charts including both subjects,
571    inter-chart aspects, house comparisons, relationship analysis, and combined
572    element/quality distributions. Used for chart types that compare or overlay
573    two astrological subjects.
574
575    Supported chart types:
576    - Transit: Natal chart with current planetary transits
577    - Synastry: Relationship compatibility between two people
578    - Return: Natal chart with planetary return comparison
579
580    Attributes:
581        chart_type: Type of dual chart (Transit, Synastry, Return)
582        first_subject: Primary astrological subject (natal, base chart)
583        second_subject: Secondary astrological subject (transit, partner, return)
584        aspects: Inter-chart aspects between the two subjects
585        house_comparison: House overlay analysis between subjects
586        relationship_score: Compatibility scoring (synastry only)
587        element_distribution: Combined elemental distribution
588        quality_distribution: Combined modal distribution
589        active_points: Celestial points included in calculations
590        active_aspects: Aspect types and orb settings used
591    """
592
593    # Chart identification
594    chart_type: Literal["Transit", "Synastry", "DualReturnChart"]
595
596    # Dual chart subjects
597    first_subject: Union["AstrologicalSubjectModel", "CompositeSubjectModel", "PlanetReturnModel"]
598    second_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"]
599
600    # Inter-chart aspects analysis
601    aspects: List[AspectModel]
602
603    # House comparison analysis
604    house_comparison: Optional["HouseComparisonModel"] = None
605
606    # Relationship analysis (synastry only)
607    relationship_score: Optional["RelationshipScoreModel"] = None
608
609    # Combined element and quality distributions
610    element_distribution: "ElementDistributionModel"
611    quality_distribution: "QualityDistributionModel"
612
613    # Configuration and metadata
614    active_points: List[AstrologicalPoint]
615    active_aspects: List["ActiveAspect"]

Chart data model for dual-subject astrological charts.

This model contains all pure data from dual-subject charts including both subjects, inter-chart aspects, house comparisons, relationship analysis, and combined element/quality distributions. Used for chart types that compare or overlay two astrological subjects.

Supported chart types:

  • Transit: Natal chart with current planetary transits
  • Synastry: Relationship compatibility between two people
  • Return: Natal chart with planetary return comparison

Attributes: chart_type: Type of dual chart (Transit, Synastry, Return) first_subject: Primary astrological subject (natal, base chart) second_subject: Secondary astrological subject (transit, partner, return) aspects: Inter-chart aspects between the two subjects house_comparison: House overlay analysis between subjects relationship_score: Compatibility scoring (synastry only) element_distribution: Combined elemental distribution quality_distribution: Combined modal distribution active_points: Celestial points included in calculations active_aspects: Aspect types and orb settings used

chart_type: Literal['Transit', 'Synastry', 'DualReturnChart']
first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel]
second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel]
aspects: List[kerykeion.schemas.kr_models.AspectModel]
house_comparison: Optional[HouseComparisonModel]
relationship_score: Optional[kerykeion.schemas.kr_models.RelationshipScoreModel]
element_distribution: ElementDistributionModel
quality_distribution: QualityDistributionModel
active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]
active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ElementDistributionModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
481class ElementDistributionModel(SubscriptableBaseModel):
482    """
483    Model representing element distribution in a chart.
484
485    Attributes:
486        fire: Fire element points total
487        earth: Earth element points total
488        air: Air element points total
489        water: Water element points total
490        fire_percentage: Fire element percentage
491        earth_percentage: Earth element percentage
492        air_percentage: Air element percentage
493        water_percentage: Water element percentage
494    """
495    fire: float
496    earth: float
497    air: float
498    water: float
499    fire_percentage: int
500    earth_percentage: int
501    air_percentage: int
502    water_percentage: int

Model representing element distribution in a chart.

Attributes: fire: Fire element points total earth: Earth element points total air: Air element points total water: Water element points total fire_percentage: Fire element percentage earth_percentage: Earth element percentage air_percentage: Air element percentage water_percentage: Water element percentage

fire: float
earth: float
air: float
water: float
fire_percentage: int
earth_percentage: int
air_percentage: int
water_percentage: int
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class QualityDistributionModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
505class QualityDistributionModel(SubscriptableBaseModel):
506    """
507    Model representing quality distribution in a chart.
508
509    Attributes:
510        cardinal: Cardinal quality points total
511        fixed: Fixed quality points total
512        mutable: Mutable quality points total
513        cardinal_percentage: Cardinal quality percentage
514        fixed_percentage: Fixed quality percentage
515        mutable_percentage: Mutable quality percentage
516    """
517    cardinal: float
518    fixed: float
519    mutable: float
520    cardinal_percentage: int
521    fixed_percentage: int
522    mutable_percentage: int

Model representing quality distribution in a chart.

Attributes: cardinal: Cardinal quality points total fixed: Fixed quality points total mutable: Mutable quality points total cardinal_percentage: Cardinal quality percentage fixed_percentage: Fixed quality percentage mutable_percentage: Mutable quality percentage

cardinal: float
fixed: float
mutable: float
cardinal_percentage: int
fixed_percentage: int
mutable_percentage: int
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ChartDrawer:
  87class ChartDrawer:
  88    """
  89    ChartDrawer generates astrological chart visualizations as SVG files from pre-computed chart data.
  90
  91    This class is designed for pure visualization and requires chart data to be pre-computed using
  92    ChartDataFactory. This separation ensures clean architecture where ChartDataFactory handles
  93    all calculations (aspects, element/quality distributions, subjects) while ChartDrawer focuses
  94    solely on rendering SVG visualizations.
  95
  96    ChartDrawer supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
  97    for various chart types including Natal, Transit, Synastry, and Composite.
  98    Charts are rendered using XML templates and drawing utilities, with customizable themes,
  99    language, and visual settings.
 100
 101    The generated SVG files are optimized for web use and can be saved to any specified
 102    destination path using the save_svg method.
 103
 104    NOTE:
 105        The generated SVG files are optimized for web use, opening in browsers. If you want to
 106        use them in other applications, you might need to adjust the SVG settings or styles.
 107
 108    Args:
 109        chart_data (ChartDataModel):
 110            Pre-computed chart data from ChartDataFactory containing all subjects, aspects,
 111            element/quality distributions, and other analytical data. This is the ONLY source
 112            of chart information - no calculations are performed by ChartDrawer.
 113        theme (KerykeionChartTheme, optional):
 114            CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'.
 115        double_chart_aspect_grid_type (Literal['list', 'table'], optional):
 116            Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
 117        chart_language (KerykeionChartLanguage, optional):
 118            Language code for chart labels. Defaults to 'EN'.
 119        language_pack (dict | None, optional):
 120            Additional translations merged over the bundled defaults for the
 121            selected language. Useful to introduce new languages or override
 122            existing labels.
 123        transparent_background (bool, optional):
 124            Whether to use a transparent background instead of the theme color. Defaults to False.
 125
 126    Public Methods:
 127        makeTemplate(minify=False, remove_css_variables=False) -> str:
 128            Render the full chart SVG as a string without writing to disk. Use `minify=True`
 129            to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
 130
 131        save_svg(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
 132            Generate and write the full chart SVG file to the specified path.
 133            If output_path is None, saves to the user's home directory.
 134            If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart.svg'.
 135
 136        makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 137            Render only the chart wheel (no aspect grid) as an SVG string.
 138
 139        save_wheel_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
 140            Generate and write the wheel-only SVG file to the specified path.
 141            If output_path is None, saves to the user's home directory.
 142            If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
 143
 144        makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 145            Render only the aspect grid as an SVG string.
 146
 147        save_aspect_grid_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
 148            Generate and write the aspect-grid-only SVG file to the specified path.
 149            If output_path is None, saves to the user's home directory.
 150            If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
 151
 152    Example:
 153        >>> from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
 154        >>> from kerykeion.chart_data_factory import ChartDataFactory
 155        >>> from kerykeion.charts.chart_drawer import ChartDrawer
 156        >>>
 157        >>> # Step 1: Create subject
 158        >>> subject = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")
 159        >>>
 160        >>> # Step 2: Pre-compute chart data
 161        >>> chart_data = ChartDataFactory.create_natal_chart_data(subject)
 162        >>>
 163        >>> # Step 3: Create visualization
 164        >>> chart_drawer = ChartDrawer(chart_data=chart_data, theme="classic")
 165        >>> chart_drawer.save_svg()  # Saves to home directory with default filename
 166        >>> # Or specify custom path and filename:
 167        >>> chart_drawer.save_svg("/path/to/output/directory", "my_custom_chart")
 168    """
 169
 170    # Constants
 171
 172    _DEFAULT_HEIGHT = 550
 173    _DEFAULT_FULL_WIDTH = 1250
 174    _DEFAULT_SYNASTRY_WIDTH = 1570
 175    _DEFAULT_NATAL_WIDTH = 870
 176    _DEFAULT_FULL_WIDTH_WITH_TABLE = 1250
 177    _DEFAULT_ULTRA_WIDE_WIDTH = 1320
 178
 179    _VERTICAL_PADDING_TOP = 15
 180    _VERTICAL_PADDING_BOTTOM = 15
 181    _TITLE_SPACING = 8
 182
 183    _ASPECT_LIST_ASPECTS_PER_COLUMN = 14
 184    _ASPECT_LIST_COLUMN_WIDTH = 105
 185
 186    _BASE_VERTICAL_OFFSETS = {
 187        "wheel": 50,
 188        "grid": 0,
 189        "aspect_grid": 50,
 190        "aspect_list": 50,
 191        "title": 0,
 192        "elements": 0,
 193        "qualities": 0,
 194        "lunar_phase": 518,
 195        "bottom_left": 0,
 196    }
 197    _MAX_TOP_SHIFT = 80
 198    _TOP_SHIFT_FACTOR = 2
 199    _ROW_HEIGHT = 8
 200
 201    _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
 202    _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
 203    _ULTRA_WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_ULTRA_WIDE_WIDTH} 546.0"
 204    _TRANSIT_CHART_WITH_TABLE_VIWBOX = f"0 0 {_DEFAULT_FULL_WIDTH_WITH_TABLE} 546.0"
 205
 206    # Set at init
 207    first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
 208    second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
 209    chart_type: ChartType
 210    theme: Union[KerykeionChartTheme, None]
 211    double_chart_aspect_grid_type: Literal["list", "table"]
 212    chart_language: KerykeionChartLanguage
 213    active_points: List[AstrologicalPoint]
 214    active_aspects: List[ActiveAspect]
 215    transparent_background: bool
 216    external_view: bool
 217    show_house_position_comparison: bool
 218    custom_title: Union[str, None]
 219    _language_model: KerykeionLanguageModel
 220    _fallback_language_model: KerykeionLanguageModel
 221
 222    # Internal properties
 223    fire: float
 224    earth: float
 225    air: float
 226    water: float
 227    first_circle_radius: float
 228    second_circle_radius: float
 229    third_circle_radius: float
 230    width: Union[float, int]
 231    language_settings: dict
 232    chart_colors_settings: dict
 233    planets_settings: list[dict[Any, Any]]
 234    aspects_settings: list[dict[Any, Any]]
 235    available_planets_setting: List[KerykeionSettingsCelestialPointModel]
 236    height: float
 237    location: str
 238    geolat: float
 239    geolon: float
 240    template: str
 241
 242    def __init__(
 243        self,
 244        chart_data: "ChartDataModel",
 245        *,
 246        theme: Union[KerykeionChartTheme, None] = "classic",
 247        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
 248        chart_language: KerykeionChartLanguage = "EN",
 249        language_pack: Optional[Mapping[str, Any]] = None,
 250        external_view: bool = False,
 251        transparent_background: bool = False,
 252        colors_settings: dict = DEFAULT_CHART_COLORS,
 253        celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
 254        aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
 255        custom_title: Union[str, None] = None,
 256        show_house_position_comparison: bool = True,
 257        show_cusp_position_comparison: bool = False,
 258        auto_size: bool = True,
 259        padding: int = 20,
 260        show_degree_indicators: bool = True,
 261        show_aspect_icons: bool = True,
 262    ):
 263        """
 264        Initialize the chart visualizer with pre-computed chart data.
 265
 266        Args:
 267            chart_data (ChartDataModel):
 268                Pre-computed chart data from ChartDataFactory containing all subjects,
 269                aspects, element/quality distributions, and other analytical data.
 270            theme (KerykeionChartTheme or None, optional):
 271                CSS theme to apply; None for default styling.
 272            double_chart_aspect_grid_type (Literal['list','table'], optional):
 273                Layout style for double-chart aspect grids ('list' or 'table').
 274            chart_language (KerykeionChartLanguage, optional):
 275                Language code for chart labels (e.g., 'EN', 'IT').
 276            language_pack (dict | None, optional):
 277                Additional translations merged over the bundled defaults for the
 278                selected language. Useful to introduce new languages or override
 279                existing labels.
 280            external_view (bool, optional):
 281                Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False.
 282            transparent_background (bool, optional):
 283                Whether to use a transparent background instead of the theme color. Defaults to False.
 284            custom_title (str or None, optional):
 285                Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None.
 286            show_house_position_comparison (bool, optional):
 287                Whether to render the house position comparison grid (when supported by the chart type).
 288                Defaults to True. Set to False to hide the table and reclaim horizontal space.
 289            show_cusp_position_comparison (bool, optional):
 290                Whether to render the cusp position comparison grid alongside or in place of the house comparison.
 291                Defaults to False so cusp tables are only shown when explicitly requested.
 292        """
 293        # --------------------
 294        # COMMON INITIALIZATION
 295        # --------------------
 296        self.chart_language = chart_language
 297        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
 298        self.transparent_background = transparent_background
 299        self.external_view = external_view
 300        self.chart_colors_settings = deepcopy(colors_settings)
 301        self.planets_settings = [dict(body) for body in celestial_points_settings]
 302        self.aspects_settings = [dict(aspect) for aspect in aspects_settings]
 303        self.custom_title = custom_title
 304        self.show_house_position_comparison = show_house_position_comparison
 305        self.show_cusp_position_comparison = show_cusp_position_comparison
 306        self.show_degree_indicators = show_degree_indicators
 307        self.show_aspect_icons = show_aspect_icons
 308        self.auto_size = auto_size
 309        self._padding = padding
 310        self._vertical_offsets: dict[str, int] = self._BASE_VERTICAL_OFFSETS.copy()
 311
 312        # Extract data from ChartDataModel
 313        self.chart_data = chart_data
 314        self.chart_type = chart_data.chart_type
 315        self.active_points = chart_data.active_points
 316        self.active_aspects = chart_data.active_aspects
 317
 318        # Extract subjects based on chart type
 319        if chart_data.chart_type in ["Natal", "Composite", "SingleReturnChart"]:
 320            # SingleChartDataModel
 321            self.first_obj = getattr(chart_data, 'subject')
 322            self.second_obj = None
 323
 324        else:  # DualChartDataModel for Transit, Synastry, DualReturnChart
 325            self.first_obj = getattr(chart_data, 'first_subject')
 326            self.second_obj = getattr(chart_data, 'second_subject')
 327
 328        # Load settings
 329        self._load_language_settings(language_pack)
 330
 331        # Default radius for all charts
 332        self.main_radius = 240
 333
 334        # Configure available planets from chart data
 335        self.available_planets_setting = []
 336        for body in self.planets_settings:
 337            if body["name"] in self.active_points:
 338                body["is_active"] = True
 339                self.available_planets_setting.append(body)  # type: ignore[arg-type]
 340
 341        active_points_count = len(self.available_planets_setting)
 342        if active_points_count > 24:
 343            logger.warning(
 344                "ChartDrawer detected %s active celestial points; rendering may look crowded beyond 24.",
 345                active_points_count,
 346            )
 347
 348        # Set available celestial points
 349        available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
 350        self.available_kerykeion_celestial_points = self._collect_subject_points(
 351            self.first_obj,
 352            available_celestial_points_names,
 353        )
 354
 355        # Collect secondary subject points for dual charts using the same active set
 356        self.t_available_kerykeion_celestial_points: list[KerykeionPointModel] = []
 357        if self.second_obj is not None:
 358            self.t_available_kerykeion_celestial_points = self._collect_subject_points(
 359                self.second_obj,
 360                available_celestial_points_names,
 361            )
 362
 363        # ------------------------
 364        # CHART TYPE SPECIFIC SETUP FROM CHART DATA
 365        # ------------------------
 366
 367        if self.chart_type == "Natal":
 368            # --- NATAL CHART SETUP ---
 369
 370            # Extract aspects from pre-computed chart data
 371            self.aspects_list = chart_data.aspects
 372
 373            # Screen size
 374            self.height = self._DEFAULT_HEIGHT
 375            self.width = self._DEFAULT_NATAL_WIDTH
 376
 377            # Get location and coordinates
 378            self.location, self.geolat, self.geolon = self._get_location_info()
 379
 380            # Circle radii - depends on external_view
 381            if self.external_view:
 382                self.first_circle_radius = 56
 383                self.second_circle_radius = 92
 384                self.third_circle_radius = 112
 385            else:
 386                self.first_circle_radius = 0
 387                self.second_circle_radius = 36
 388                self.third_circle_radius = 120
 389
 390        elif self.chart_type == "Composite":
 391            # --- COMPOSITE CHART SETUP ---
 392
 393            # Extract aspects from pre-computed chart data
 394            self.aspects_list = chart_data.aspects
 395
 396            # Screen size
 397            self.height = self._DEFAULT_HEIGHT
 398            self.width = self._DEFAULT_NATAL_WIDTH
 399
 400            # Get location and coordinates
 401            self.location, self.geolat, self.geolon = self._get_location_info()
 402
 403            # Circle radii
 404            self.first_circle_radius = 0
 405            self.second_circle_radius = 36
 406            self.third_circle_radius = 120
 407
 408        elif self.chart_type == "Transit":
 409            # --- TRANSIT CHART SETUP ---
 410
 411            # Extract aspects from pre-computed chart data
 412            self.aspects_list = chart_data.aspects
 413
 414            # Screen size
 415            self.height = self._DEFAULT_HEIGHT
 416            if self.double_chart_aspect_grid_type == "table":
 417                self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
 418            else:
 419                self.width = self._DEFAULT_FULL_WIDTH
 420
 421            # Get location and coordinates
 422            self.location, self.geolat, self.geolon = self._get_location_info()
 423
 424            # Circle radii
 425            self.first_circle_radius = 0
 426            self.second_circle_radius = 36
 427            self.third_circle_radius = 120
 428
 429        elif self.chart_type == "Synastry":
 430            # --- SYNASTRY CHART SETUP ---
 431
 432            # Extract aspects from pre-computed chart data
 433            self.aspects_list = chart_data.aspects
 434
 435            # Screen size
 436            self.height = self._DEFAULT_HEIGHT
 437            self.width = self._DEFAULT_SYNASTRY_WIDTH
 438
 439            # Get location and coordinates
 440            self.location, self.geolat, self.geolon = self._get_location_info()
 441
 442            # Circle radii
 443            self.first_circle_radius = 0
 444            self.second_circle_radius = 36
 445            self.third_circle_radius = 120
 446
 447        elif self.chart_type == "DualReturnChart":
 448            # --- RETURN CHART SETUP ---
 449
 450            # Extract aspects from pre-computed chart data
 451            self.aspects_list = chart_data.aspects
 452
 453            # Screen size
 454            self.height = self._DEFAULT_HEIGHT
 455            self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
 456
 457            # Get location and coordinates
 458            self.location, self.geolat, self.geolon = self._get_location_info()
 459
 460            # Circle radii
 461            self.first_circle_radius = 0
 462            self.second_circle_radius = 36
 463            self.third_circle_radius = 120
 464
 465        elif self.chart_type == "SingleReturnChart":
 466            # --- SINGLE WHEEL RETURN CHART SETUP ---
 467
 468            # Extract aspects from pre-computed chart data
 469            self.aspects_list = chart_data.aspects
 470
 471            # Screen size
 472            self.height = self._DEFAULT_HEIGHT
 473            self.width = self._DEFAULT_NATAL_WIDTH
 474
 475            # Get location and coordinates
 476            self.location, self.geolat, self.geolon = self._get_location_info()
 477
 478            # Circle radii
 479            self.first_circle_radius = 0
 480            self.second_circle_radius = 36
 481            self.third_circle_radius = 120
 482
 483        self._apply_house_comparison_width_override()
 484
 485        # --------------------
 486        # FINAL COMMON SETUP FROM CHART DATA
 487        # --------------------
 488
 489        # Extract pre-computed element and quality distributions
 490        self.fire = chart_data.element_distribution.fire
 491        self.earth = chart_data.element_distribution.earth
 492        self.air = chart_data.element_distribution.air
 493        self.water = chart_data.element_distribution.water
 494
 495        self.cardinal = chart_data.quality_distribution.cardinal
 496        self.fixed = chart_data.quality_distribution.fixed
 497        self.mutable = chart_data.quality_distribution.mutable
 498
 499        # Set up theme
 500        if theme not in get_args(KerykeionChartTheme) and theme is not None:
 501            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
 502
 503        self.set_up_theme(theme)
 504
 505        self._apply_dynamic_height_adjustment()
 506        self._adjust_height_for_extended_aspect_columns()
 507        # Reconcile width with the updated layout once height adjustments are known.
 508        if self.auto_size:
 509            self._update_width_to_content()
 510
 511    def _count_active_planets(self) -> int:
 512        """Return number of active celestial points in the current chart."""
 513        return len([p for p in self.available_planets_setting if p.get("is_active")])
 514
 515    def _apply_dynamic_height_adjustment(self) -> None:
 516        """Adjust chart height and vertical offsets based on active points."""
 517        active_points_count = self._count_active_planets()
 518
 519        offsets = self._BASE_VERTICAL_OFFSETS.copy()
 520
 521        minimum_height = self._DEFAULT_HEIGHT
 522
 523        if self.chart_type == "Synastry":
 524            self._apply_synastry_height_adjustment(
 525                active_points_count=active_points_count,
 526                offsets=offsets,
 527                minimum_height=minimum_height,
 528            )
 529            return
 530
 531        if active_points_count <= 20:
 532            self.height = max(self.height, minimum_height)
 533            self._vertical_offsets = offsets
 534            return
 535
 536        extra_points = active_points_count - 20
 537        extra_height = extra_points * self._ROW_HEIGHT
 538
 539        self.height = max(self.height, minimum_height + extra_height)
 540
 541        delta_height = max(self.height - minimum_height, 0)
 542
 543        # Anchor wheel, aspect grid/list, and lunar phase to the bottom
 544        offsets["wheel"] += delta_height
 545        offsets["aspect_grid"] += delta_height
 546        offsets["aspect_list"] += delta_height
 547        offsets["lunar_phase"] += delta_height
 548        offsets["bottom_left"] += delta_height
 549
 550        # Smooth top offsets to keep breathing room near the title and grids
 551        shift = min(extra_points * self._TOP_SHIFT_FACTOR, self._MAX_TOP_SHIFT)
 552        top_shift = shift // 2
 553
 554        offsets["grid"] += shift
 555        offsets["title"] += top_shift
 556        offsets["elements"] += top_shift
 557        offsets["qualities"] += top_shift
 558
 559        self._vertical_offsets = offsets
 560
 561    def _adjust_height_for_extended_aspect_columns(self) -> None:
 562        """Ensure tall aspect columns fit within the SVG for double-chart lists."""
 563        if self.double_chart_aspect_grid_type != "list":
 564            return
 565
 566        if self.chart_type not in ("Synastry", "Transit", "DualReturnChart"):
 567            return
 568
 569        total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
 570        if total_aspects == 0:
 571            return
 572
 573        aspects_per_column = 14
 574        extended_column_start = 11  # Zero-based column index where tall columns begin
 575        base_capacity = aspects_per_column * extended_column_start
 576
 577        if total_aspects <= base_capacity:
 578            return
 579
 580        translate_y = 273
 581        bottom_padding = 40
 582        title_clearance = 18
 583        line_height = 14
 584        baseline_index = aspects_per_column - 1
 585        top_limit_index = ceil((-translate_y + title_clearance) / line_height)
 586        max_capacity_by_top = baseline_index - top_limit_index + 1
 587
 588        if max_capacity_by_top <= aspects_per_column:
 589            return
 590
 591        target_capacity = max_capacity_by_top
 592        required_available_height = target_capacity * line_height
 593        required_height = translate_y + bottom_padding + required_available_height
 594
 595        if required_height <= self.height:
 596            return
 597
 598        delta = required_height - self.height
 599        self.height = required_height
 600
 601        offsets = self._vertical_offsets
 602        # Keep bottom-anchored groups aligned after changing the overall height.
 603        offsets["wheel"] += delta
 604        offsets["aspect_grid"] += delta
 605        offsets["aspect_list"] += delta
 606        offsets["lunar_phase"] += delta
 607        offsets["bottom_left"] += delta
 608        self._vertical_offsets = offsets
 609
 610    def _apply_synastry_height_adjustment(
 611        self,
 612        *,
 613        active_points_count: int,
 614        offsets: dict[str, int],
 615        minimum_height: int,
 616    ) -> None:
 617        """Specialised dynamic height handling for Synastry charts.
 618
 619        With the planet grids locked to a single column, every additional active
 620        point extends multiple tables vertically (planets, houses, comparisons).
 621        We therefore scale the height using the actual line spacing used by those
 622        tables (≈14px) and keep the bottom anchored elements aligned.
 623        """
 624        base_rows = 14  # Up to 16 active points fit without extra height
 625        extra_rows = max(active_points_count - base_rows, 0)
 626
 627        synastry_row_height = 15
 628        comparison_padding_per_row = 4  # Keeps house comparison grids within view.
 629        extra_height = extra_rows * (synastry_row_height + comparison_padding_per_row)
 630
 631        self.height = max(self.height, minimum_height + extra_height)
 632
 633        delta_height = max(self.height - minimum_height, 0)
 634
 635        # Move title up for synastry charts
 636        offsets["title"] = -10
 637
 638        offsets["wheel"] += delta_height
 639        offsets["aspect_grid"] += delta_height
 640        offsets["aspect_list"] += delta_height
 641        offsets["lunar_phase"] += delta_height
 642        offsets["bottom_left"] += delta_height
 643
 644        row_height_ratio = synastry_row_height / max(self._ROW_HEIGHT, 1)
 645        synastry_top_shift_factor = max(
 646            self._TOP_SHIFT_FACTOR,
 647            int(ceil(self._TOP_SHIFT_FACTOR * row_height_ratio)),
 648        )
 649        shift = min(extra_rows * synastry_top_shift_factor, self._MAX_TOP_SHIFT)
 650
 651        base_grid_padding = 36
 652        grid_padding_per_row = 6
 653        base_header_padding = 12
 654        header_padding_per_row = 4
 655        min_title_to_grid_gap = 36
 656
 657        grid_shift = shift + base_grid_padding + (extra_rows * grid_padding_per_row)
 658        grid_shift = min(grid_shift, shift + self._MAX_TOP_SHIFT)
 659
 660        top_shift = (shift // 2) + base_header_padding + (extra_rows * header_padding_per_row)
 661
 662        max_allowed_shift = shift + self._MAX_TOP_SHIFT
 663        missing_gap = min_title_to_grid_gap - (grid_shift - top_shift)
 664        grid_shift = min(grid_shift + missing_gap, max_allowed_shift)
 665        if grid_shift - top_shift < min_title_to_grid_gap:
 666            top_shift = max(0, grid_shift - min_title_to_grid_gap)
 667
 668        offsets["grid"] += grid_shift
 669        offsets["title"] += top_shift
 670        offsets["elements"] += top_shift
 671        offsets["qualities"] += top_shift
 672
 673        self._vertical_offsets = offsets
 674
 675    def _collect_subject_points(
 676        self,
 677        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
 678        point_attribute_names: list[str],
 679    ) -> list[KerykeionPointModel]:
 680        """Collect ordered active celestial points for a subject."""
 681
 682        collected: list[KerykeionPointModel] = []
 683
 684        for raw_name in point_attribute_names:
 685            attr_name = raw_name if hasattr(subject, raw_name) else raw_name.lower()
 686            point = getattr(subject, attr_name, None)
 687            if point is None:
 688                continue
 689            collected.append(point)
 690
 691        return collected
 692
 693    def _apply_house_comparison_width_override(self) -> None:
 694        """Shrink chart width when the optional house comparison grid is hidden."""
 695        if self.show_house_position_comparison or self.show_cusp_position_comparison:
 696            return
 697
 698        if self.chart_type == "Synastry":
 699            self.width = self._DEFAULT_FULL_WIDTH
 700        elif self.chart_type == "DualReturnChart":
 701            self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE if self.double_chart_aspect_grid_type == "table" else self._DEFAULT_FULL_WIDTH
 702        elif self.chart_type == "Transit":
 703            # Transit charts already use the compact width unless the aspect grid table is requested.
 704            self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE if self.double_chart_aspect_grid_type == "table" else self._DEFAULT_FULL_WIDTH
 705
 706    def _dynamic_viewbox(self) -> str:
 707        """Return the viewBox string based on current width/height with vertical padding."""
 708        min_y = -self._VERTICAL_PADDING_TOP
 709        viewbox_height = int(self.height) + self._VERTICAL_PADDING_TOP + self._VERTICAL_PADDING_BOTTOM
 710        return f"0 {min_y} {int(self.width)} {viewbox_height}"
 711
 712    def _wheel_only_viewbox(self, margin: int = 25) -> str:
 713        """Return a tight viewBox for the wheel-only template.
 714
 715        The wheel is drawn inside a group translated by (100, 50) and has
 716        diameter 2 * main_radius. We add a small margin around it.
 717        """
 718        left = 100 - margin
 719        top = 50 - margin
 720        width = (2 * self.main_radius) + (2 * margin)
 721        height = (2 * self.main_radius) + (2 * margin)
 722        return f"{left} {top} {width} {height}"
 723
 724    def _grid_only_viewbox(self, margin: int = 10) -> str:
 725        """Compute a tight viewBox for the Aspect Grid Only SVG.
 726
 727        The grid is rendered using fixed origins and box size:
 728        - For Transit/Synastry/DualReturn charts, `draw_transit_aspect_grid`
 729          uses `x_indent=50`, `y_indent=250`, `box_size=14` and draws:
 730            • a header row to the right of `x_indent`
 731            • a left header column at `x_indent - box_size`
 732            • an NxN grid of cells above `y_indent`
 733
 734        - For Natal/Composite/SingleReturn charts, `draw_aspect_grid` uses
 735          `x_start=50`, `y_start=250`, `box_size=14` and draws a triangular grid
 736          that extends to the right (x) and upwards (y).
 737
 738        This function mirrors that geometry to return a snug viewBox around the
 739        content, with a small configurable `margin`.
 740
 741        Args:
 742            margin: Extra pixels to add on each side of the computed bounds.
 743
 744        Returns:
 745            A string "minX minY width height" suitable for the SVG `viewBox`.
 746        """
 747        # Must match defaults used in the renderers
 748        x0 = 50
 749        y0 = 250
 750        box = 14
 751
 752        n = max(len([p for p in self.available_planets_setting if p.get("is_active")]), 1)
 753
 754        if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
 755            # Full N×N grid
 756            left = (x0 - box) - margin
 757            top = (y0 - box * n) - margin
 758            right = (x0 + box * n) + margin
 759            bottom = (y0 + box) + margin
 760        else:
 761            # Triangular grid (no extra left column)
 762            left = x0 - margin
 763            top = (y0 - box * n) - margin
 764            right = (x0 + box * n) + margin
 765            bottom = (y0 + box) + margin
 766
 767        width = max(1, int(right - left))
 768        height = max(1, int(bottom - top))
 769
 770        return f"{int(left)} {int(top)} {width} {height}"
 771
 772    def _estimate_required_width_full(self) -> int:
 773        """Estimate minimal width to contain all rendered groups for the full chart.
 774
 775        The calculation is heuristic and mirrors the default x positions used in
 776        the SVG templates and drawing utilities. We keep a conservative padding.
 777        """
 778        # Wheel footprint (translate(100,50) + diameter of 2*radius)
 779        wheel_right = 100 + (2 * self.main_radius)
 780        extents = [wheel_right]
 781
 782        n_active = max(self._count_active_planets(), 1)
 783
 784        # Common grids present on many chart types
 785        main_planet_grid_right = 645 + 80
 786        main_houses_grid_right = 750 + 120
 787        extents.extend([main_planet_grid_right, main_houses_grid_right])
 788
 789        if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
 790            # Triangular aspect grid at x_start=540, width ~ 14 * n_active
 791            aspect_grid_right = 560 + 14 * n_active
 792            extents.append(aspect_grid_right)
 793
 794        if self.chart_type in ("Transit", "Synastry", "DualReturnChart"):
 795            # Double-chart aspects placement
 796            if self.double_chart_aspect_grid_type == "list":
 797                total_aspects = len(self.aspects_list) if hasattr(self, "aspects_list") else 0
 798                columns = self._calculate_double_chart_aspect_columns(total_aspects, self.height)
 799                columns = max(columns, 1)
 800                aspect_list_right = 565 + (columns * self._ASPECT_LIST_COLUMN_WIDTH)
 801                extents.append(aspect_list_right)
 802            else:
 803                # Grid table placed with x_indent ~550, width ~ 14px per cell across n_active+1
 804                aspect_grid_table_right = 550 + (14 * (n_active + 1))
 805                extents.append(aspect_grid_table_right)
 806
 807            # Secondary grids
 808            secondary_planet_grid_right = 910 + 80
 809            extents.append(secondary_planet_grid_right)
 810
 811            if self.chart_type == "Synastry":
 812                # Secondary houses grid default x ~ 1015
 813                secondary_houses_grid_right = 1015 + 120
 814                extents.append(secondary_houses_grid_right)
 815                if (self.show_house_position_comparison or self.show_cusp_position_comparison) and self.second_obj is not None:
 816                    point_column_label = self._translate("point", "Point")
 817                    first_subject_label = self._truncate_name(self.first_obj.name, 8, "…", True)  # type: ignore[union-attr]
 818                    second_subject_label = self._truncate_name(self.second_obj.name, 8, "…", True)  # type: ignore[union-attr]
 819
 820                    first_columns = [
 821                        f"{first_subject_label} {point_column_label}",
 822                        first_subject_label,
 823                        second_subject_label,
 824                    ]
 825                    second_columns = [
 826                        f"{second_subject_label} {point_column_label}",
 827                        second_subject_label,
 828                        first_subject_label,
 829                    ]
 830
 831                    first_grid_width = self._estimate_house_comparison_grid_width(
 832                        column_labels=first_columns,
 833                        include_radix_column=True,
 834                        include_title=True,
 835                    )
 836                    second_grid_width = self._estimate_house_comparison_grid_width(
 837                        column_labels=second_columns,
 838                        include_radix_column=True,
 839                        include_title=False,
 840                    )
 841
 842                    first_house_comparison_grid_right = 1090 + first_grid_width
 843                    second_house_comparison_grid_right = 1290 + second_grid_width
 844                    extents.extend([first_house_comparison_grid_right, second_house_comparison_grid_right])
 845
 846                    if self.show_cusp_position_comparison:
 847                        # Cusp comparison block positioned to the right of both point grids.
 848                        # In Synastry we render two cusp grids side by side; reserve
 849                        # enough horizontal space for both tables plus a small gap.
 850                        max_house_comparison_right = max(
 851                            first_house_comparison_grid_right,
 852                            second_house_comparison_grid_right,
 853                        )
 854                        cusp_grid_width = 160.0
 855                        inter_cusp_gap = 0.0
 856                        cusp_block_width = (cusp_grid_width * 2.0) + inter_cusp_gap
 857                        # Place cusp block slightly to the right of the house comparison tables
 858                        # and ensure the overall SVG width comfortably contains it, including
 859                        # the rightmost text of the second cusp table.
 860                        extra_cusp_margin = 45.0
 861                        cusp_block_right = max_house_comparison_right + 50.0 + cusp_block_width + extra_cusp_margin
 862                        extents.append(cusp_block_right)
 863
 864            if self.chart_type == "Transit":
 865                # House comparison grid at x ~ 1030
 866                if self.show_house_position_comparison or self.show_cusp_position_comparison:
 867                    transit_columns = [
 868                        self._translate("transit_point", "Transit Point"),
 869                        self._translate("house_position", "Natal House"),
 870                    ]
 871                    transit_grid_width = self._estimate_house_comparison_grid_width(
 872                        column_labels=transit_columns,
 873                        include_radix_column=False,
 874                        include_title=True,
 875                        minimum_width=170.0,
 876                    )
 877                    house_comparison_grid_right = 980 + transit_grid_width
 878
 879                    if self.show_house_position_comparison:
 880                        # Classic layout: house comparison grid at x=980
 881                        extents.append(house_comparison_grid_right)
 882
 883                    if self.show_cusp_position_comparison:
 884                        if self.show_house_position_comparison:
 885                            # Both grids visible: cusp table rendered to the right
 886                            cusp_block_width = 260.0
 887                            cusp_block_right = house_comparison_grid_right + 40.0 + cusp_block_width
 888                            extents.append(cusp_block_right)
 889                        else:
 890                            # Cusp-only: cusp table occupies the house grid slot at x=980
 891                            cusp_only_right = house_comparison_grid_right
 892                            extents.append(cusp_only_right)
 893
 894            if self.chart_type == "DualReturnChart":
 895                # House and cusp comparison grids laid out similarly to Synastry.
 896                if self.show_house_position_comparison or self.show_cusp_position_comparison:
 897                    # Use localized labels for the natal subject and the return.
 898                    first_subject_label = self._translate("Natal", "Natal")
 899                    if self.second_obj is not None and hasattr(self.second_obj, "return_type") and self.second_obj.return_type == "Solar":
 900                        second_subject_label = self._translate("solar_return", "Solar Return")
 901                    else:
 902                        second_subject_label = self._translate("lunar_return", "Lunar Return")
 903                    point_column_label = self._translate("point", "Point")
 904
 905                    first_columns = [
 906                        f"{first_subject_label} {point_column_label}",
 907                        first_subject_label,
 908                        second_subject_label,
 909                    ]
 910                    second_columns = [
 911                        f"{second_subject_label} {point_column_label}",
 912                        second_subject_label,
 913                        first_subject_label,
 914                    ]
 915
 916                    first_grid_width = self._estimate_house_comparison_grid_width(
 917                        column_labels=first_columns,
 918                        include_radix_column=True,
 919                        include_title=True,
 920                    )
 921                    second_grid_width = self._estimate_house_comparison_grid_width(
 922                        column_labels=second_columns,
 923                        include_radix_column=True,
 924                        include_title=False,
 925                    )
 926
 927                    first_house_comparison_grid_right = 1090 + first_grid_width
 928                    second_house_comparison_grid_right = 1290 + second_grid_width
 929                    extents.extend([first_house_comparison_grid_right, second_house_comparison_grid_right])
 930
 931                    if self.show_cusp_position_comparison:
 932                        # Cusp comparison block positioned to the right of both house grids.
 933                        max_house_comparison_right = max(
 934                            first_house_comparison_grid_right,
 935                            second_house_comparison_grid_right,
 936                        )
 937                        cusp_grid_width = 160.0
 938                        inter_cusp_gap = 0.0
 939                        cusp_block_width = (cusp_grid_width * 2.0) + inter_cusp_gap
 940                        extra_cusp_margin = 45.0
 941                        cusp_block_right = max_house_comparison_right + 50.0 + cusp_block_width + extra_cusp_margin
 942                        extents.append(cusp_block_right)
 943
 944        # Conservative safety padding
 945        return int(max(extents) + self._padding)
 946
 947    def _calculate_double_chart_aspect_columns(
 948        self,
 949        total_aspects: int,
 950        chart_height: Optional[int],
 951    ) -> int:
 952        """Return how many columns the double-chart aspect list needs.
 953
 954        The first 11 columns follow the legacy 14-rows layout. Starting from the
 955        12th column we can fit more rows thanks to the taller chart height that
 956        gets computed earlier, so we re-use the same capacity as the SVG builder.
 957        """
 958        if total_aspects <= 0:
 959            return 0
 960
 961        per_column = self._ASPECT_LIST_ASPECTS_PER_COLUMN
 962        extended_start = 10  # 0-based index where tall columns begin
 963        base_capacity = per_column * extended_start
 964
 965        full_height_capacity = self._calculate_full_height_column_capacity(chart_height)
 966
 967        if total_aspects <= base_capacity:
 968            return ceil(total_aspects / per_column)
 969
 970        remaining = max(total_aspects - base_capacity, 0)
 971        extra_columns = ceil(remaining / full_height_capacity) if remaining > 0 else 0
 972        return extended_start + extra_columns
 973
 974    def _calculate_full_height_column_capacity(
 975        self,
 976        chart_height: Optional[int],
 977    ) -> int:
 978        """Compute the row capacity for columns that use the tall layout."""
 979        per_column = self._ASPECT_LIST_ASPECTS_PER_COLUMN
 980
 981        if chart_height is None:
 982            return per_column
 983
 984        translate_y = 273
 985        bottom_padding = 40
 986        title_clearance = 18
 987        line_height = 14
 988        baseline_index = per_column - 1
 989        top_limit_index = ceil((-translate_y + title_clearance) / line_height)
 990        max_capacity_by_top = baseline_index - top_limit_index + 1
 991
 992        available_height = max(chart_height - translate_y - bottom_padding, line_height)
 993        allowed_capacity = max(per_column, int(available_height // line_height))
 994
 995        # Respect both the physical height of the SVG and the visual limit
 996        # imposed by the title area.
 997        return max(per_column, min(allowed_capacity, max_capacity_by_top))
 998
 999    def _estimate_text_width(self, text: str, font_size: float = 12) -> float:
1000        """Very rough text width estimation in pixels based on font size."""
1001        if not text:
1002            return 0.0
1003        average_char_width = float(font_size) * 0.7
1004        return max(float(font_size), len(text) * average_char_width)
1005
1006    def _get_active_point_display_names(self) -> list[str]:
1007        """Return localized labels for the currently active celestial points."""
1008        language_map = {}
1009        fallback_map = {}
1010
1011        if hasattr(self, "_language_model"):
1012            language_map = self._language_model.celestial_points.model_dump()
1013        if hasattr(self, "_fallback_language_model"):
1014            fallback_map = self._fallback_language_model.celestial_points.model_dump()
1015
1016        display_names: list[str] = []
1017        for point in self.active_points:
1018            key = str(point)
1019            label = language_map.get(key) or fallback_map.get(key) or key
1020            display_names.append(str(label))
1021        return display_names
1022
1023    def _estimate_house_comparison_grid_width(
1024        self,
1025        *,
1026        column_labels: Sequence[str],
1027        include_radix_column: bool,
1028        include_title: bool,
1029        minimum_width: float = 250.0,
1030    ) -> int:
1031        """
1032        Approximate the rendered width for a house comparison grid in the current locale.
1033
1034        Args:
1035            column_labels: Ordered labels for the header row.
1036            include_radix_column: Whether a third numeric column is rendered.
1037            include_title: Include the localized title in the width estimation.
1038            minimum_width: Absolute lower bound to prevent extreme shrinking.
1039        """
1040        font_size_body = 10
1041        font_size_title = 14
1042        minimum_grid_width = float(minimum_width)
1043
1044        active_names = self._get_active_point_display_names()
1045        max_name_width = max(
1046            (self._estimate_text_width(name, font_size_body) for name in active_names),
1047            default=self._estimate_text_width("Sun", font_size_body),
1048        )
1049        width_candidates: list[float] = []
1050
1051        name_start = 15
1052        width_candidates.append(name_start + max_name_width)
1053
1054        value_offsets = [90]
1055        if include_radix_column:
1056            value_offsets.append(140)
1057        value_samples = ("12", "-", "0")
1058        max_value_width = max((self._estimate_text_width(sample, font_size_body) for sample in value_samples))
1059        for offset in value_offsets:
1060            width_candidates.append(offset + max_value_width)
1061
1062        header_offsets = [0, 77]
1063        if include_radix_column:
1064            header_offsets.append(132)
1065        for idx, offset in enumerate(header_offsets):
1066            label = column_labels[idx] if idx < len(column_labels) else ""
1067            if not label:
1068                continue
1069            width_candidates.append(offset + self._estimate_text_width(label, font_size_body))
1070
1071        if include_title:
1072            title_label = self._translate("house_position_comparison", "House Position Comparison")
1073            width_candidates.append(self._estimate_text_width(title_label, font_size_title))
1074
1075        grid_width = max(width_candidates, default=minimum_grid_width)
1076        return int(max(grid_width, minimum_grid_width))
1077
1078    def _minimum_width_for_chart_type(self) -> int:
1079        """Baseline width to avoid compressing core groups too tightly."""
1080        wheel_right = 100 + (2 * self.main_radius)
1081        baseline = wheel_right + self._padding
1082
1083        if self.chart_type in ("Natal", "Composite", "SingleReturnChart"):
1084            return max(int(baseline), self._DEFAULT_NATAL_WIDTH)
1085        if self.chart_type == "Synastry":
1086            return max(int(baseline), self._DEFAULT_SYNASTRY_WIDTH // 2)
1087        if self.chart_type == "DualReturnChart":
1088            return max(int(baseline), self._DEFAULT_ULTRA_WIDE_WIDTH // 2)
1089        if self.chart_type == "Transit":
1090            return max(int(baseline), 450)
1091        return max(int(baseline), self._DEFAULT_NATAL_WIDTH)
1092
1093    def _update_width_to_content(self) -> None:
1094        """Resize the chart width so the farthest element fits comfortably."""
1095        try:
1096            required_width = self._estimate_required_width_full()
1097        except Exception as e:
1098            logger.debug("Auto-size width calculation failed: %s", e)
1099            return
1100
1101        minimum_width = self._minimum_width_for_chart_type()
1102        self.width = max(required_width, minimum_width)
1103
1104    def _get_location_info(self) -> tuple[str, float, float]:
1105        """
1106        Determine location information based on chart type and subjects.
1107
1108        Returns:
1109            tuple: (location_name, latitude, longitude)
1110        """
1111        if self.chart_type == "Composite":
1112            # For composite charts, use average location of the two composite subjects
1113            if isinstance(self.first_obj, CompositeSubjectModel):
1114                location_name = ""
1115                latitude = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
1116                longitude = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
1117            else:
1118                # Fallback to first subject location
1119                location_name = self.first_obj.city or "Unknown"
1120                latitude = self.first_obj.lat or 0.0
1121                longitude = self.first_obj.lng or 0.0
1122        elif self.chart_type in ["Transit", "DualReturnChart"] and self.second_obj:
1123            # Use location from the second subject (transit/return)
1124            location_name = self.second_obj.city or "Unknown"
1125            latitude = self.second_obj.lat or 0.0
1126            longitude = self.second_obj.lng or 0.0
1127        else:
1128            # Use location from the first subject
1129            location_name = self.first_obj.city or "Unknown"
1130            latitude = self.first_obj.lat or 0.0
1131            longitude = self.first_obj.lng or 0.0
1132
1133        return location_name, latitude, longitude
1134
1135    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
1136        """
1137        Load and apply a CSS theme for the chart visualization.
1138
1139        Args:
1140            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
1141        """
1142        if theme is None:
1143            self.color_style_tag = ""
1144            return
1145
1146        theme_dir = Path(__file__).parent / "themes"
1147
1148        with open(theme_dir / f"{theme}.css", "r") as f:
1149            self.color_style_tag = f.read()
1150
1151    def _load_language_settings(
1152        self,
1153        language_pack: Optional[Mapping[str, Any]],
1154    ) -> None:
1155        """Resolve language models for the requested chart language."""
1156        overrides = {self.chart_language: dict(language_pack)} if language_pack else None
1157        languages = load_language_settings(overrides)
1158
1159        fallback_data = languages.get("EN")
1160        if fallback_data is None:
1161            raise KerykeionException("English translations are missing from LANGUAGE_SETTINGS.")
1162
1163        base_data = languages.get(self.chart_language, fallback_data)
1164        selected_model = KerykeionLanguageModel(**base_data)
1165        fallback_model = KerykeionLanguageModel(**fallback_data)
1166
1167        self._fallback_language_model = fallback_model
1168        self._language_model = selected_model
1169        self._fallback_language_dict = fallback_model.model_dump()
1170        self._language_dict = selected_model.model_dump()
1171        self.language_settings = self._language_dict  # Backward compatibility
1172
1173    def _translate(self, key: str, default: Any) -> Any:
1174        fallback_value = get_translations(key, default, language_dict=self._fallback_language_dict)
1175        return get_translations(key, fallback_value, language_dict=self._language_dict)
1176
1177    def _draw_zodiac_circle_slices(self, r):
1178        """
1179        Draw zodiac circle slices for each sign.
1180
1181        Args:
1182            r (float): Outer radius of the zodiac ring.
1183
1184        Returns:
1185            str: Concatenated SVG elements for zodiac slices.
1186        """
1187        sings = get_args(Sign)
1188        output = ""
1189        for i, sing in enumerate(sings):
1190            output += draw_zodiac_slice(
1191                c1=self.first_circle_radius,
1192                chart_type=self.chart_type,
1193                seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1194                num=i,
1195                r=r,
1196                style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;',
1197                type=sing,
1198            )
1199
1200        return output
1201
1202    def _draw_all_aspects_lines(self, r, ar):
1203        """
1204        Render SVG lines for all aspects in the chart.
1205
1206        Args:
1207            r (float): Radius at which aspect lines originate.
1208            ar (float): Radius at which aspect lines terminate.
1209
1210        Returns:
1211            str: SVG markup for all aspect lines.
1212        """
1213        out = ""
1214        # Track rendered icon positions (x, y, aspect_degrees) to avoid overlapping symbols of same type
1215        rendered_icon_positions: list[tuple[float, float, int]] = []
1216        for aspect in self.aspects_list:
1217            aspect_name = aspect["aspect"]
1218            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
1219            if aspect_color:
1220                out += draw_aspect_line(
1221                    r=r,
1222                    ar=ar,
1223                    aspect=aspect,
1224                    color=aspect_color,
1225                    seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1226                    show_aspect_icon=self.show_aspect_icons,
1227                    rendered_icon_positions=rendered_icon_positions,
1228                )
1229        return out
1230
1231    def _draw_all_transit_aspects_lines(self, r, ar):
1232        """
1233        Render SVG lines for all transit aspects in the chart.
1234
1235        Args:
1236            r (float): Radius at which transit aspect lines originate.
1237            ar (float): Radius at which transit aspect lines terminate.
1238
1239        Returns:
1240            str: SVG markup for all transit aspect lines.
1241        """
1242        out = ""
1243        # Track rendered icon positions (x, y, aspect_degrees) to avoid overlapping symbols of same type
1244        rendered_icon_positions: list[tuple[float, float, int]] = []
1245        for aspect in self.aspects_list:
1246            aspect_name = aspect["aspect"]
1247            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
1248            if aspect_color:
1249                out += draw_aspect_line(
1250                    r=r,
1251                    ar=ar,
1252                    aspect=aspect,
1253                    color=aspect_color,
1254                    seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1255                    show_aspect_icon=self.show_aspect_icons,
1256                    rendered_icon_positions=rendered_icon_positions,
1257                )
1258        return out
1259
1260    def _truncate_name(self, name: str, max_length: int = 50, ellipsis_symbol: str = "…", truncate_at_space: bool = False) -> str:
1261        """
1262        Truncate a name if it's too long, preserving readability.
1263
1264        Args:
1265            name (str): The name to truncate
1266            max_length (int): Maximum allowed length
1267
1268        Returns:
1269            str: Truncated name with ellipsis if needed
1270        """
1271        if truncate_at_space:
1272            name = name.split(" ")[0]
1273
1274        if len(name) <= max_length:
1275            return name
1276
1277        return name[:max_length-1] + ellipsis_symbol
1278
1279    def _get_chart_title(self, custom_title_override: Union[str, None] = None) -> str:
1280        """
1281        Generate the chart title based on chart type and custom title settings.
1282
1283        If a custom title is provided, it will be used. Otherwise, generates the
1284        appropriate default title based on the chart type and subjects.
1285
1286        Args:
1287            custom_title_override (str | None): Explicit override supplied at render time.
1288
1289        Returns:
1290            str: The chart title to display (max ~40 characters).
1291        """
1292        # If a kwarg override is provided, use it
1293        if custom_title_override is not None:
1294            return custom_title_override
1295
1296        # If custom title is provided at initialization, use it
1297        if self.custom_title is not None:
1298            return self.custom_title
1299
1300        # Generate default title based on chart type
1301        if self.chart_type == "Natal":
1302            natal_label = self._translate("birth_chart", "Natal")
1303            truncated_name = self._truncate_name(self.first_obj.name)
1304            return f'{truncated_name} - {natal_label}'
1305
1306        elif self.chart_type == "Composite":
1307            composite_label = self._translate("composite_chart", "Composite")
1308            and_word = self._translate("and_word", "&")
1309            name1 = self._truncate_name(self.first_obj.first_subject.name) # type: ignore
1310            name2 = self._truncate_name(self.first_obj.second_subject.name) # type: ignore
1311            return f"{composite_label}: {name1} {and_word} {name2}"
1312
1313        elif self.chart_type == "Transit":
1314            transit_label = self._translate("transits", "Transits")
1315            date_obj = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime) # type: ignore
1316            date_str = date_obj.strftime("%Y-%m-%d")
1317            truncated_name = self._truncate_name(self.first_obj.name)
1318            return f"{truncated_name} - {transit_label} {date_str}"
1319
1320        elif self.chart_type == "Synastry":
1321            synastry_label = self._translate("synastry_chart", "Synastry")
1322            and_word = self._translate("and_word", "&")
1323            name1 = self._truncate_name(self.first_obj.name)
1324            name2 = self._truncate_name(self.second_obj.name) # type: ignore
1325            return f"{synastry_label}: {name1} {and_word} {name2}"
1326
1327        elif self.chart_type == "DualReturnChart":
1328            return_datetime = datetime.fromisoformat(self.second_obj.iso_formatted_local_datetime) # type: ignore
1329            year = return_datetime.year
1330            month_year = return_datetime.strftime("%Y-%m")
1331            truncated_name = self._truncate_name(self.first_obj.name)
1332            if self.second_obj is not None and isinstance(self.second_obj, PlanetReturnModel) and self.second_obj.return_type == "Solar":
1333                solar_label = self._translate("solar_return", "Solar")
1334                return f"{truncated_name} - {solar_label} {year}"
1335            else:
1336                lunar_label = self._translate("lunar_return", "Lunar")
1337                return f"{truncated_name} - {lunar_label} {month_year}"
1338
1339        elif self.chart_type == "SingleReturnChart":
1340            return_datetime = datetime.fromisoformat(self.first_obj.iso_formatted_local_datetime) # type: ignore
1341            year = return_datetime.year
1342            month_year = return_datetime.strftime("%Y-%m")
1343            truncated_name = self._truncate_name(self.first_obj.name)
1344            if isinstance(self.first_obj, PlanetReturnModel) and self.first_obj.return_type == "Solar":
1345                solar_label = self._translate("solar_return", "Solar")
1346                return f"{truncated_name} - {solar_label} {year}"
1347            else:
1348                lunar_label = self._translate("lunar_return", "Lunar")
1349                return f"{truncated_name} - {lunar_label} {month_year}"
1350
1351        # Fallback for unknown chart types
1352        return self._truncate_name(self.first_obj.name)
1353
1354    def _create_template_dictionary(self, *, custom_title: Union[str, None] = None) -> ChartTemplateModel:
1355        """
1356        Assemble chart data and rendering instructions into a template dictionary.
1357
1358        Gathers styling, dimensions, and SVG fragments for chart components based on
1359        chart type and subjects.
1360
1361        Args:
1362            custom_title (str | None): Optional runtime override for the chart title.
1363
1364        Returns:
1365            ChartTemplateModel: Populated structure of template variables.
1366        """
1367        # Initialize template dictionary
1368        template_dict: dict = {}
1369
1370        # -------------------------------------#
1371        #  COMMON SETTINGS FOR ALL CHART TYPES #
1372        # -------------------------------------#
1373
1374        # Set the color style tag and basic dimensions
1375        template_dict["color_style_tag"] = self.color_style_tag
1376        template_dict["chart_height"] = self.height
1377        template_dict["chart_width"] = self.width
1378
1379        offsets = self._vertical_offsets
1380        template_dict["full_wheel_translate_y"] = offsets["wheel"]
1381        template_dict["houses_and_planets_translate_y"] = offsets["grid"]
1382        template_dict["aspect_grid_translate_y"] = offsets["aspect_grid"]
1383        template_dict["aspect_list_translate_y"] = offsets["aspect_list"]
1384        template_dict["title_translate_y"] = offsets["title"]
1385        template_dict["elements_translate_y"] = offsets["elements"]
1386        template_dict["qualities_translate_y"] = offsets["qualities"]
1387        template_dict["lunar_phase_translate_y"] = offsets["lunar_phase"]
1388        template_dict["bottom_left_translate_y"] = offsets["bottom_left"]
1389
1390        # Set paper colors
1391        template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
1392
1393        # Set background color based on transparent_background setting
1394        if self.transparent_background:
1395            template_dict["background_color"] = "transparent"
1396        else:
1397            template_dict["background_color"] = self.chart_colors_settings["paper_1"]
1398
1399        # Set planet colors - initialize all possible colors first with defaults
1400        default_color = "#000000"  # Default black color for unused planets
1401        for i in range(42):  # Support all 42 celestial points (0-41)
1402            template_dict[f"planets_color_{i}"] = default_color
1403
1404        # Override with actual colors from settings
1405        for planet in self.planets_settings:
1406            planet_id = planet["id"]
1407            template_dict[f"planets_color_{planet_id}"] = planet["color"]
1408
1409        # Set zodiac colors
1410        for i in range(12):
1411            template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"]
1412
1413        # Set orb colors
1414        for aspect in self.aspects_settings:
1415            template_dict[f"orb_color_{aspect['degree']}"] = aspect["color"]
1416
1417        # Draw zodiac circle slices
1418        template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius)
1419
1420        # Calculate element percentages
1421        total_elements = self.fire + self.water + self.earth + self.air
1422        element_values = {"fire": self.fire, "earth": self.earth, "air": self.air, "water": self.water}
1423        element_percentages = distribute_percentages_to_100(element_values) if total_elements > 0 else {"fire": 0, "earth": 0, "air": 0, "water": 0}
1424        fire_percentage = element_percentages["fire"]
1425        earth_percentage = element_percentages["earth"]
1426        air_percentage = element_percentages["air"]
1427        water_percentage = element_percentages["water"]
1428
1429        # Element Percentages
1430        template_dict["elements_string"] = f"{self._translate('elements', 'Elements')}:"
1431        template_dict["fire_string"] = f"{self._translate('fire', 'Fire')} {fire_percentage}%"
1432        template_dict["earth_string"] = f"{self._translate('earth', 'Earth')} {earth_percentage}%"
1433        template_dict["air_string"] = f"{self._translate('air', 'Air')} {air_percentage}%"
1434        template_dict["water_string"] = f"{self._translate('water', 'Water')} {water_percentage}%"
1435
1436
1437        # Qualities Percentages
1438        total_qualities = self.cardinal + self.fixed + self.mutable
1439        quality_values = {"cardinal": self.cardinal, "fixed": self.fixed, "mutable": self.mutable}
1440        quality_percentages = distribute_percentages_to_100(quality_values) if total_qualities > 0 else {"cardinal": 0, "fixed": 0, "mutable": 0}
1441        cardinal_percentage = quality_percentages["cardinal"]
1442        fixed_percentage = quality_percentages["fixed"]
1443        mutable_percentage = quality_percentages["mutable"]
1444
1445        template_dict["qualities_string"] = f"{self._translate('qualities', 'Qualities')}:"
1446        template_dict["cardinal_string"] = f"{self._translate('cardinal', 'Cardinal')} {cardinal_percentage}%"
1447        template_dict["fixed_string"] = f"{self._translate('fixed', 'Fixed')} {fixed_percentage}%"
1448        template_dict["mutable_string"] = f"{self._translate('mutable', 'Mutable')} {mutable_percentage}%"
1449
1450        # Get houses list for main subject
1451        first_subject_houses_list = get_houses_list(self.first_obj)
1452
1453        # Chart title
1454        template_dict["stringTitle"] = self._get_chart_title(custom_title_override=custom_title)
1455
1456        # ------------------------------- #
1457        #  CHART TYPE SPECIFIC SETTINGS   #
1458        # ------------------------------- #
1459
1460        if self.chart_type == "Natal":
1461            # Set viewbox dynamically
1462            template_dict["viewbox"] = self._dynamic_viewbox()
1463
1464            # Rings and circles
1465            template_dict["transitRing"] = ""
1466            template_dict["degreeRing"] = draw_degree_ring(
1467                self.main_radius,
1468                self.first_circle_radius,
1469                self.first_obj.seventh_house.abs_pos,
1470                self.chart_colors_settings["paper_0"],
1471            )
1472            template_dict["background_circle"] = draw_background_circle(
1473                self.main_radius,
1474                self.chart_colors_settings["paper_1"],
1475                self.chart_colors_settings["paper_1"],
1476            )
1477            template_dict["first_circle"] = draw_first_circle(
1478                self.main_radius,
1479                self.chart_colors_settings["zodiac_radix_ring_2"],
1480                self.chart_type,
1481                self.first_circle_radius,
1482            )
1483            template_dict["second_circle"] = draw_second_circle(
1484                self.main_radius,
1485                self.chart_colors_settings["zodiac_radix_ring_1"],
1486                self.chart_colors_settings["paper_1"],
1487                self.chart_type,
1488                self.second_circle_radius,
1489            )
1490            template_dict["third_circle"] = draw_third_circle(
1491                self.main_radius,
1492                self.chart_colors_settings["zodiac_radix_ring_0"],
1493                self.chart_colors_settings["paper_1"],
1494                self.chart_type,
1495                self.third_circle_radius,
1496            )
1497
1498            # Aspects
1499            template_dict["makeDoubleChartAspectList"] = ""
1500            template_dict["makeAspectGrid"] = draw_aspect_grid(
1501                self.chart_colors_settings["paper_0"],
1502                self.available_planets_setting,
1503                self.aspects_list,
1504            )
1505            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1506
1507            # Top left section
1508            latitude_string = convert_latitude_coordinate_to_string(
1509                self.geolat,
1510                self._translate("north", "North"),
1511                self._translate("south", "South"),
1512            )
1513            longitude_string = convert_longitude_coordinate_to_string(
1514                self.geolon,
1515                self._translate("east", "East"),
1516                self._translate("west", "West"),
1517            )
1518
1519            template_dict["top_left_0"] = f'{self._translate("location", "Location")}:'
1520            template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}"
1521            template_dict["top_left_2"] = f"{self._translate('latitude', 'Latitude')}: {latitude_string}"
1522            template_dict["top_left_3"] = f"{self._translate('longitude', 'Longitude')}: {longitude_string}"
1523            template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1524            localized_weekday = self._translate(
1525                f"weekdays.{self.first_obj.day_of_week}",
1526                self.first_obj.day_of_week,  # type: ignore[arg-type]
1527            )
1528            template_dict["top_left_5"] = f"{self._translate('day_of_week', 'Day of Week')}: {localized_weekday}" # type: ignore
1529
1530            # Bottom left section
1531            if self.first_obj.zodiac_type == "Tropical":
1532                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1533            else:
1534                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1535                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1536                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1537
1538            template_dict["bottom_left_0"] = zodiac_info
1539            template_dict["bottom_left_1"] = (
1540                f"{self._translate('domification', 'Domification')}: "
1541                f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1542            )
1543
1544            # Lunar phase information (optional)
1545            if self.first_obj.lunar_phase is not None:
1546                template_dict["bottom_left_2"] = (
1547                    f'{self._translate("lunation_day", "Lunation Day")}: '
1548                    f'{self.first_obj.lunar_phase.get("moon_phase", "")}'
1549                )
1550                template_dict["bottom_left_3"] = (
1551                    f'{self._translate("lunar_phase", "Lunar Phase")}: '
1552                    f'{self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
1553                )
1554            else:
1555                template_dict["bottom_left_2"] = ""
1556                template_dict["bottom_left_3"] = ""
1557
1558            template_dict["bottom_left_4"] = (
1559                f'{self._translate("perspective_type", "Perspective")}: '
1560                f'{self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1561            )
1562
1563            # Moon phase section calculations
1564            if self.first_obj.lunar_phase is not None:
1565                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1566            else:
1567                template_dict["makeLunarPhase"] = ""
1568
1569            # Houses and planet drawing
1570            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1571                main_subject_houses_list=first_subject_houses_list,
1572                text_color=self.chart_colors_settings["paper_0"],
1573                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1574            )
1575            template_dict["makeSecondaryHousesGrid"] = ""
1576
1577            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1578                r=self.main_radius,
1579                first_subject_houses_list=first_subject_houses_list,
1580                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1581                first_house_color=self.planets_settings[12]["color"],
1582                tenth_house_color=self.planets_settings[13]["color"],
1583                seventh_house_color=self.planets_settings[14]["color"],
1584                fourth_house_color=self.planets_settings[15]["color"],
1585                c1=self.first_circle_radius,
1586                c3=self.third_circle_radius,
1587                chart_type=self.chart_type,
1588                external_view=self.external_view,
1589            )
1590
1591            template_dict["makePlanets"] = draw_planets(
1592                available_planets_setting=self.available_planets_setting,
1593                chart_type=self.chart_type,
1594                radius=self.main_radius,
1595                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1596                third_circle_radius=self.third_circle_radius,
1597                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1598                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1599                external_view=self.external_view,
1600                first_circle_radius=self.first_circle_radius,
1601                show_degree_indicators=self.show_degree_indicators,
1602            )
1603
1604            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1605                planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
1606                subject_name=self.first_obj.name,
1607                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1608                chart_type=self.chart_type,
1609                text_color=self.chart_colors_settings["paper_0"],
1610                celestial_point_language=self._language_model.celestial_points,
1611            )
1612            template_dict["makeSecondaryPlanetGrid"] = ""
1613            template_dict["makeHouseComparisonGrid"] = ""
1614
1615        elif self.chart_type == "Composite":
1616            # Set viewbox dynamically
1617            template_dict["viewbox"] = self._dynamic_viewbox()
1618
1619            # Rings and circles
1620            template_dict["transitRing"] = ""
1621            template_dict["degreeRing"] = draw_degree_ring(
1622                self.main_radius,
1623                self.first_circle_radius,
1624                self.first_obj.seventh_house.abs_pos,
1625                self.chart_colors_settings["paper_0"],
1626            )
1627            template_dict["background_circle"] = draw_background_circle(
1628                self.main_radius,
1629                self.chart_colors_settings["paper_1"],
1630                self.chart_colors_settings["paper_1"],
1631            )
1632            template_dict["first_circle"] = draw_first_circle(
1633                self.main_radius,
1634                self.chart_colors_settings["zodiac_radix_ring_2"],
1635                self.chart_type,
1636                self.first_circle_radius,
1637            )
1638            template_dict["second_circle"] = draw_second_circle(
1639                self.main_radius,
1640                self.chart_colors_settings["zodiac_radix_ring_1"],
1641                self.chart_colors_settings["paper_1"],
1642                self.chart_type,
1643                self.second_circle_radius,
1644            )
1645            template_dict["third_circle"] = draw_third_circle(
1646                self.main_radius,
1647                self.chart_colors_settings["zodiac_radix_ring_0"],
1648                self.chart_colors_settings["paper_1"],
1649                self.chart_type,
1650                self.third_circle_radius,
1651            )
1652
1653            # Aspects
1654            template_dict["makeDoubleChartAspectList"] = ""
1655            template_dict["makeAspectGrid"] = draw_aspect_grid(
1656                self.chart_colors_settings["paper_0"],
1657                self.available_planets_setting,
1658                self.aspects_list,
1659            )
1660            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1661
1662            # Top left section
1663            # First subject
1664            latitude = convert_latitude_coordinate_to_string(
1665                self.first_obj.first_subject.lat, # type: ignore
1666                self._translate("north_letter", "N"),
1667                self._translate("south_letter", "S"),
1668            )
1669            longitude = convert_longitude_coordinate_to_string(
1670                self.first_obj.first_subject.lng, # type: ignore
1671                self._translate("east_letter", "E"),
1672                self._translate("west_letter", "W"),
1673            )
1674
1675            # Second subject
1676            latitude_string = convert_latitude_coordinate_to_string(
1677                self.first_obj.second_subject.lat, # type: ignore
1678                self._translate("north_letter", "N"),
1679                self._translate("south_letter", "S"),
1680            )
1681            longitude_string = convert_longitude_coordinate_to_string(
1682                self.first_obj.second_subject.lng, # type: ignore
1683                self._translate("east_letter", "E"),
1684                self._translate("west_letter", "W"),
1685            )
1686
1687            template_dict["top_left_0"] = f"{self.first_obj.first_subject.name}" # type: ignore
1688            template_dict["top_left_1"] = f"{datetime.fromisoformat(self.first_obj.first_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore
1689            template_dict["top_left_2"] = f"{latitude} {longitude}"
1690            template_dict["top_left_3"] = self.first_obj.second_subject.name # type: ignore
1691            template_dict["top_left_4"] = f"{datetime.fromisoformat(self.first_obj.second_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore
1692            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
1693
1694            # Bottom left section
1695            if self.first_obj.zodiac_type == "Tropical":
1696                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1697            else:
1698                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1699                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1700                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1701
1702            template_dict["bottom_left_0"] = zodiac_info
1703            template_dict["bottom_left_1"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
1704            template_dict["bottom_left_2"] = f'{self._translate("perspective_type", "Perspective")}: {self.first_obj.first_subject.perspective_type}' # type: ignore
1705            template_dict["bottom_left_3"] = f'{self._translate("composite_chart", "Composite Chart")} - {self._translate("midpoints", "Midpoints")}'
1706            template_dict["bottom_left_4"] = ""
1707
1708            # Moon phase section calculations
1709            if self.first_obj.lunar_phase is not None:
1710                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1711            else:
1712                template_dict["makeLunarPhase"] = ""
1713
1714            # Houses and planet drawing
1715            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1716                main_subject_houses_list=first_subject_houses_list,
1717                text_color=self.chart_colors_settings["paper_0"],
1718                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1719            )
1720            template_dict["makeSecondaryHousesGrid"] = ""
1721
1722            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1723                r=self.main_radius,
1724                first_subject_houses_list=first_subject_houses_list,
1725                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1726                first_house_color=self.planets_settings[12]["color"],
1727                tenth_house_color=self.planets_settings[13]["color"],
1728                seventh_house_color=self.planets_settings[14]["color"],
1729                fourth_house_color=self.planets_settings[15]["color"],
1730                c1=self.first_circle_radius,
1731                c3=self.third_circle_radius,
1732                chart_type=self.chart_type,
1733                external_view=self.external_view,
1734            )
1735
1736            template_dict["makePlanets"] = draw_planets(
1737                available_planets_setting=self.available_planets_setting,
1738                chart_type=self.chart_type,
1739                radius=self.main_radius,
1740                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1741                third_circle_radius=self.third_circle_radius,
1742                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1743                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1744                external_view=self.external_view,
1745                first_circle_radius=self.first_circle_radius,
1746                show_degree_indicators=self.show_degree_indicators,
1747            )
1748
1749            subject_name = (
1750                f"{self.first_obj.first_subject.name}"
1751                f" {self._translate('and_word', '&')} "
1752                f"{self.first_obj.second_subject.name}"
1753            )  # type: ignore
1754
1755            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1756                planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
1757                subject_name=subject_name,
1758                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1759                chart_type=self.chart_type,
1760                text_color=self.chart_colors_settings["paper_0"],
1761                celestial_point_language=self._language_model.celestial_points,
1762            )
1763            template_dict["makeSecondaryPlanetGrid"] = ""
1764            template_dict["makeHouseComparisonGrid"] = ""
1765
1766        elif self.chart_type == "Transit":
1767
1768            # Transit has no Element Percentages
1769            template_dict["elements_string"] = ""
1770            template_dict["fire_string"] = ""
1771            template_dict["earth_string"] = ""
1772            template_dict["air_string"] = ""
1773            template_dict["water_string"] = ""
1774
1775            # Transit has no Qualities Percentages
1776            template_dict["qualities_string"] = ""
1777            template_dict["cardinal_string"] = ""
1778            template_dict["fixed_string"] = ""
1779            template_dict["mutable_string"] = ""
1780
1781            # Set viewbox dynamically
1782            template_dict["viewbox"] = self._dynamic_viewbox()
1783
1784            # Get houses list for secondary subject
1785            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1786
1787            # Rings and circles
1788            template_dict["transitRing"] = draw_transit_ring(
1789                self.main_radius,
1790                self.chart_colors_settings["paper_1"],
1791                self.chart_colors_settings["zodiac_transit_ring_3"],
1792            )
1793            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1794            template_dict["background_circle"] = draw_background_circle(
1795                self.main_radius,
1796                self.chart_colors_settings["paper_1"],
1797                self.chart_colors_settings["paper_1"],
1798            )
1799            template_dict["first_circle"] = draw_first_circle(
1800                self.main_radius,
1801                self.chart_colors_settings["zodiac_transit_ring_2"],
1802                self.chart_type,
1803            )
1804            template_dict["second_circle"] = draw_second_circle(
1805                self.main_radius,
1806                self.chart_colors_settings["zodiac_transit_ring_1"],
1807                self.chart_colors_settings["paper_1"],
1808                self.chart_type,
1809            )
1810            template_dict["third_circle"] = draw_third_circle(
1811                self.main_radius,
1812                self.chart_colors_settings["zodiac_transit_ring_0"],
1813                self.chart_colors_settings["paper_1"],
1814                self.chart_type,
1815                self.third_circle_radius,
1816            )
1817
1818            # Aspects
1819            if self.double_chart_aspect_grid_type == "list":
1820                title = f'{self.first_obj.name} - {self._translate("transit_aspects", "Transit Aspects")}'
1821                template_dict["makeAspectGrid"] = ""
1822                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1823                    title,
1824                    self.aspects_list,
1825                    self.planets_settings,
1826                    self.aspects_settings,
1827                    chart_height=self.height,
1828                )  # type: ignore[arg-type]
1829            else:
1830                template_dict["makeAspectGrid"] = ""
1831                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1832                    self.chart_colors_settings["paper_0"],
1833                    self.available_planets_setting,
1834                    self.aspects_list,
1835                    600,
1836                    520,
1837                )
1838
1839            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1840
1841            # Top left section (clear separation of Natal vs Transit details)
1842            natal_latitude_string = (
1843                convert_latitude_coordinate_to_string(
1844                    self.first_obj.lat,  # type: ignore[arg-type]
1845                    self._translate("north_letter", "N"),
1846                    self._translate("south_letter", "S"),
1847                )
1848                if getattr(self.first_obj, "lat", None) is not None
1849                else ""
1850            )
1851            natal_longitude_string = (
1852                convert_longitude_coordinate_to_string(
1853                    self.first_obj.lng,  # type: ignore[arg-type]
1854                    self._translate("east_letter", "E"),
1855                    self._translate("west_letter", "W"),
1856                )
1857                if getattr(self.first_obj, "lng", None) is not None
1858                else ""
1859            )
1860
1861            transit_latitude_string = ""
1862            transit_longitude_string = ""
1863            if self.second_obj is not None:
1864                if getattr(self.second_obj, "lat", None) is not None:
1865                    transit_latitude_string = convert_latitude_coordinate_to_string(
1866                        self.second_obj.lat,  # type: ignore[arg-type]
1867                        self._translate("north_letter", "N"),
1868                        self._translate("south_letter", "S"),
1869                    )
1870                if getattr(self.second_obj, "lng", None) is not None:
1871                    transit_longitude_string = convert_longitude_coordinate_to_string(
1872                        self.second_obj.lng,  # type: ignore[arg-type]
1873                        self._translate("east_letter", "E"),
1874                        self._translate("west_letter", "W"),
1875                    )
1876
1877            natal_dt = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime)  # type: ignore[arg-type]
1878            natal_place = f"{format_location_string(self.first_obj.city)}, {self.first_obj.nation}"  # type: ignore[arg-type]
1879            transit_dt = (
1880                format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime)  # type: ignore[arg-type]
1881                if self.second_obj is not None and getattr(self.second_obj, "iso_formatted_local_datetime", None) is not None
1882                else ""
1883            )
1884            transit_place = (
1885                f"{format_location_string(self.second_obj.city)}, {self.second_obj.nation}"  # type: ignore[arg-type]
1886                if self.second_obj is not None
1887                else ""
1888            )
1889
1890            template_dict["top_left_0"] = f"{self._translate('chart_info_natal_label', 'Natal')}: {natal_dt}"
1891            template_dict["top_left_1"] = natal_place
1892            template_dict["top_left_2"] = f"{natal_latitude_string}  ·  {natal_longitude_string}"
1893            template_dict["top_left_3"] = f"{self._translate('chart_info_transit_label', 'Transit')}: {transit_dt}"
1894            template_dict["top_left_4"] = transit_place
1895            template_dict["top_left_5"] = f"{transit_latitude_string}  ·  {transit_longitude_string}"
1896
1897            # Bottom left section
1898            if self.first_obj.zodiac_type == "Tropical":
1899                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
1900            else:
1901                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1902                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1903                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
1904
1905            template_dict["bottom_left_0"] = zodiac_info
1906            template_dict["bottom_left_1"] = f"{self._translate('domification', 'Domification')}: {self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1907
1908            # Lunar phase information from second object (Transit) (optional)
1909            if self.second_obj is not None and hasattr(self.second_obj, 'lunar_phase') and self.second_obj.lunar_phase is not None:
1910                template_dict["bottom_left_3"] = (
1911                    f"{self._translate('Transit', 'Transit')} "
1912                    f"{self._translate('lunation_day', 'Lunation Day')}: "
1913                    f"{self.second_obj.lunar_phase.get('moon_phase', '')}"
1914                )  # type: ignore
1915                template_dict["bottom_left_4"] = (
1916                    f"{self._translate('Transit', 'Transit')} "
1917                    f"{self._translate('lunar_phase', 'Lunar Phase')}: "
1918                    f"{self._translate(self.second_obj.lunar_phase.moon_phase_name.lower().replace(' ', '_'), self.second_obj.lunar_phase.moon_phase_name)}"
1919                )
1920            else:
1921                template_dict["bottom_left_3"] = ""
1922                template_dict["bottom_left_4"] = ""
1923
1924            template_dict["bottom_left_2"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.second_obj.perspective_type.lower().replace(" ", "_"), self.second_obj.perspective_type)}' # type: ignore
1925
1926            # Moon phase section calculations - use transit subject data only
1927            if self.second_obj is not None and getattr(self.second_obj, "lunar_phase", None):
1928                template_dict["makeLunarPhase"] = makeLunarPhase(self.second_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1929            else:
1930                template_dict["makeLunarPhase"] = ""
1931
1932            # Houses and planet drawing
1933            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1934                main_subject_houses_list=first_subject_houses_list,
1935                text_color=self.chart_colors_settings["paper_0"],
1936                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1937            )
1938            # template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1939            #     secondary_subject_houses_list=second_subject_houses_list,
1940            #     text_color=self.chart_colors_settings["paper_0"],
1941            #     house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
1942            # )
1943            template_dict["makeSecondaryHousesGrid"] = ""
1944
1945            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1946                r=self.main_radius,
1947                first_subject_houses_list=first_subject_houses_list,
1948                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1949                first_house_color=self.planets_settings[12]["color"],
1950                tenth_house_color=self.planets_settings[13]["color"],
1951                seventh_house_color=self.planets_settings[14]["color"],
1952                fourth_house_color=self.planets_settings[15]["color"],
1953                c1=self.first_circle_radius,
1954                c3=self.third_circle_radius,
1955                chart_type=self.chart_type,
1956                external_view=self.external_view,
1957                second_subject_houses_list=second_subject_houses_list,
1958                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1959            )
1960
1961            template_dict["makePlanets"] = draw_planets(
1962                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1963                available_planets_setting=self.available_planets_setting,
1964                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1965                radius=self.main_radius,
1966                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1967                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1968                chart_type=self.chart_type,
1969                third_circle_radius=self.third_circle_radius,
1970                external_view=self.external_view,
1971                second_circle_radius=self.second_circle_radius,
1972                show_degree_indicators=self.show_degree_indicators,
1973            )
1974
1975            # Planet grids
1976            first_name_label = self._truncate_name(self.first_obj.name)
1977            transit_label = self._translate("transit", "Transit")
1978            first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
1979            second_return_grid_title = f"{transit_label} ({self._translate('outer_wheel', 'Outer Wheel')})"
1980            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1981                planets_and_houses_grid_title="",
1982                subject_name=first_return_grid_title,
1983                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1984                chart_type=self.chart_type,
1985                text_color=self.chart_colors_settings["paper_0"],
1986                celestial_point_language=self._language_model.celestial_points,
1987            )
1988
1989            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1990                planets_and_houses_grid_title="",
1991                second_subject_name=second_return_grid_title,
1992                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1993                chart_type=self.chart_type,
1994                text_color=self.chart_colors_settings["paper_0"],
1995                celestial_point_language=self._language_model.celestial_points,
1996            )
1997
1998            # House comparison grid
1999            if self.show_house_position_comparison or self.show_cusp_position_comparison:
2000                house_comparison_factory = HouseComparisonFactory(
2001                    first_subject=self.first_obj,  # type: ignore[arg-type]
2002                    second_subject=self.second_obj,  # type: ignore[arg-type]
2003                    active_points=self.active_points,
2004                )
2005                house_comparison = house_comparison_factory.get_house_comparison()
2006
2007                house_comparison_svg = ""
2008
2009                if self.show_house_position_comparison:
2010                    house_comparison_svg = draw_single_house_comparison_grid(
2011                        house_comparison,
2012                        celestial_point_language=self._language_model.celestial_points,
2013                        active_points=self.active_points,
2014                        points_owner_subject_number=2,  # The second subject is the Transit
2015                        house_position_comparison_label=self._translate("house_position_comparison", "House Position Comparison"),
2016                        return_point_label=self._translate("transit_point", "Transit Point"),
2017                        natal_house_label=self._translate("house_position", "Natal House"),
2018                        x_position=980,
2019                    )
2020
2021                if self.show_cusp_position_comparison:
2022                    # When only the cusp comparison is visible, reuse the house grid slot
2023                    # so the layout remains compact. When both are visible, place the cusp
2024                    # table to the right of the house comparison grid.
2025                    if self.show_house_position_comparison:
2026                        # Classic dual-grid layout: house at x=980, cusp at a fixed offset.
2027                        # Fixed position alignment: 980 (start) + ~200 (table width + gap) = 1180
2028                        cusp_x_position = 1180
2029                    else:
2030                        # Cusp-only layout: occupy the house comparison slot directly.
2031                        cusp_x_position = 980
2032
2033                    cusp_grid = draw_single_cusp_comparison_grid(
2034                        house_comparison,
2035                        celestial_point_language=self._language_model.celestial_points,
2036                        cusps_owner_subject_number=2,  # Transit cusps in natal houses
2037                        cusp_position_comparison_label=self._translate("cusp_position_comparison", "Cusp Position Comparison"),
2038                        owner_cusp_label=self._translate("transit_cusp", "Transit Cusp"),
2039                        projected_house_label=self._translate("natal_house", "Natal House"),
2040                        x_position=int(cusp_x_position),
2041                        y_position=0,
2042                    )
2043                    house_comparison_svg += cusp_grid
2044
2045                template_dict["makeHouseComparisonGrid"] = house_comparison_svg
2046            else:
2047                template_dict["makeHouseComparisonGrid"] = ""
2048
2049        elif self.chart_type == "Synastry":
2050            # Set viewbox dynamically
2051            template_dict["viewbox"] = self._dynamic_viewbox()
2052
2053            # Get houses list for secondary subject
2054            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
2055
2056            # Rings and circles
2057            template_dict["transitRing"] = draw_transit_ring(
2058                self.main_radius,
2059                self.chart_colors_settings["paper_1"],
2060                self.chart_colors_settings["zodiac_transit_ring_3"],
2061            )
2062            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
2063            template_dict["background_circle"] = draw_background_circle(
2064                self.main_radius,
2065                self.chart_colors_settings["paper_1"],
2066                self.chart_colors_settings["paper_1"],
2067            )
2068            template_dict["first_circle"] = draw_first_circle(
2069                self.main_radius,
2070                self.chart_colors_settings["zodiac_transit_ring_2"],
2071                self.chart_type,
2072            )
2073            template_dict["second_circle"] = draw_second_circle(
2074                self.main_radius,
2075                self.chart_colors_settings["zodiac_transit_ring_1"],
2076                self.chart_colors_settings["paper_1"],
2077                self.chart_type,
2078            )
2079            template_dict["third_circle"] = draw_third_circle(
2080                self.main_radius,
2081                self.chart_colors_settings["zodiac_transit_ring_0"],
2082                self.chart_colors_settings["paper_1"],
2083                self.chart_type,
2084                self.third_circle_radius,
2085            )
2086
2087            # Aspects
2088            if self.double_chart_aspect_grid_type == "list":
2089                template_dict["makeAspectGrid"] = ""
2090                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
2091                    f"{self.first_obj.name} - {self.second_obj.name} {self._translate('synastry_aspects', 'Synastry Aspects')}",  # type: ignore[union-attr]
2092                    self.aspects_list,
2093                    self.planets_settings,  # type: ignore[arg-type]
2094                    self.aspects_settings,  # type: ignore[arg-type]
2095                    chart_height=self.height,
2096                )
2097            else:
2098                template_dict["makeAspectGrid"] = ""
2099                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
2100                    self.chart_colors_settings["paper_0"],
2101                    self.available_planets_setting,
2102                    self.aspects_list,
2103                    550,
2104                    450,
2105                )
2106
2107            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
2108
2109            # Top left section
2110            template_dict["top_left_0"] = f"{self.first_obj.name}:"
2111            template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
2112            template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
2113            template_dict["top_left_3"] = f"{self.second_obj.name}: " # type: ignore
2114            template_dict["top_left_4"] = f"{self.second_obj.city}, {self.second_obj.nation}" # type: ignore
2115            template_dict["top_left_5"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
2116
2117            # Bottom left section
2118            if self.first_obj.zodiac_type == "Tropical":
2119                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
2120            else:
2121                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
2122                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
2123                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
2124
2125            template_dict["bottom_left_0"] = ""
2126            # FIXME!
2127            template_dict["bottom_left_1"] = "" # f"Compatibility Score: {16}/44" # type: ignore
2128            template_dict["bottom_left_2"] = zodiac_info
2129            template_dict["bottom_left_3"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
2130            template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
2131
2132            # Moon phase section calculations
2133            template_dict["makeLunarPhase"] = ""
2134
2135            # Houses and planet drawing
2136            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
2137                main_subject_houses_list=first_subject_houses_list,
2138                text_color=self.chart_colors_settings["paper_0"],
2139                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2140            )
2141
2142            template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
2143                secondary_subject_houses_list=second_subject_houses_list,
2144                text_color=self.chart_colors_settings["paper_0"],
2145                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2146            )
2147
2148            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
2149                r=self.main_radius,
2150                first_subject_houses_list=first_subject_houses_list,
2151                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
2152                first_house_color=self.planets_settings[12]["color"],
2153                tenth_house_color=self.planets_settings[13]["color"],
2154                seventh_house_color=self.planets_settings[14]["color"],
2155                fourth_house_color=self.planets_settings[15]["color"],
2156                c1=self.first_circle_radius,
2157                c3=self.third_circle_radius,
2158                chart_type=self.chart_type,
2159                external_view=self.external_view,
2160                second_subject_houses_list=second_subject_houses_list,
2161                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
2162            )
2163
2164            template_dict["makePlanets"] = draw_planets(
2165                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2166                available_planets_setting=self.available_planets_setting,
2167                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2168                radius=self.main_radius,
2169                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
2170                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
2171                chart_type=self.chart_type,
2172                third_circle_radius=self.third_circle_radius,
2173                external_view=self.external_view,
2174                second_circle_radius=self.second_circle_radius,
2175                show_degree_indicators=self.show_degree_indicators,
2176            )
2177
2178            # Planet grid
2179            first_name_label = self._truncate_name(self.first_obj.name, 18, "…")  # type: ignore[union-attr]
2180            second_name_label = self._truncate_name(self.second_obj.name, 18, "…")  # type: ignore[union-attr]
2181            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
2182                planets_and_houses_grid_title="",
2183                subject_name=f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})",
2184                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2185                chart_type=self.chart_type,
2186                text_color=self.chart_colors_settings["paper_0"],
2187                celestial_point_language=self._language_model.celestial_points,
2188            )
2189            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
2190                planets_and_houses_grid_title="",
2191                second_subject_name= f"{second_name_label} ({self._translate('outer_wheel', 'Outer Wheel')})", # type: ignore
2192                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2193                chart_type=self.chart_type,
2194                text_color=self.chart_colors_settings["paper_0"],
2195                celestial_point_language=self._language_model.celestial_points,
2196            )
2197            if self.show_house_position_comparison or self.show_cusp_position_comparison:
2198                house_comparison_factory = HouseComparisonFactory(
2199                    first_subject=self.first_obj,  # type: ignore[arg-type]
2200                    second_subject=self.second_obj,  # type: ignore[arg-type]
2201                    active_points=self.active_points,
2202                )
2203                house_comparison = house_comparison_factory.get_house_comparison()
2204
2205                first_subject_label = self._truncate_name(self.first_obj.name, 8, "…", True)  # type: ignore[union-attr]
2206                second_subject_label = self._truncate_name(self.second_obj.name, 8, "…", True)  # type: ignore[union-attr]
2207                point_column_label = self._translate("point", "Point")
2208                comparison_label = self._translate("house_position_comparison", "House Position Comparison")
2209
2210                house_comparison_svg = ""
2211
2212                if self.show_house_position_comparison:
2213                    first_subject_grid = draw_house_comparison_grid(
2214                        house_comparison,
2215                        celestial_point_language=self._language_model.celestial_points,
2216                        active_points=self.active_points,
2217                        points_owner_subject_number=1,
2218                        house_position_comparison_label=comparison_label,
2219                        return_point_label=first_subject_label + " " + point_column_label,
2220                        return_label=first_subject_label,
2221                        radix_label=second_subject_label,
2222                        x_position=1090,
2223                        y_position=0,
2224                    )
2225
2226                    second_subject_grid = draw_house_comparison_grid(
2227                        house_comparison,
2228                        celestial_point_language=self._language_model.celestial_points,
2229                        active_points=self.active_points,
2230                        points_owner_subject_number=2,
2231                        house_position_comparison_label="",
2232                        return_point_label=second_subject_label + " " + point_column_label,
2233                        return_label=second_subject_label,
2234                        radix_label=first_subject_label,
2235                        x_position=1290,
2236                        y_position=0,
2237                    )
2238
2239                    house_comparison_svg = first_subject_grid + second_subject_grid
2240
2241                if self.show_cusp_position_comparison:
2242                    if self.show_house_position_comparison:
2243                        # Estimate house comparison grid widths to place cusp comparison at the far right
2244                        first_columns = [
2245                            f"{first_subject_label} {point_column_label}",
2246                            first_subject_label,
2247                            second_subject_label,
2248                        ]
2249                        second_columns = [
2250                            f"{second_subject_label} {point_column_label}",
2251                            second_subject_label,
2252                            first_subject_label,
2253                        ]
2254
2255                        first_grid_width = self._estimate_house_comparison_grid_width(
2256                            column_labels=first_columns,
2257                            include_radix_column=True,
2258                            include_title=True,
2259                        )
2260                        second_grid_width = self._estimate_house_comparison_grid_width(
2261                            column_labels=second_columns,
2262                            include_radix_column=True,
2263                            include_title=False,
2264                        )
2265
2266                        first_house_comparison_grid_right = 1000 + first_grid_width
2267                        second_house_comparison_grid_right = 1190 + second_grid_width
2268                        max_house_comparison_right = max(
2269                            first_house_comparison_grid_right,
2270                            second_house_comparison_grid_right,
2271                        )
2272                        cusp_x_position = max_house_comparison_right + 50.0
2273
2274                        # Place cusp comparison grids for Synastry side by side on the far right,
2275                        # keeping them as close together as possible and slightly shifted left.
2276                        cusp_grid_width = 160.0
2277                        inter_cusp_gap = 0.0
2278                        first_cusp_x = int(cusp_x_position)
2279                        second_cusp_x = int(cusp_x_position + cusp_grid_width + inter_cusp_gap)
2280                    else:
2281                        # Cusp-only layout: reuse the house comparison slots at x=1090 and x=1290.
2282                        first_cusp_x = 1090
2283                        second_cusp_x = 1290
2284
2285                    first_cusp_grid = draw_cusp_comparison_grid(
2286                        house_comparison,
2287                        celestial_point_language=self._language_model.celestial_points,
2288                        cusps_owner_subject_number=1,
2289                        cusp_position_comparison_label=self._translate("cusp_position_comparison", "Cusp Position Comparison"),
2290                        owner_cusp_label=first_subject_label + " " + self._translate("cusp", "Cusp"),
2291                        projected_house_label=second_subject_label + " " + self._translate("house", "House"),
2292                        x_position=first_cusp_x,
2293                        y_position=0,
2294                    )
2295
2296                    second_cusp_grid = draw_cusp_comparison_grid(
2297                        house_comparison,
2298                        celestial_point_language=self._language_model.celestial_points,
2299                        cusps_owner_subject_number=2,
2300                        cusp_position_comparison_label="",
2301                        owner_cusp_label=second_subject_label + " " + self._translate("cusp", "Cusp"),
2302                        projected_house_label=first_subject_label + " " + self._translate("house", "House"),
2303                        x_position=second_cusp_x,
2304                        y_position=0,
2305                    )
2306
2307                    house_comparison_svg += first_cusp_grid + second_cusp_grid
2308
2309                template_dict["makeHouseComparisonGrid"] = house_comparison_svg
2310            else:
2311                template_dict["makeHouseComparisonGrid"] = ""
2312
2313        elif self.chart_type == "DualReturnChart":
2314            # Set viewbox dynamically
2315            template_dict["viewbox"] = self._dynamic_viewbox()
2316
2317            # Get houses list for secondary subject
2318            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
2319
2320            # Rings and circles
2321            template_dict["transitRing"] = draw_transit_ring(
2322                self.main_radius,
2323                self.chart_colors_settings["paper_1"],
2324                self.chart_colors_settings["zodiac_transit_ring_3"],
2325            )
2326            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
2327            template_dict["background_circle"] = draw_background_circle(
2328                self.main_radius,
2329                self.chart_colors_settings["paper_1"],
2330                self.chart_colors_settings["paper_1"],
2331            )
2332            template_dict["first_circle"] = draw_first_circle(
2333                self.main_radius,
2334                self.chart_colors_settings["zodiac_transit_ring_2"],
2335                self.chart_type,
2336            )
2337            template_dict["second_circle"] = draw_second_circle(
2338                self.main_radius,
2339                self.chart_colors_settings["zodiac_transit_ring_1"],
2340                self.chart_colors_settings["paper_1"],
2341                self.chart_type,
2342            )
2343            template_dict["third_circle"] = draw_third_circle(
2344                self.main_radius,
2345                self.chart_colors_settings["zodiac_transit_ring_0"],
2346                self.chart_colors_settings["paper_1"],
2347                self.chart_type,
2348                self.third_circle_radius,
2349            )
2350
2351            # Aspects
2352            if self.double_chart_aspect_grid_type == "list":
2353                title = self._translate("return_aspects", "Natal to Return Aspects")
2354                template_dict["makeAspectGrid"] = ""
2355                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
2356                    title,
2357                    self.aspects_list,
2358                    self.planets_settings,
2359                    self.aspects_settings,
2360                    max_columns=7,
2361                    chart_height=self.height,
2362                )  # type: ignore[arg-type]
2363            else:
2364                template_dict["makeAspectGrid"] = ""
2365                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
2366                    self.chart_colors_settings["paper_0"],
2367                    self.available_planets_setting,
2368                    self.aspects_list,
2369                    550,
2370                    450,
2371                )
2372
2373            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
2374
2375
2376            # Top left section
2377            # Subject
2378            latitude_string = convert_latitude_coordinate_to_string(self.first_obj.lat, self._translate("north", "North"), self._translate("south", "South")) # type: ignore
2379            longitude_string = convert_longitude_coordinate_to_string(self.first_obj.lng, self._translate("east", "East"), self._translate("west", "West")) # type: ignore
2380
2381            # Return
2382            return_latitude_string = convert_latitude_coordinate_to_string(self.second_obj.lat, self._translate("north", "North"), self._translate("south", "South")) # type: ignore
2383            return_longitude_string = convert_longitude_coordinate_to_string(self.second_obj.lng, self._translate("east", "East"), self._translate("west", "West")) # type: ignore
2384
2385            if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2386                template_dict["top_left_0"] = f"{self._translate('solar_return', 'Solar Return')}:"
2387            else:
2388                template_dict["top_left_0"] = f"{self._translate('lunar_return', 'Lunar Return')}:"
2389            template_dict["top_left_1"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
2390            template_dict["top_left_2"] = f"{return_latitude_string} / {return_longitude_string}"
2391            template_dict["top_left_3"] = f"{self.first_obj.name}"
2392            template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
2393            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
2394
2395            # Bottom left section
2396            if self.first_obj.zodiac_type == "Tropical":
2397                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
2398            else:
2399                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
2400                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
2401                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
2402
2403            template_dict["bottom_left_0"] = zodiac_info
2404            template_dict["bottom_left_1"] = f"{self._translate('domification', 'Domification')}: {self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
2405
2406            # Lunar phase information (optional)
2407            if self.first_obj.lunar_phase is not None:
2408                template_dict["bottom_left_2"] = f'{self._translate("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
2409                template_dict["bottom_left_3"] = f'{self._translate("lunar_phase", "Lunar Phase")}: {self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
2410            else:
2411                template_dict["bottom_left_2"] = ""
2412                template_dict["bottom_left_3"] = ""
2413
2414            template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
2415
2416            # Moon phase section calculations
2417            if self.first_obj.lunar_phase is not None:
2418                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
2419            else:
2420                template_dict["makeLunarPhase"] = ""
2421
2422            # Houses and planet drawing
2423            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
2424                main_subject_houses_list=first_subject_houses_list,
2425                text_color=self.chart_colors_settings["paper_0"],
2426                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2427            )
2428
2429            template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
2430                secondary_subject_houses_list=second_subject_houses_list,
2431                text_color=self.chart_colors_settings["paper_0"],
2432                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2433            )
2434
2435            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
2436                r=self.main_radius,
2437                first_subject_houses_list=first_subject_houses_list,
2438                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
2439                first_house_color=self.planets_settings[12]["color"],
2440                tenth_house_color=self.planets_settings[13]["color"],
2441                seventh_house_color=self.planets_settings[14]["color"],
2442                fourth_house_color=self.planets_settings[15]["color"],
2443                c1=self.first_circle_radius,
2444                c3=self.third_circle_radius,
2445                chart_type=self.chart_type,
2446                external_view=self.external_view,
2447                second_subject_houses_list=second_subject_houses_list,
2448                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
2449            )
2450
2451            template_dict["makePlanets"] = draw_planets(
2452                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2453                available_planets_setting=self.available_planets_setting,
2454                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2455                radius=self.main_radius,
2456                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
2457                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
2458                chart_type=self.chart_type,
2459                third_circle_radius=self.third_circle_radius,
2460                external_view=self.external_view,
2461                second_circle_radius=self.second_circle_radius,
2462                show_degree_indicators=self.show_degree_indicators,
2463            )
2464
2465            # Planet grid
2466            first_name_label = self._truncate_name(self.first_obj.name)
2467            if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2468                first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
2469                second_return_grid_title = f"{self._translate('solar_return', 'Solar Return')} ({self._translate('outer_wheel', 'Outer Wheel')})"
2470            else:
2471                first_return_grid_title = f"{first_name_label} ({self._translate('inner_wheel', 'Inner Wheel')})"
2472                second_return_grid_title = f'{self._translate("lunar_return", "Lunar Return")} ({self._translate("outer_wheel", "Outer Wheel")})'
2473            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
2474                planets_and_houses_grid_title="",
2475                subject_name=first_return_grid_title,
2476                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2477                chart_type=self.chart_type,
2478                text_color=self.chart_colors_settings["paper_0"],
2479                celestial_point_language=self._language_model.celestial_points,
2480            )
2481            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
2482                planets_and_houses_grid_title="",
2483                second_subject_name=second_return_grid_title,
2484                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
2485                chart_type=self.chart_type,
2486                text_color=self.chart_colors_settings["paper_0"],
2487                celestial_point_language=self._language_model.celestial_points,
2488            )
2489
2490            if self.show_house_position_comparison or self.show_cusp_position_comparison:
2491                house_comparison_factory = HouseComparisonFactory(
2492                    first_subject=self.first_obj,  # type: ignore[arg-type]
2493                    second_subject=self.second_obj,  # type: ignore[arg-type]
2494                    active_points=self.active_points,
2495                )
2496                house_comparison = house_comparison_factory.get_house_comparison()
2497
2498                # Use generic, localized labels (Natal vs Return) so the DualReturn
2499                # comparison layout mirrors the Synastry layout.
2500                natal_label = self._translate("Natal", "Natal")
2501                if self.second_obj is not None and hasattr(self.second_obj, "return_type") and self.second_obj.return_type == "Solar":
2502                    return_label_text = self._translate("Return", "Return")
2503                else:
2504                    return_label_text = self._translate("Return", "Return")
2505                point_column_label = self._translate("point", "Point")
2506
2507                house_comparison_svg = ""
2508
2509                if self.show_house_position_comparison:
2510                    # Two house comparison grids side by side, like Synastry.
2511                    first_house_grid = draw_house_comparison_grid(
2512                        house_comparison,
2513                        celestial_point_language=self._language_model.celestial_points,
2514                        active_points=self.active_points,
2515                        points_owner_subject_number=1,
2516                        house_position_comparison_label=self._translate("house_position_comparison", "House Position Comparison"),
2517                        return_point_label=f"{natal_label} {point_column_label}",
2518                        return_label=natal_label,
2519                        radix_label=return_label_text,
2520                        x_position=1090,
2521                        y_position=0,
2522                    )
2523
2524                    second_house_grid = draw_house_comparison_grid(
2525                        house_comparison,
2526                        celestial_point_language=self._language_model.celestial_points,
2527                        active_points=self.active_points,
2528                        points_owner_subject_number=2,
2529                        house_position_comparison_label="",
2530                        return_point_label=point_column_label,
2531                        return_label=return_label_text,
2532                        radix_label=natal_label,
2533                        x_position=1290,
2534                        y_position=0,
2535                    )
2536
2537                    house_comparison_svg = first_house_grid + second_house_grid
2538
2539                if self.show_cusp_position_comparison:
2540                    if self.show_house_position_comparison:
2541                        # Match the Synastry spacing: estimate widths to place cusp
2542                        # comparison grids immediately to the right of both house grids.
2543                        first_columns = [
2544                            f"{natal_label} {point_column_label}",
2545                            natal_label,
2546                            return_label_text,
2547                        ]
2548                        second_columns = [
2549                            f"{return_label_text} {point_column_label}",
2550                            return_label_text,
2551                            natal_label,
2552                        ]
2553
2554                        first_grid_width = self._estimate_house_comparison_grid_width(
2555                            column_labels=first_columns,
2556                            include_radix_column=True,
2557                            include_title=True,
2558                        )
2559                        second_grid_width = self._estimate_house_comparison_grid_width(
2560                            column_labels=second_columns,
2561                            include_radix_column=True,
2562                            include_title=False,
2563                        )
2564
2565                        first_house_comparison_grid_right = 1000 + first_grid_width
2566                        second_house_comparison_grid_right = 1190 + second_grid_width
2567                        max_house_comparison_right = max(
2568                            first_house_comparison_grid_right,
2569                            second_house_comparison_grid_right,
2570                        )
2571                        cusp_x_position = max_house_comparison_right + 50.0
2572
2573                        cusp_grid_width = 160.0
2574                        inter_cusp_gap = 0.0
2575                        first_cusp_x = int(cusp_x_position)
2576                        second_cusp_x = int(cusp_x_position + cusp_grid_width + inter_cusp_gap)
2577                    else:
2578                        # Cusp-only layout: reuse the house comparison slots at x=1090 and x=1290.
2579                        first_cusp_x = 1090
2580                        second_cusp_x = 1290
2581
2582                    first_cusp_grid = draw_cusp_comparison_grid(
2583                        house_comparison,
2584                        celestial_point_language=self._language_model.celestial_points,
2585                        cusps_owner_subject_number=1,
2586                        cusp_position_comparison_label=self._translate("cusp_position_comparison", "Cusp Position Comparison"),
2587                        owner_cusp_label=f"{natal_label} " + self._translate("cusp", "Cusp"),
2588                        projected_house_label=self._translate("Return", "Return") + " " + self._translate("house", "House"),
2589                        x_position=first_cusp_x,
2590                        y_position=0,
2591                    )
2592
2593                    second_cusp_grid = draw_cusp_comparison_grid(
2594                        house_comparison,
2595                        celestial_point_language=self._language_model.celestial_points,
2596                        cusps_owner_subject_number=2,
2597                        cusp_position_comparison_label="",
2598                        owner_cusp_label=self._translate("return_cusp", "Return Cusp"),
2599                        projected_house_label=f"{natal_label} " + self._translate("house", "House"),
2600                        x_position=second_cusp_x,
2601                        y_position=0,
2602                    )
2603
2604                    house_comparison_svg += first_cusp_grid + second_cusp_grid
2605
2606                template_dict["makeHouseComparisonGrid"] = house_comparison_svg
2607            else:
2608                template_dict["makeHouseComparisonGrid"] = ""
2609
2610        elif self.chart_type == "SingleReturnChart":
2611            # Set viewbox dynamically
2612            template_dict["viewbox"] = self._dynamic_viewbox()
2613
2614            # Rings and circles
2615            template_dict["transitRing"] = ""
2616            template_dict["degreeRing"] = draw_degree_ring(
2617                self.main_radius,
2618                self.first_circle_radius,
2619                self.first_obj.seventh_house.abs_pos,
2620                self.chart_colors_settings["paper_0"],
2621            )
2622            template_dict["background_circle"] = draw_background_circle(
2623                self.main_radius,
2624                self.chart_colors_settings["paper_1"],
2625                self.chart_colors_settings["paper_1"],
2626            )
2627            template_dict["first_circle"] = draw_first_circle(
2628                self.main_radius,
2629                self.chart_colors_settings["zodiac_radix_ring_2"],
2630                self.chart_type,
2631                self.first_circle_radius,
2632            )
2633            template_dict["second_circle"] = draw_second_circle(
2634                self.main_radius,
2635                self.chart_colors_settings["zodiac_radix_ring_1"],
2636                self.chart_colors_settings["paper_1"],
2637                self.chart_type,
2638                self.second_circle_radius,
2639            )
2640            template_dict["third_circle"] = draw_third_circle(
2641                self.main_radius,
2642                self.chart_colors_settings["zodiac_radix_ring_0"],
2643                self.chart_colors_settings["paper_1"],
2644                self.chart_type,
2645                self.third_circle_radius,
2646            )
2647
2648            # Aspects
2649            template_dict["makeDoubleChartAspectList"] = ""
2650            template_dict["makeAspectGrid"] = draw_aspect_grid(
2651                self.chart_colors_settings["paper_0"],
2652                self.available_planets_setting,
2653                self.aspects_list,
2654            )
2655            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
2656
2657            # Top left section
2658            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self._translate("north", "North"), self._translate("south", "South"))
2659            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self._translate("east", "East"), self._translate("west", "West"))
2660
2661            template_dict["top_left_0"] = f'{self._translate("info", "Info")}:'
2662            template_dict["top_left_1"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
2663            template_dict["top_left_2"] = f"{self.first_obj.city}, {self.first_obj.nation}"
2664            template_dict["top_left_3"] = f"{self._translate('latitude', 'Latitude')}: {latitude_string}"
2665            template_dict["top_left_4"] = f"{self._translate('longitude', 'Longitude')}: {longitude_string}"
2666
2667            if hasattr(self.first_obj, 'return_type') and self.first_obj.return_type == "Solar":
2668                template_dict["top_left_5"] = f"{self._translate('type', 'Type')}: {self._translate('solar_return', 'Solar Return')}"
2669            else:
2670                template_dict["top_left_5"] = f"{self._translate('type', 'Type')}: {self._translate('lunar_return', 'Lunar Return')}"
2671
2672            # Bottom left section
2673            if self.first_obj.zodiac_type == "Tropical":
2674                zodiac_info = f"{self._translate('zodiac', 'Zodiac')}: {self._translate('tropical', 'Tropical')}"
2675            else:
2676                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
2677                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
2678                zodiac_info = f"{self._translate('ayanamsa', 'Ayanamsa')}: {mode_name}"
2679
2680            template_dict["bottom_left_0"] = zodiac_info
2681            template_dict["bottom_left_1"] = f"{self._translate('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self._translate('houses', 'Houses')}"
2682
2683            # Lunar phase information (optional)
2684            if self.first_obj.lunar_phase is not None:
2685                template_dict["bottom_left_2"] = f'{self._translate("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
2686                template_dict["bottom_left_3"] = f'{self._translate("lunar_phase", "Lunar Phase")}: {self._translate(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
2687            else:
2688                template_dict["bottom_left_2"] = ""
2689                template_dict["bottom_left_3"] = ""
2690
2691            template_dict["bottom_left_4"] = f'{self._translate("perspective_type", "Perspective")}: {self._translate(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
2692
2693            # Moon phase section calculations
2694            if self.first_obj.lunar_phase is not None:
2695                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
2696            else:
2697                template_dict["makeLunarPhase"] = ""
2698
2699            # Houses and planet drawing
2700            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
2701                main_subject_houses_list=first_subject_houses_list,
2702                text_color=self.chart_colors_settings["paper_0"],
2703                house_cusp_generale_name_label=self._translate("cusp", "Cusp"),
2704            )
2705            template_dict["makeSecondaryHousesGrid"] = ""
2706
2707            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
2708                r=self.main_radius,
2709                first_subject_houses_list=first_subject_houses_list,
2710                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
2711                first_house_color=self.planets_settings[12]["color"],
2712                tenth_house_color=self.planets_settings[13]["color"],
2713                seventh_house_color=self.planets_settings[14]["color"],
2714                fourth_house_color=self.planets_settings[15]["color"],
2715                c1=self.first_circle_radius,
2716                c3=self.third_circle_radius,
2717                chart_type=self.chart_type,
2718                external_view=self.external_view,
2719            )
2720
2721            template_dict["makePlanets"] = draw_planets(
2722                available_planets_setting=self.available_planets_setting,
2723                chart_type=self.chart_type,
2724                radius=self.main_radius,
2725                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2726                third_circle_radius=self.third_circle_radius,
2727                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
2728                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
2729                external_view=self.external_view,
2730                first_circle_radius=self.first_circle_radius,
2731                show_degree_indicators=self.show_degree_indicators,
2732            )
2733
2734            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
2735                planets_and_houses_grid_title=self._translate("planets_and_house", "Points for"),
2736                subject_name=self.first_obj.name,
2737                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
2738                chart_type=self.chart_type,
2739                text_color=self.chart_colors_settings["paper_0"],
2740                celestial_point_language=self._language_model.celestial_points,
2741            )
2742            template_dict["makeSecondaryPlanetGrid"] = ""
2743            template_dict["makeHouseComparisonGrid"] = ""
2744
2745        return ChartTemplateModel(**template_dict)
2746
2747    def generate_svg_string(self, minify: bool = False, remove_css_variables=False, *, custom_title: Union[str, None] = None) -> str:
2748        """
2749        Render the full chart SVG as a string.
2750
2751        Reads the XML template, substitutes variables, and optionally inlines CSS
2752        variables and minifies the output.
2753
2754        Args:
2755            minify (bool): Remove whitespace and quotes for compactness.
2756            remove_css_variables (bool): Embed CSS variable definitions.
2757            custom_title (str or None): Optional override for the SVG title.
2758
2759        Returns:
2760            str: SVG markup as a string.
2761        """
2762        td = self._create_template_dictionary(custom_title=custom_title)
2763
2764        DATA_DIR = Path(__file__).parent
2765        xml_svg = DATA_DIR / "templates" / "chart.xml"
2766
2767        # read template
2768        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
2769            template = Template(f.read()).substitute(td.model_dump())
2770
2771        # return filename
2772
2773        logger.debug("Template dictionary includes %s fields", len(td.model_dump()))
2774
2775        if remove_css_variables:
2776            template = inline_css_variables_in_svg(template)
2777
2778        if minify:
2779            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2780
2781        else:
2782            template = template.replace('"', "'")
2783
2784        return template
2785
2786    def save_svg(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False, *, custom_title: Union[str, None] = None):
2787        """
2788        Generate and save the full chart SVG to disk.
2789
2790        Calls generate_svg_string to render the SVG, then writes a file named
2791        "{subject.name} - {chart_type} Chart.svg" in the specified output directory.
2792
2793        Args:
2794            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2795                If None, defaults to the user's home directory.
2796            filename (str or None): Custom filename for the SVG file (without extension).
2797                If None, uses the default pattern: "{subject.name} - {chart_type} Chart".
2798            minify (bool): Pass-through to generate_svg_string for compact output.
2799            remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables.
2800            custom_title (str or None): Optional override for the SVG title.
2801
2802        Returns:
2803            None
2804        """
2805
2806        self.template = self.generate_svg_string(minify, remove_css_variables, custom_title=custom_title)
2807
2808        # Convert output_path to Path object, default to home directory
2809        output_directory = Path(output_path) if output_path is not None else Path.home()
2810
2811        # Determine filename
2812        if filename is not None:
2813            chartname = output_directory / f"{filename}.svg"
2814        else:
2815            # Use default filename pattern
2816            chart_type_for_filename = self.chart_type
2817
2818            if self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
2819                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Lunar Return.svg"
2820            elif self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2821                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Solar Return.svg"
2822            else:
2823                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart.svg"
2824
2825        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2826            output_file.write(self.template)
2827
2828        print(f"SVG Generated Correctly in: {chartname}")
2829
2830    def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2831        """
2832        Render the wheel-only chart SVG as a string.
2833
2834        Reads the wheel-only XML template, substitutes chart data, and applies optional
2835        CSS inlining and minification.
2836
2837        Args:
2838            minify (bool): Remove whitespace and quotes for compactness.
2839            remove_css_variables (bool): Embed CSS variable definitions.
2840
2841        Returns:
2842            str: SVG markup for the chart wheel only.
2843        """
2844
2845        with open(
2846            Path(__file__).parent / "templates" / "wheel_only.xml",
2847            "r",
2848            encoding="utf-8",
2849            errors="ignore",
2850        ) as f:
2851            template = f.read()
2852
2853        template_dict = self._create_template_dictionary()
2854        # Use a compact viewBox specific for the wheel-only rendering
2855        wheel_viewbox = self._wheel_only_viewbox()
2856        template = Template(template).substitute({**template_dict.model_dump(), "viewbox": wheel_viewbox})
2857
2858        if remove_css_variables:
2859            template = inline_css_variables_in_svg(template)
2860
2861        if minify:
2862            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2863
2864        else:
2865            template = template.replace('"', "'")
2866
2867        return template
2868
2869    def save_wheel_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2870        """
2871        Generate and save wheel-only chart SVG to disk.
2872
2873        Calls generate_wheel_only_svg_string and writes a file named
2874        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.
2875
2876        Args:
2877            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2878                If None, defaults to the user's home directory.
2879            filename (str or None): Custom filename for the SVG file (without extension).
2880                If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only".
2881            minify (bool): Pass-through to generate_wheel_only_svg_string for compact output.
2882            remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.
2883
2884        Returns:
2885            None
2886        """
2887
2888        template = self.generate_wheel_only_svg_string(minify, remove_css_variables)
2889
2890        # Convert output_path to Path object, default to home directory
2891        output_directory = Path(output_path) if output_path is not None else Path.home()
2892
2893        # Determine filename
2894        if filename is not None:
2895            chartname = output_directory / f"{filename}.svg"
2896        else:
2897            # Use default filename pattern
2898            chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2899            chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Wheel Only.svg"
2900
2901        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2902            output_file.write(template)
2903
2904        print(f"SVG Generated Correctly in: {chartname}")
2905
2906    def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2907        """
2908        Render the aspect-grid-only chart SVG as a string.
2909
2910        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
2911        and applies optional CSS inlining and minification.
2912
2913        Args:
2914            minify (bool): Remove whitespace and quotes for compactness.
2915            remove_css_variables (bool): Embed CSS variable definitions.
2916
2917        Returns:
2918            str: SVG markup for the aspect grid only.
2919        """
2920
2921        with open(
2922            Path(__file__).parent / "templates" / "aspect_grid_only.xml",
2923            "r",
2924            encoding="utf-8",
2925            errors="ignore",
2926        ) as f:
2927            template = f.read()
2928
2929        template_dict = self._create_template_dictionary()
2930
2931        if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
2932            aspects_grid = draw_transit_aspect_grid(
2933                self.chart_colors_settings["paper_0"],
2934                self.available_planets_setting,
2935                self.aspects_list,
2936            )
2937        else:
2938            aspects_grid = draw_aspect_grid(
2939                self.chart_colors_settings["paper_0"],
2940                self.available_planets_setting,
2941                self.aspects_list,
2942                x_start=50,
2943                y_start=250,
2944            )
2945
2946        # Use a compact, known-good viewBox that frames the grid
2947        viewbox_override = self._grid_only_viewbox()
2948
2949        template = Template(template).substitute({**template_dict.model_dump(), "makeAspectGrid": aspects_grid, "viewbox": viewbox_override})
2950
2951        if remove_css_variables:
2952            template = inline_css_variables_in_svg(template)
2953
2954        if minify:
2955            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2956
2957        else:
2958            template = template.replace('"', "'")
2959
2960        return template
2961
2962    def save_aspect_grid_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2963        """
2964        Generate and save aspect-grid-only chart SVG to disk.
2965
2966        Calls generate_aspect_grid_only_svg_string and writes a file named
2967        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.
2968
2969        Args:
2970            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2971                If None, defaults to the user's home directory.
2972            filename (str or None): Custom filename for the SVG file (without extension).
2973                If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only".
2974            minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output.
2975            remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.
2976
2977        Returns:
2978            None
2979        """
2980
2981        template = self.generate_aspect_grid_only_svg_string(minify, remove_css_variables)
2982
2983        # Convert output_path to Path object, default to home directory
2984        output_directory = Path(output_path) if output_path is not None else Path.home()
2985
2986        # Determine filename
2987        if filename is not None:
2988            chartname = output_directory / f"{filename}.svg"
2989        else:
2990            # Use default filename pattern
2991            chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2992            chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Aspect Grid Only.svg"
2993
2994        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2995            output_file.write(template)
2996
2997        print(f"SVG Generated Correctly in: {chartname}")

ChartDrawer generates astrological chart visualizations as SVG files from pre-computed chart data.

This class is designed for pure visualization and requires chart data to be pre-computed using ChartDataFactory. This separation ensures clean architecture where ChartDataFactory handles all calculations (aspects, element/quality distributions, subjects) while ChartDrawer focuses solely on rendering SVG visualizations.

ChartDrawer supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs for various chart types including Natal, Transit, Synastry, and Composite. Charts are rendered using XML templates and drawing utilities, with customizable themes, language, and visual settings.

The generated SVG files are optimized for web use and can be saved to any specified destination path using the save_svg method.

NOTE: The generated SVG files are optimized for web use, opening in browsers. If you want to use them in other applications, you might need to adjust the SVG settings or styles.

Args: chart_data (ChartDataModel): Pre-computed chart data from ChartDataFactory containing all subjects, aspects, element/quality distributions, and other analytical data. This is the ONLY source of chart information - no calculations are performed by ChartDrawer. theme (KerykeionChartTheme, optional): CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'. double_chart_aspect_grid_type (Literal['list', 'table'], optional): Specifies rendering style for double-chart aspect grids. Defaults to 'list'. chart_language (KerykeionChartLanguage, optional): Language code for chart labels. Defaults to 'EN'. language_pack (dict | None, optional): Additional translations merged over the bundled defaults for the selected language. Useful to introduce new languages or override existing labels. transparent_background (bool, optional): Whether to use a transparent background instead of the theme color. Defaults to False.

Public Methods: makeTemplate(minify=False, remove_css_variables=False) -> str: Render the full chart SVG as a string without writing to disk. Use minify=True to remove whitespace and quotes, and remove_css_variables=True to embed CSS vars.

save_svg(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
    Generate and write the full chart SVG file to the specified path.
    If output_path is None, saves to the user's home directory.
    If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart.svg'.

makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
    Render only the chart wheel (no aspect grid) as an SVG string.

save_wheel_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
    Generate and write the wheel-only SVG file to the specified path.
    If output_path is None, saves to the user's home directory.
    If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Wheel Only.svg'.

makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
    Render only the aspect grid as an SVG string.

save_aspect_grid_only_svg_file(output_path=None, filename=None, minify=False, remove_css_variables=False) -> None:
    Generate and write the aspect-grid-only SVG file to the specified path.
    If output_path is None, saves to the user's home directory.
    If filename is None, uses default pattern: '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.

Example:

from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory from kerykeion.chart_data_factory import ChartDataFactory from kerykeion.charts.chart_drawer import ChartDrawer

Step 1: Create subject

subject = AstrologicalSubjectFactory.from_birth_data("John", 1990, 1, 1, 12, 0, "London", "GB")

Step 2: Pre-compute chart data

chart_data = ChartDataFactory.create_natal_chart_data(subject)

Step 3: Create visualization

chart_drawer = ChartDrawer(chart_data=chart_data, theme="classic") chart_drawer.save_svg() # Saves to home directory with default filename

Or specify custom path and filename:

chart_drawer.save_svg("/path/to/output/directory", "my_custom_chart")

ChartDrawer( chart_data: Union[SingleChartDataModel, DualChartDataModel], *, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry', 'black-and-white']] = 'classic', double_chart_aspect_grid_type: Literal['list', 'table'] = 'list', chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI'] = 'EN', language_pack: Optional[Mapping[str, Any]] = None, external_view: bool = False, transparent_background: bool = False, colors_settings: dict = {'paper_0': 'var(--kerykeion-chart-color-paper-0)', 'paper_1': 'var(--kerykeion-chart-color-paper-1)', 'zodiac_bg_0': 'var(--kerykeion-chart-color-zodiac-bg-0)', 'zodiac_bg_1': 'var(--kerykeion-chart-color-zodiac-bg-1)', 'zodiac_bg_2': 'var(--kerykeion-chart-color-zodiac-bg-2)', 'zodiac_bg_3': 'var(--kerykeion-chart-color-zodiac-bg-3)', 'zodiac_bg_4': 'var(--kerykeion-chart-color-zodiac-bg-4)', 'zodiac_bg_5': 'var(--kerykeion-chart-color-zodiac-bg-5)', 'zodiac_bg_6': 'var(--kerykeion-chart-color-zodiac-bg-6)', 'zodiac_bg_7': 'var(--kerykeion-chart-color-zodiac-bg-7)', 'zodiac_bg_8': 'var(--kerykeion-chart-color-zodiac-bg-8)', 'zodiac_bg_9': 'var(--kerykeion-chart-color-zodiac-bg-9)', 'zodiac_bg_10': 'var(--kerykeion-chart-color-zodiac-bg-10)', 'zodiac_bg_11': 'var(--kerykeion-chart-color-zodiac-bg-11)', 'zodiac_icon_0': 'var(--kerykeion-chart-color-zodiac-icon-0)', 'zodiac_icon_1': 'var(--kerykeion-chart-color-zodiac-icon-1)', 'zodiac_icon_2': 'var(--kerykeion-chart-color-zodiac-icon-2)', 'zodiac_icon_3': 'var(--kerykeion-chart-color-zodiac-icon-3)', 'zodiac_icon_4': 'var(--kerykeion-chart-color-zodiac-icon-4)', 'zodiac_icon_5': 'var(--kerykeion-chart-color-zodiac-icon-5)', 'zodiac_icon_6': 'var(--kerykeion-chart-color-zodiac-icon-6)', 'zodiac_icon_7': 'var(--kerykeion-chart-color-zodiac-icon-7)', 'zodiac_icon_8': 'var(--kerykeion-chart-color-zodiac-icon-8)', 'zodiac_icon_9': 'var(--kerykeion-chart-color-zodiac-icon-9)', 'zodiac_icon_10': 'var(--kerykeion-chart-color-zodiac-icon-10)', 'zodiac_icon_11': 'var(--kerykeion-chart-color-zodiac-icon-11)', 'zodiac_radix_ring_0': 'var(--kerykeion-chart-color-zodiac-radix-ring-0)', 'zodiac_radix_ring_1': 'var(--kerykeion-chart-color-zodiac-radix-ring-1)', 'zodiac_radix_ring_2': 'var(--kerykeion-chart-color-zodiac-radix-ring-2)', 'zodiac_transit_ring_0': 'var(--kerykeion-chart-color-zodiac-transit-ring-0)', 'zodiac_transit_ring_1': 'var(--kerykeion-chart-color-zodiac-transit-ring-1)', 'zodiac_transit_ring_2': 'var(--kerykeion-chart-color-zodiac-transit-ring-2)', 'zodiac_transit_ring_3': 'var(--kerykeion-chart-color-zodiac-transit-ring-3)', 'houses_radix_line': 'var(--kerykeion-chart-color-houses-radix-line)', 'houses_transit_line': 'var(--kerykeion-chart-color-houses-transit-line)', 'lunar_phase_0': 'var(--kerykeion-chart-color-lunar-phase-0)', 'lunar_phase_1': 'var(--kerykeion-chart-color-lunar-phase-1)'}, celestial_points_settings: list[dict] = [{'id': 0, 'name': 'Sun', 'color': 'var(--kerykeion-chart-color-sun)', 'element_points': 40, 'label': 'Sun'}, {'id': 1, 'name': 'Moon', 'color': 'var(--kerykeion-chart-color-moon)', 'element_points': 40, 'label': 'Moon'}, {'id': 2, 'name': 'Mercury', 'color': 'var(--kerykeion-chart-color-mercury)', 'element_points': 15, 'label': 'Mercury'}, {'id': 3, 'name': 'Venus', 'color': 'var(--kerykeion-chart-color-venus)', 'element_points': 15, 'label': 'Venus'}, {'id': 4, 'name': 'Mars', 'color': 'var(--kerykeion-chart-color-mars)', 'element_points': 15, 'label': 'Mars'}, {'id': 5, 'name': 'Jupiter', 'color': 'var(--kerykeion-chart-color-jupiter)', 'element_points': 10, 'label': 'Jupiter'}, {'id': 6, 'name': 'Saturn', 'color': 'var(--kerykeion-chart-color-saturn)', 'element_points': 10, 'label': 'Saturn'}, {'id': 7, 'name': 'Uranus', 'color': 'var(--kerykeion-chart-color-uranus)', 'element_points': 10, 'label': 'Uranus'}, {'id': 8, 'name': 'Neptune', 'color': 'var(--kerykeion-chart-color-neptune)', 'element_points': 10, 'label': 'Neptune'}, {'id': 9, 'name': 'Pluto', 'color': 'var(--kerykeion-chart-color-pluto)', 'element_points': 10, 'label': 'Pluto'}, {'id': 10, 'name': 'Mean_North_Lunar_Node', 'color': 'var(--kerykeion-chart-color-mean-node)', 'element_points': 0, 'label': 'Mean_North_Lunar_Node'}, {'id': 11, 'name': 'True_North_Lunar_Node', 'color': 'var(--kerykeion-chart-color-true-node)', 'element_points': 0, 'label': 'True_North_Lunar_Node'}, {'id': 12, 'name': 'Chiron', 'color': 'var(--kerykeion-chart-color-chiron)', 'element_points': 0, 'label': 'Chiron'}, {'id': 13, 'name': 'Ascendant', 'color': 'var(--kerykeion-chart-color-first-house)', 'element_points': 40, 'label': 'Asc'}, {'id': 14, 'name': 'Medium_Coeli', 'color': 'var(--kerykeion-chart-color-tenth-house)', 'element_points': 20, 'label': 'Mc'}, {'id': 15, 'name': 'Descendant', 'color': 'var(--kerykeion-chart-color-seventh-house)', 'element_points': 0, 'label': 'Dsc'}, {'id': 16, 'name': 'Imum_Coeli', 'color': 'var(--kerykeion-chart-color-fourth-house)', 'element_points': 0, 'label': 'Ic'}, {'id': 17, 'name': 'Mean_Lilith', 'color': 'var(--kerykeion-chart-color-mean-lilith)', 'element_points': 0, 'label': 'Mean_Lilith'}, {'id': 18, 'name': 'Mean_South_Lunar_Node', 'color': 'var(--kerykeion-chart-color-mean-node)', 'element_points': 0, 'label': 'Mean_South_Lunar_Node'}, {'id': 19, 'name': 'True_South_Lunar_Node', 'color': 'var(--kerykeion-chart-color-true-node)', 'element_points': 0, 'label': 'True_South_Lunar_Node'}, {'id': 20, 'name': 'True_Lilith', 'color': 'var(--kerykeion-chart-color-mean-lilith)', 'element_points': 0, 'label': 'True_Lilith'}, {'id': 21, 'name': 'Earth', 'color': 'var(--kerykeion-chart-color-earth)', 'element_points': 0, 'label': 'Earth'}, {'id': 22, 'name': 'Pholus', 'color': 'var(--kerykeion-chart-color-pholus)', 'element_points': 0, 'label': 'Pholus'}, {'id': 23, 'name': 'Ceres', 'color': 'var(--kerykeion-chart-color-ceres)', 'element_points': 0, 'label': 'Ceres'}, {'id': 24, 'name': 'Pallas', 'color': 'var(--kerykeion-chart-color-pallas)', 'element_points': 0, 'label': 'Pallas'}, {'id': 25, 'name': 'Juno', 'color': 'var(--kerykeion-chart-color-juno)', 'element_points': 0, 'label': 'Juno'}, {'id': 26, 'name': 'Vesta', 'color': 'var(--kerykeion-chart-color-vesta)', 'element_points': 0, 'label': 'Vesta'}, {'id': 27, 'name': 'Eris', 'color': 'var(--kerykeion-chart-color-eris)', 'element_points': 0, 'label': 'Eris'}, {'id': 28, 'name': 'Sedna', 'color': 'var(--kerykeion-chart-color-sedna)', 'element_points': 0, 'label': 'Sedna'}, {'id': 29, 'name': 'Haumea', 'color': 'var(--kerykeion-chart-color-haumea)', 'element_points': 0, 'label': 'Haumea'}, {'id': 30, 'name': 'Makemake', 'color': 'var(--kerykeion-chart-color-makemake)', 'element_points': 0, 'label': 'Makemake'}, {'id': 31, 'name': 'Ixion', 'color': 'var(--kerykeion-chart-color-ixion)', 'element_points': 0, 'label': 'Ixion'}, {'id': 32, 'name': 'Orcus', 'color': 'var(--kerykeion-chart-color-orcus)', 'element_points': 0, 'label': 'Orcus'}, {'id': 33, 'name': 'Quaoar', 'color': 'var(--kerykeion-chart-color-quaoar)', 'element_points': 0, 'label': 'Quaoar'}, {'id': 34, 'name': 'Regulus', 'color': 'var(--kerykeion-chart-color-regulus)', 'element_points': 0, 'label': 'Regulus'}, {'id': 35, 'name': 'Spica', 'color': 'var(--kerykeion-chart-color-spica)', 'element_points': 0, 'label': 'Spica'}, {'id': 36, 'name': 'Pars_Fortunae', 'color': 'var(--kerykeion-chart-color-pars-fortunae)', 'element_points': 5, 'label': 'Fortune'}, {'id': 37, 'name': 'Pars_Spiritus', 'color': 'var(--kerykeion-chart-color-pars-spiritus)', 'element_points': 0, 'label': 'Spirit'}, {'id': 38, 'name': 'Pars_Amoris', 'color': 'var(--kerykeion-chart-color-pars-amoris)', 'element_points': 0, 'label': 'Love'}, {'id': 39, 'name': 'Pars_Fidei', 'color': 'var(--kerykeion-chart-color-pars-fidei)', 'element_points': 0, 'label': 'Faith'}, {'id': 40, 'name': 'Vertex', 'color': 'var(--kerykeion-chart-color-vertex)', 'element_points': 0, 'label': 'Vertex'}, {'id': 41, 'name': 'Anti_Vertex', 'color': 'var(--kerykeion-chart-color-anti-vertex)', 'element_points': 0, 'label': 'Anti_Vertex'}], aspects_settings: list[dict] = [{'degree': 0, 'name': 'conjunction', 'is_major': True, 'color': 'var(--kerykeion-chart-color-conjunction)'}, {'degree': 30, 'name': 'semi-sextile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-semi-sextile)'}, {'degree': 45, 'name': 'semi-square', 'is_major': False, 'color': 'var(--kerykeion-chart-color-semi-square)'}, {'degree': 60, 'name': 'sextile', 'is_major': True, 'color': 'var(--kerykeion-chart-color-sextile)'}, {'degree': 72, 'name': 'quintile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-quintile)'}, {'degree': 90, 'name': 'square', 'is_major': True, 'color': 'var(--kerykeion-chart-color-square)'}, {'degree': 120, 'name': 'trine', 'is_major': True, 'color': 'var(--kerykeion-chart-color-trine)'}, {'degree': 135, 'name': 'sesquiquadrate', 'is_major': False, 'color': 'var(--kerykeion-chart-color-sesquiquadrate)'}, {'degree': 144, 'name': 'biquintile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-biquintile)'}, {'degree': 150, 'name': 'quincunx', 'is_major': False, 'color': 'var(--kerykeion-chart-color-quincunx)'}, {'degree': 180, 'name': 'opposition', 'is_major': True, 'color': 'var(--kerykeion-chart-color-opposition)'}], custom_title: Optional[str] = None, show_house_position_comparison: bool = True, show_cusp_position_comparison: bool = False, auto_size: bool = True, padding: int = 20, show_degree_indicators: bool = True, show_aspect_icons: bool = True)
242    def __init__(
243        self,
244        chart_data: "ChartDataModel",
245        *,
246        theme: Union[KerykeionChartTheme, None] = "classic",
247        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
248        chart_language: KerykeionChartLanguage = "EN",
249        language_pack: Optional[Mapping[str, Any]] = None,
250        external_view: bool = False,
251        transparent_background: bool = False,
252        colors_settings: dict = DEFAULT_CHART_COLORS,
253        celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
254        aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
255        custom_title: Union[str, None] = None,
256        show_house_position_comparison: bool = True,
257        show_cusp_position_comparison: bool = False,
258        auto_size: bool = True,
259        padding: int = 20,
260        show_degree_indicators: bool = True,
261        show_aspect_icons: bool = True,
262    ):
263        """
264        Initialize the chart visualizer with pre-computed chart data.
265
266        Args:
267            chart_data (ChartDataModel):
268                Pre-computed chart data from ChartDataFactory containing all subjects,
269                aspects, element/quality distributions, and other analytical data.
270            theme (KerykeionChartTheme or None, optional):
271                CSS theme to apply; None for default styling.
272            double_chart_aspect_grid_type (Literal['list','table'], optional):
273                Layout style for double-chart aspect grids ('list' or 'table').
274            chart_language (KerykeionChartLanguage, optional):
275                Language code for chart labels (e.g., 'EN', 'IT').
276            language_pack (dict | None, optional):
277                Additional translations merged over the bundled defaults for the
278                selected language. Useful to introduce new languages or override
279                existing labels.
280            external_view (bool, optional):
281                Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False.
282            transparent_background (bool, optional):
283                Whether to use a transparent background instead of the theme color. Defaults to False.
284            custom_title (str or None, optional):
285                Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None.
286            show_house_position_comparison (bool, optional):
287                Whether to render the house position comparison grid (when supported by the chart type).
288                Defaults to True. Set to False to hide the table and reclaim horizontal space.
289            show_cusp_position_comparison (bool, optional):
290                Whether to render the cusp position comparison grid alongside or in place of the house comparison.
291                Defaults to False so cusp tables are only shown when explicitly requested.
292        """
293        # --------------------
294        # COMMON INITIALIZATION
295        # --------------------
296        self.chart_language = chart_language
297        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
298        self.transparent_background = transparent_background
299        self.external_view = external_view
300        self.chart_colors_settings = deepcopy(colors_settings)
301        self.planets_settings = [dict(body) for body in celestial_points_settings]
302        self.aspects_settings = [dict(aspect) for aspect in aspects_settings]
303        self.custom_title = custom_title
304        self.show_house_position_comparison = show_house_position_comparison
305        self.show_cusp_position_comparison = show_cusp_position_comparison
306        self.show_degree_indicators = show_degree_indicators
307        self.show_aspect_icons = show_aspect_icons
308        self.auto_size = auto_size
309        self._padding = padding
310        self._vertical_offsets: dict[str, int] = self._BASE_VERTICAL_OFFSETS.copy()
311
312        # Extract data from ChartDataModel
313        self.chart_data = chart_data
314        self.chart_type = chart_data.chart_type
315        self.active_points = chart_data.active_points
316        self.active_aspects = chart_data.active_aspects
317
318        # Extract subjects based on chart type
319        if chart_data.chart_type in ["Natal", "Composite", "SingleReturnChart"]:
320            # SingleChartDataModel
321            self.first_obj = getattr(chart_data, 'subject')
322            self.second_obj = None
323
324        else:  # DualChartDataModel for Transit, Synastry, DualReturnChart
325            self.first_obj = getattr(chart_data, 'first_subject')
326            self.second_obj = getattr(chart_data, 'second_subject')
327
328        # Load settings
329        self._load_language_settings(language_pack)
330
331        # Default radius for all charts
332        self.main_radius = 240
333
334        # Configure available planets from chart data
335        self.available_planets_setting = []
336        for body in self.planets_settings:
337            if body["name"] in self.active_points:
338                body["is_active"] = True
339                self.available_planets_setting.append(body)  # type: ignore[arg-type]
340
341        active_points_count = len(self.available_planets_setting)
342        if active_points_count > 24:
343            logger.warning(
344                "ChartDrawer detected %s active celestial points; rendering may look crowded beyond 24.",
345                active_points_count,
346            )
347
348        # Set available celestial points
349        available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
350        self.available_kerykeion_celestial_points = self._collect_subject_points(
351            self.first_obj,
352            available_celestial_points_names,
353        )
354
355        # Collect secondary subject points for dual charts using the same active set
356        self.t_available_kerykeion_celestial_points: list[KerykeionPointModel] = []
357        if self.second_obj is not None:
358            self.t_available_kerykeion_celestial_points = self._collect_subject_points(
359                self.second_obj,
360                available_celestial_points_names,
361            )
362
363        # ------------------------
364        # CHART TYPE SPECIFIC SETUP FROM CHART DATA
365        # ------------------------
366
367        if self.chart_type == "Natal":
368            # --- NATAL CHART SETUP ---
369
370            # Extract aspects from pre-computed chart data
371            self.aspects_list = chart_data.aspects
372
373            # Screen size
374            self.height = self._DEFAULT_HEIGHT
375            self.width = self._DEFAULT_NATAL_WIDTH
376
377            # Get location and coordinates
378            self.location, self.geolat, self.geolon = self._get_location_info()
379
380            # Circle radii - depends on external_view
381            if self.external_view:
382                self.first_circle_radius = 56
383                self.second_circle_radius = 92
384                self.third_circle_radius = 112
385            else:
386                self.first_circle_radius = 0
387                self.second_circle_radius = 36
388                self.third_circle_radius = 120
389
390        elif self.chart_type == "Composite":
391            # --- COMPOSITE CHART SETUP ---
392
393            # Extract aspects from pre-computed chart data
394            self.aspects_list = chart_data.aspects
395
396            # Screen size
397            self.height = self._DEFAULT_HEIGHT
398            self.width = self._DEFAULT_NATAL_WIDTH
399
400            # Get location and coordinates
401            self.location, self.geolat, self.geolon = self._get_location_info()
402
403            # Circle radii
404            self.first_circle_radius = 0
405            self.second_circle_radius = 36
406            self.third_circle_radius = 120
407
408        elif self.chart_type == "Transit":
409            # --- TRANSIT CHART SETUP ---
410
411            # Extract aspects from pre-computed chart data
412            self.aspects_list = chart_data.aspects
413
414            # Screen size
415            self.height = self._DEFAULT_HEIGHT
416            if self.double_chart_aspect_grid_type == "table":
417                self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
418            else:
419                self.width = self._DEFAULT_FULL_WIDTH
420
421            # Get location and coordinates
422            self.location, self.geolat, self.geolon = self._get_location_info()
423
424            # Circle radii
425            self.first_circle_radius = 0
426            self.second_circle_radius = 36
427            self.third_circle_radius = 120
428
429        elif self.chart_type == "Synastry":
430            # --- SYNASTRY CHART SETUP ---
431
432            # Extract aspects from pre-computed chart data
433            self.aspects_list = chart_data.aspects
434
435            # Screen size
436            self.height = self._DEFAULT_HEIGHT
437            self.width = self._DEFAULT_SYNASTRY_WIDTH
438
439            # Get location and coordinates
440            self.location, self.geolat, self.geolon = self._get_location_info()
441
442            # Circle radii
443            self.first_circle_radius = 0
444            self.second_circle_radius = 36
445            self.third_circle_radius = 120
446
447        elif self.chart_type == "DualReturnChart":
448            # --- RETURN CHART SETUP ---
449
450            # Extract aspects from pre-computed chart data
451            self.aspects_list = chart_data.aspects
452
453            # Screen size
454            self.height = self._DEFAULT_HEIGHT
455            self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
456
457            # Get location and coordinates
458            self.location, self.geolat, self.geolon = self._get_location_info()
459
460            # Circle radii
461            self.first_circle_radius = 0
462            self.second_circle_radius = 36
463            self.third_circle_radius = 120
464
465        elif self.chart_type == "SingleReturnChart":
466            # --- SINGLE WHEEL RETURN CHART SETUP ---
467
468            # Extract aspects from pre-computed chart data
469            self.aspects_list = chart_data.aspects
470
471            # Screen size
472            self.height = self._DEFAULT_HEIGHT
473            self.width = self._DEFAULT_NATAL_WIDTH
474
475            # Get location and coordinates
476            self.location, self.geolat, self.geolon = self._get_location_info()
477
478            # Circle radii
479            self.first_circle_radius = 0
480            self.second_circle_radius = 36
481            self.third_circle_radius = 120
482
483        self._apply_house_comparison_width_override()
484
485        # --------------------
486        # FINAL COMMON SETUP FROM CHART DATA
487        # --------------------
488
489        # Extract pre-computed element and quality distributions
490        self.fire = chart_data.element_distribution.fire
491        self.earth = chart_data.element_distribution.earth
492        self.air = chart_data.element_distribution.air
493        self.water = chart_data.element_distribution.water
494
495        self.cardinal = chart_data.quality_distribution.cardinal
496        self.fixed = chart_data.quality_distribution.fixed
497        self.mutable = chart_data.quality_distribution.mutable
498
499        # Set up theme
500        if theme not in get_args(KerykeionChartTheme) and theme is not None:
501            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
502
503        self.set_up_theme(theme)
504
505        self._apply_dynamic_height_adjustment()
506        self._adjust_height_for_extended_aspect_columns()
507        # Reconcile width with the updated layout once height adjustments are known.
508        if self.auto_size:
509            self._update_width_to_content()

Initialize the chart visualizer with pre-computed chart data.

Args: chart_data (ChartDataModel): Pre-computed chart data from ChartDataFactory containing all subjects, aspects, element/quality distributions, and other analytical data. theme (KerykeionChartTheme or None, optional): CSS theme to apply; None for default styling. double_chart_aspect_grid_type (Literal['list','table'], optional): Layout style for double-chart aspect grids ('list' or 'table'). chart_language (KerykeionChartLanguage, optional): Language code for chart labels (e.g., 'EN', 'IT'). language_pack (dict | None, optional): Additional translations merged over the bundled defaults for the selected language. Useful to introduce new languages or override existing labels. external_view (bool, optional): Whether to use external visualization (planets on outer ring) for single-subject charts. Defaults to False. transparent_background (bool, optional): Whether to use a transparent background instead of the theme color. Defaults to False. custom_title (str or None, optional): Custom title for the chart. If None, the default title will be used based on chart type. Defaults to None. show_house_position_comparison (bool, optional): Whether to render the house position comparison grid (when supported by the chart type). Defaults to True. Set to False to hide the table and reclaim horizontal space. show_cusp_position_comparison (bool, optional): Whether to render the cusp position comparison grid alongside or in place of the house comparison. Defaults to False so cusp tables are only shown when explicitly requested.

first_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel]
second_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel, NoneType]
chart_type: Literal['Natal', 'Synastry', 'Transit', 'Composite', 'DualReturnChart', 'SingleReturnChart']
theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry', 'black-and-white']]
double_chart_aspect_grid_type: Literal['list', 'table']
chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI']
active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]
active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect]
transparent_background: bool
external_view: bool
show_house_position_comparison: bool
custom_title: Optional[str]
fire: float
earth: float
air: float
water: float
first_circle_radius: float
second_circle_radius: float
third_circle_radius: float
width: Union[float, int]
language_settings: dict
chart_colors_settings: dict
planets_settings: list[dict[typing.Any, typing.Any]]
aspects_settings: list[dict[typing.Any, typing.Any]]
available_planets_setting: List[kerykeion.schemas.settings_models.KerykeionSettingsCelestialPointModel]
height: float
location: str
geolat: float
geolon: float
template: str
show_cusp_position_comparison
show_degree_indicators
show_aspect_icons
auto_size
chart_data
main_radius
available_kerykeion_celestial_points
t_available_kerykeion_celestial_points: list[kerykeion.schemas.kr_models.KerykeionPointModel]
cardinal
fixed
mutable
def set_up_theme( self, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry', 'black-and-white']] = None) -> None:
1135    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
1136        """
1137        Load and apply a CSS theme for the chart visualization.
1138
1139        Args:
1140            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
1141        """
1142        if theme is None:
1143            self.color_style_tag = ""
1144            return
1145
1146        theme_dir = Path(__file__).parent / "themes"
1147
1148        with open(theme_dir / f"{theme}.css", "r") as f:
1149            self.color_style_tag = f.read()

Load and apply a CSS theme for the chart visualization.

Args: theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.

def generate_svg_string( self, minify: bool = False, remove_css_variables=False, *, custom_title: Optional[str] = None) -> str:
2747    def generate_svg_string(self, minify: bool = False, remove_css_variables=False, *, custom_title: Union[str, None] = None) -> str:
2748        """
2749        Render the full chart SVG as a string.
2750
2751        Reads the XML template, substitutes variables, and optionally inlines CSS
2752        variables and minifies the output.
2753
2754        Args:
2755            minify (bool): Remove whitespace and quotes for compactness.
2756            remove_css_variables (bool): Embed CSS variable definitions.
2757            custom_title (str or None): Optional override for the SVG title.
2758
2759        Returns:
2760            str: SVG markup as a string.
2761        """
2762        td = self._create_template_dictionary(custom_title=custom_title)
2763
2764        DATA_DIR = Path(__file__).parent
2765        xml_svg = DATA_DIR / "templates" / "chart.xml"
2766
2767        # read template
2768        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
2769            template = Template(f.read()).substitute(td.model_dump())
2770
2771        # return filename
2772
2773        logger.debug("Template dictionary includes %s fields", len(td.model_dump()))
2774
2775        if remove_css_variables:
2776            template = inline_css_variables_in_svg(template)
2777
2778        if minify:
2779            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2780
2781        else:
2782            template = template.replace('"', "'")
2783
2784        return template

Render the full chart SVG as a string.

Reads the XML template, substitutes variables, and optionally inlines CSS variables and minifies the output.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions. custom_title (str or None): Optional override for the SVG title.

Returns: str: SVG markup as a string.

def save_svg( self, output_path: Union[str, pathlib.Path, NoneType] = None, filename: Optional[str] = None, minify: bool = False, remove_css_variables=False, *, custom_title: Optional[str] = None):
2786    def save_svg(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False, *, custom_title: Union[str, None] = None):
2787        """
2788        Generate and save the full chart SVG to disk.
2789
2790        Calls generate_svg_string to render the SVG, then writes a file named
2791        "{subject.name} - {chart_type} Chart.svg" in the specified output directory.
2792
2793        Args:
2794            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2795                If None, defaults to the user's home directory.
2796            filename (str or None): Custom filename for the SVG file (without extension).
2797                If None, uses the default pattern: "{subject.name} - {chart_type} Chart".
2798            minify (bool): Pass-through to generate_svg_string for compact output.
2799            remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables.
2800            custom_title (str or None): Optional override for the SVG title.
2801
2802        Returns:
2803            None
2804        """
2805
2806        self.template = self.generate_svg_string(minify, remove_css_variables, custom_title=custom_title)
2807
2808        # Convert output_path to Path object, default to home directory
2809        output_directory = Path(output_path) if output_path is not None else Path.home()
2810
2811        # Determine filename
2812        if filename is not None:
2813            chartname = output_directory / f"{filename}.svg"
2814        else:
2815            # Use default filename pattern
2816            chart_type_for_filename = self.chart_type
2817
2818            if self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
2819                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Lunar Return.svg"
2820            elif self.chart_type == "DualReturnChart" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
2821                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Solar Return.svg"
2822            else:
2823                chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart.svg"
2824
2825        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2826            output_file.write(self.template)
2827
2828        print(f"SVG Generated Correctly in: {chartname}")

Generate and save the full chart SVG to disk.

Calls generate_svg_string to render the SVG, then writes a file named "{subject.name} - {chart_type} Chart.svg" in the specified output directory.

Args: output_path (str, Path, or None): Directory path where the SVG file will be saved. If None, defaults to the user's home directory. filename (str or None): Custom filename for the SVG file (without extension). If None, uses the default pattern: "{subject.name} - {chart_type} Chart". minify (bool): Pass-through to generate_svg_string for compact output. remove_css_variables (bool): Pass-through to generate_svg_string to embed CSS variables. custom_title (str or None): Optional override for the SVG title.

Returns: None

def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2830    def generate_wheel_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2831        """
2832        Render the wheel-only chart SVG as a string.
2833
2834        Reads the wheel-only XML template, substitutes chart data, and applies optional
2835        CSS inlining and minification.
2836
2837        Args:
2838            minify (bool): Remove whitespace and quotes for compactness.
2839            remove_css_variables (bool): Embed CSS variable definitions.
2840
2841        Returns:
2842            str: SVG markup for the chart wheel only.
2843        """
2844
2845        with open(
2846            Path(__file__).parent / "templates" / "wheel_only.xml",
2847            "r",
2848            encoding="utf-8",
2849            errors="ignore",
2850        ) as f:
2851            template = f.read()
2852
2853        template_dict = self._create_template_dictionary()
2854        # Use a compact viewBox specific for the wheel-only rendering
2855        wheel_viewbox = self._wheel_only_viewbox()
2856        template = Template(template).substitute({**template_dict.model_dump(), "viewbox": wheel_viewbox})
2857
2858        if remove_css_variables:
2859            template = inline_css_variables_in_svg(template)
2860
2861        if minify:
2862            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2863
2864        else:
2865            template = template.replace('"', "'")
2866
2867        return template

Render the wheel-only chart SVG as a string.

Reads the wheel-only XML template, substitutes chart data, and applies optional CSS inlining and minification.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions.

Returns: str: SVG markup for the chart wheel only.

def save_wheel_only_svg_file( self, output_path: Union[str, pathlib.Path, NoneType] = None, filename: Optional[str] = None, minify: bool = False, remove_css_variables=False):
2869    def save_wheel_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2870        """
2871        Generate and save wheel-only chart SVG to disk.
2872
2873        Calls generate_wheel_only_svg_string and writes a file named
2874        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.
2875
2876        Args:
2877            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2878                If None, defaults to the user's home directory.
2879            filename (str or None): Custom filename for the SVG file (without extension).
2880                If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only".
2881            minify (bool): Pass-through to generate_wheel_only_svg_string for compact output.
2882            remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.
2883
2884        Returns:
2885            None
2886        """
2887
2888        template = self.generate_wheel_only_svg_string(minify, remove_css_variables)
2889
2890        # Convert output_path to Path object, default to home directory
2891        output_directory = Path(output_path) if output_path is not None else Path.home()
2892
2893        # Determine filename
2894        if filename is not None:
2895            chartname = output_directory / f"{filename}.svg"
2896        else:
2897            # Use default filename pattern
2898            chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2899            chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Wheel Only.svg"
2900
2901        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2902            output_file.write(template)
2903
2904        print(f"SVG Generated Correctly in: {chartname}")

Generate and save wheel-only chart SVG to disk.

Calls generate_wheel_only_svg_string and writes a file named "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the specified output directory.

Args: output_path (str, Path, or None): Directory path where the SVG file will be saved. If None, defaults to the user's home directory. filename (str or None): Custom filename for the SVG file (without extension). If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Wheel Only". minify (bool): Pass-through to generate_wheel_only_svg_string for compact output. remove_css_variables (bool): Pass-through to generate_wheel_only_svg_string to embed CSS variables.

Returns: None

def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2906    def generate_aspect_grid_only_svg_string(self, minify: bool = False, remove_css_variables=False):
2907        """
2908        Render the aspect-grid-only chart SVG as a string.
2909
2910        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
2911        and applies optional CSS inlining and minification.
2912
2913        Args:
2914            minify (bool): Remove whitespace and quotes for compactness.
2915            remove_css_variables (bool): Embed CSS variable definitions.
2916
2917        Returns:
2918            str: SVG markup for the aspect grid only.
2919        """
2920
2921        with open(
2922            Path(__file__).parent / "templates" / "aspect_grid_only.xml",
2923            "r",
2924            encoding="utf-8",
2925            errors="ignore",
2926        ) as f:
2927            template = f.read()
2928
2929        template_dict = self._create_template_dictionary()
2930
2931        if self.chart_type in ["Transit", "Synastry", "DualReturnChart"]:
2932            aspects_grid = draw_transit_aspect_grid(
2933                self.chart_colors_settings["paper_0"],
2934                self.available_planets_setting,
2935                self.aspects_list,
2936            )
2937        else:
2938            aspects_grid = draw_aspect_grid(
2939                self.chart_colors_settings["paper_0"],
2940                self.available_planets_setting,
2941                self.aspects_list,
2942                x_start=50,
2943                y_start=250,
2944            )
2945
2946        # Use a compact, known-good viewBox that frames the grid
2947        viewbox_override = self._grid_only_viewbox()
2948
2949        template = Template(template).substitute({**template_dict.model_dump(), "makeAspectGrid": aspects_grid, "viewbox": viewbox_override})
2950
2951        if remove_css_variables:
2952            template = inline_css_variables_in_svg(template)
2953
2954        if minify:
2955            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
2956
2957        else:
2958            template = template.replace('"', "'")
2959
2960        return template

Render the aspect-grid-only chart SVG as a string.

Reads the aspect-grid XML template, generates the aspect grid based on chart type, and applies optional CSS inlining and minification.

Args: minify (bool): Remove whitespace and quotes for compactness. remove_css_variables (bool): Embed CSS variable definitions.

Returns: str: SVG markup for the aspect grid only.

def save_aspect_grid_only_svg_file( self, output_path: Union[str, pathlib.Path, NoneType] = None, filename: Optional[str] = None, minify: bool = False, remove_css_variables=False):
2962    def save_aspect_grid_only_svg_file(self, output_path: Union[str, Path, None] = None, filename: Union[str, None] = None, minify: bool = False, remove_css_variables=False):
2963        """
2964        Generate and save aspect-grid-only chart SVG to disk.
2965
2966        Calls generate_aspect_grid_only_svg_string and writes a file named
2967        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.
2968
2969        Args:
2970            output_path (str, Path, or None): Directory path where the SVG file will be saved.
2971                If None, defaults to the user's home directory.
2972            filename (str or None): Custom filename for the SVG file (without extension).
2973                If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only".
2974            minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output.
2975            remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.
2976
2977        Returns:
2978            None
2979        """
2980
2981        template = self.generate_aspect_grid_only_svg_string(minify, remove_css_variables)
2982
2983        # Convert output_path to Path object, default to home directory
2984        output_directory = Path(output_path) if output_path is not None else Path.home()
2985
2986        # Determine filename
2987        if filename is not None:
2988            chartname = output_directory / f"{filename}.svg"
2989        else:
2990            # Use default filename pattern
2991            chart_type_for_filename = "ExternalNatal" if self.external_view and self.chart_type == "Natal" else self.chart_type
2992            chartname = output_directory / f"{self.first_obj.name} - {chart_type_for_filename} Chart - Aspect Grid Only.svg"
2993
2994        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
2995            output_file.write(template)
2996
2997        print(f"SVG Generated Correctly in: {chartname}")

Generate and save aspect-grid-only chart SVG to disk.

Calls generate_aspect_grid_only_svg_string and writes a file named "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the specified output directory.

Args: output_path (str, Path, or None): Directory path where the SVG file will be saved. If None, defaults to the user's home directory. filename (str or None): Custom filename for the SVG file (without extension). If None, uses the default pattern: "{subject.name} - {chart_type} Chart - Aspect Grid Only". minify (bool): Pass-through to generate_aspect_grid_only_svg_string for compact output. remove_css_variables (bool): Pass-through to generate_aspect_grid_only_svg_string to embed CSS variables.

Returns: None

class CompositeSubjectFactory:
 59class CompositeSubjectFactory:
 60    """
 61    Factory class to create composite astrological charts from two astrological subjects.
 62
 63    A composite chart represents the relationship between two people by calculating the midpoint
 64    between corresponding planetary positions and house cusps. This creates a single chart
 65    that symbolizes the energy of the relationship itself.
 66
 67    Currently supports the midpoint method for composite chart calculation, where:
 68    - Planetary positions are calculated as the circular mean of corresponding planets
 69    - House cusps are calculated as the circular mean of corresponding houses
 70    - Houses are reordered to maintain consistency with the original house system
 71    - Only common active points between both subjects are included
 72
 73    The resulting composite chart maintains the zodiac type, sidereal mode, houses system,
 74    and perspective type of the input subjects (which must be identical between subjects).
 75
 76    Attributes:
 77        model (CompositeSubjectModel | None): The generated composite subject model
 78        first_subject (AstrologicalSubjectModel): First astrological subject
 79        second_subject (AstrologicalSubjectModel): Second astrological subject
 80        name (str): Name of the composite chart
 81        composite_chart_type (CompositeChartType): Type of composite chart (currently "Midpoint")
 82        zodiac_type (ZodiacType): Zodiac system used (Tropical or Sidereal)
 83        sidereal_mode (SiderealMode | None): Sidereal calculation mode if applicable
 84        houses_system_identifier (HousesSystemIdentifier): House system identifier
 85        houses_system_name (str): Human-readable house system name
 86        perspective_type (PerspectiveType): Astrological perspective type
 87        houses_names_list (list[Houses]): List of house names
 88        active_points (list[AstrologicalPoint]): Common active planetary points
 89
 90    Example:
 91        >>> first_person = AstrologicalSubjectFactory.from_birth_data(
 92        ...     "John", 1990, 1, 1, 12, 0, "New York", "US"
 93        ... )
 94        >>> second_person = AstrologicalSubjectFactory.from_birth_data(
 95        ...     "Jane", 1992, 6, 15, 14, 30, "Los Angeles", "US"
 96        ... )
 97        >>> composite = CompositeSubjectFactory(first_person, second_person)
 98        >>> composite_model = composite.get_midpoint_composite_subject_model()
 99
100    Raises:
101        KerykeionException: When subjects have incompatible settings (different zodiac types,
102                           sidereal modes, house systems, or perspective types)
103    """
104
105    model: Union[CompositeSubjectModel, None]
106    first_subject: AstrologicalSubjectModel
107    second_subject: AstrologicalSubjectModel
108    name: str
109    composite_chart_type: CompositeChartType
110    zodiac_type: ZodiacType
111    sidereal_mode: Union[SiderealMode, None]
112    houses_system_identifier: HousesSystemIdentifier
113    houses_system_name: str
114    perspective_type: PerspectiveType
115    houses_names_list: list[Houses]
116    active_points: list[AstrologicalPoint]
117
118    def __init__(
119            self,
120            first_subject: AstrologicalSubjectModel,
121            second_subject: AstrologicalSubjectModel,
122            chart_name: Union[str, None] = None
123    ):
124        """
125        Initialize the composite subject factory with two astrological subjects.
126
127        Validates that both subjects have compatible settings and extracts common
128        active points for composite chart calculation.
129
130        Args:
131            first_subject (AstrologicalSubjectModel): First astrological subject for the composite
132            second_subject (AstrologicalSubjectModel): Second astrological subject for the composite
133            chart_name (str | None, optional): Custom name for the composite chart.
134                                             If None, generates name from subject names.
135                                             Defaults to None.
136
137        Raises:
138            KerykeionException: If subjects have different zodiac types, sidereal modes,
139                              house systems, house system names, or perspective types.
140
141        Note:
142            Both subjects must have identical astrological calculation settings to ensure
143            meaningful composite chart calculations.
144        """
145        self.model: Union[CompositeSubjectModel, None] = None
146        self.composite_chart_type = "Midpoint"
147
148        self.first_subject = first_subject
149        self.second_subject = second_subject
150        self.active_points = find_common_active_points(
151            first_subject.active_points,
152            second_subject.active_points
153        )
154
155        # Name
156        if chart_name is None:
157            self.name = f"{first_subject.name} and {second_subject.name} Composite Chart"
158        else:
159            self.name = chart_name
160
161        # Zodiac Type
162        if first_subject.zodiac_type != second_subject.zodiac_type:
163            raise KerykeionException("Both subjects must have the same zodiac type")
164        self.zodiac_type = first_subject.zodiac_type
165
166        # Sidereal Mode
167        if first_subject.sidereal_mode != second_subject.sidereal_mode:
168            raise KerykeionException("Both subjects must have the same sidereal mode")
169
170        if first_subject.sidereal_mode is not None:
171            self.sidereal_mode = first_subject.sidereal_mode
172        else:
173            self.sidereal_mode = None
174
175        # Houses System
176        if first_subject.houses_system_identifier != second_subject.houses_system_identifier:
177            raise KerykeionException("Both subjects must have the same houses system")
178        self.houses_system_identifier = first_subject.houses_system_identifier
179
180        # Houses System Name
181        if first_subject.houses_system_name != second_subject.houses_system_name:
182            raise KerykeionException("Both subjects must have the same houses system name")
183        self.houses_system_name = first_subject.houses_system_name
184
185        # Perspective Type
186        if first_subject.perspective_type != second_subject.perspective_type:
187            raise KerykeionException("Both subjects must have the same perspective type")
188        self.perspective_type = first_subject.perspective_type
189
190        # Planets Names List
191        self.active_points = []
192        for planet in first_subject.active_points:
193            if planet in second_subject.active_points:
194                self.active_points.append(planet)
195
196        # Houses Names List
197        self.houses_names_list = self.first_subject.houses_names_list
198
199    def __str__(self):
200        """
201        Return string representation of the composite subject.
202
203        Returns:
204            str: Human-readable string describing the composite chart.
205        """
206        return f"Composite Chart Data for {self.name}"
207
208    def __repr__(self):
209        """
210        Return detailed string representation of the composite subject.
211
212        Returns:
213            str: Detailed string representation for debugging purposes.
214        """
215        return f"Composite Chart Data for {self.name}"
216
217    def __eq__(self, other):
218        """
219        Check equality with another composite subject.
220
221        Args:
222            other (CompositeSubjectFactory): Another composite subject to compare with.
223
224        Returns:
225            bool: True if both subjects and chart name are identical.
226        """
227        return self.first_subject == other.first_subject and self.second_subject == other.second_subject and self.name == other.chart_name
228
229    def __ne__(self, other):
230        """
231        Check inequality with another composite subject.
232
233        Args:
234            other (CompositeSubjectFactory): Another composite subject to compare with.
235
236        Returns:
237            bool: True if subjects or chart name are different.
238        """
239        return not self.__eq__(other)
240
241    def __hash__(self):
242        """
243        Generate hash for the composite subject.
244
245        Returns:
246            int: Hash value based on both subjects and chart name.
247        """
248        return hash((self.first_subject, self.second_subject, self.name))
249
250    def __copy__(self):
251        """
252        Create a shallow copy of the composite subject.
253
254        Returns:
255            CompositeSubjectFactory: New instance with the same subjects and name.
256        """
257        return CompositeSubjectFactory(self.first_subject, self.second_subject, self.name)
258
259    def __setitem__(self, key, value):
260        """
261        Set an attribute using dictionary-style access.
262
263        Args:
264            key (str): Attribute name to set.
265            value: Value to assign to the attribute.
266        """
267        setattr(self, key, value)
268
269    def __getitem__(self, key):
270        """
271        Get an attribute using dictionary-style access.
272
273        Args:
274            key (str): Attribute name to retrieve.
275
276        Returns:
277            Any: Value of the requested attribute.
278
279        Raises:
280            AttributeError: If the attribute doesn't exist.
281        """
282        return getattr(self, key)
283
284    def _calculate_midpoint_composite_points_and_houses(self):
285        """
286        Calculate midpoint positions for all planets and house cusps in the composite chart.
287
288        This method implements the midpoint composite technique by:
289        1. Computing circular means of house cusp positions from both subjects
290        2. Sorting house positions to maintain proper house order
291        3. Creating composite house cusps with calculated positions
292        4. Computing circular means of planetary positions for common active points
293        5. Assigning planets to their appropriate houses in the composite chart
294
295        The circular mean calculation ensures proper handling of zodiacal positions
296        around the 360-degree boundary (e.g., when one position is at 350° and
297        another at 10°, the midpoint is correctly calculated as 0°).
298
299        Side Effects:
300            - Updates instance attributes with calculated house cusp positions
301            - Updates instance attributes with calculated planetary positions
302            - Sets house assignments for each planetary position
303
304        Note:
305            This is an internal method called by get_midpoint_composite_subject_model().
306            Only planets that exist in both subjects' active_points are included.
307        """
308        # Houses
309        house_degree_list_ut = []
310        for house in self.first_subject.houses_names_list:
311            house_lower = house.lower()
312            house_degree_list_ut.append(
313                circular_mean(
314                    self.first_subject[house_lower]["abs_pos"],
315                    self.second_subject[house_lower]["abs_pos"]
316                )
317        )
318        house_degree_list_ut = circular_sort(house_degree_list_ut)
319
320        for house_index, house_name in enumerate(self.first_subject.houses_names_list):
321            house_lower = house_name.lower()
322            self[house_lower] = get_kerykeion_point_from_degree(
323                house_degree_list_ut[house_index],
324                house_name,
325                "House"
326            )
327
328
329        # Planets
330        common_planets = []
331        for planet in self.first_subject.active_points:
332            if planet in self.second_subject.active_points:
333                common_planets.append(planet)
334
335        planets = {}
336        for planet in common_planets:
337            planet_lower = planet.lower()
338            planets[planet_lower] = {}
339            planets[planet_lower]["abs_pos"] = circular_mean(
340                self.first_subject[planet_lower]["abs_pos"],
341                self.second_subject[planet_lower]["abs_pos"]
342            )
343            self[planet_lower] = get_kerykeion_point_from_degree(planets[planet_lower]["abs_pos"], planet, "AstrologicalPoint")
344            self[planet_lower]["house"] = get_planet_house(self[planet_lower]['abs_pos'], house_degree_list_ut)
345
346    def _calculate_composite_lunar_phase(self):
347        """
348        Calculate the lunar phase for the composite chart based on Sun-Moon midpoints.
349
350        Uses the composite positions of the Sun and Moon to determine the lunar phase
351        angle, representing the relationship's emotional and instinctual dynamics.
352
353        Side Effects:
354            Sets the lunar_phase attribute with the calculated phase information.
355
356        Note:
357            This method should be called after _calculate_midpoint_composite_points_and_houses()
358            to ensure Sun and Moon composite positions are available.
359        """
360        self.lunar_phase = calculate_moon_phase(
361            self['moon'].abs_pos,
362            self['sun'].abs_pos
363        )
364
365    def get_midpoint_composite_subject_model(self):
366        """
367        Generate the complete composite chart model using the midpoint technique.
368
369        This is the main public method for creating a composite chart. It orchestrates
370        the calculation of all composite positions and creates a complete CompositeSubjectModel
371        containing all necessary astrological data for the relationship chart.
372
373        The process includes:
374        1. Calculating midpoint positions for all planets and house cusps
375        2. Computing the composite lunar phase
376        3. Assembling all data into a comprehensive model
377
378        Returns:
379            CompositeSubjectModel: Complete composite chart data model containing:
380                - All calculated planetary positions and their house placements
381                - House cusp positions maintaining proper house system order
382                - Lunar phase information for the composite chart
383                - All metadata from the original subjects (names, chart type, etc.)
384
385        Example:
386            >>> composite = CompositeSubjectFactory(person1, person2, "Our Relationship")
387            >>> model = composite.get_midpoint_composite_subject_model()
388            >>> print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}")
389
390        Note:
391            This method performs all calculations internally and returns a complete,
392            ready-to-use composite chart model suitable for analysis or chart drawing.
393        """
394        self._calculate_midpoint_composite_points_and_houses()
395        self._calculate_composite_lunar_phase()
396
397        return CompositeSubjectModel(
398            **self.__dict__
399        )

Factory class to create composite astrological charts from two astrological subjects.

A composite chart represents the relationship between two people by calculating the midpoint between corresponding planetary positions and house cusps. This creates a single chart that symbolizes the energy of the relationship itself.

Currently supports the midpoint method for composite chart calculation, where:

  • Planetary positions are calculated as the circular mean of corresponding planets
  • House cusps are calculated as the circular mean of corresponding houses
  • Houses are reordered to maintain consistency with the original house system
  • Only common active points between both subjects are included

The resulting composite chart maintains the zodiac type, sidereal mode, houses system, and perspective type of the input subjects (which must be identical between subjects).

Attributes: model (CompositeSubjectModel | None): The generated composite subject model first_subject (AstrologicalSubjectModel): First astrological subject second_subject (AstrologicalSubjectModel): Second astrological subject name (str): Name of the composite chart composite_chart_type (CompositeChartType): Type of composite chart (currently "Midpoint") zodiac_type (ZodiacType): Zodiac system used (Tropical or Sidereal) sidereal_mode (SiderealMode | None): Sidereal calculation mode if applicable houses_system_identifier (HousesSystemIdentifier): House system identifier houses_system_name (str): Human-readable house system name perspective_type (PerspectiveType): Astrological perspective type houses_names_list (list[Houses]): List of house names active_points (list[AstrologicalPoint]): Common active planetary points

Example:

first_person = AstrologicalSubjectFactory.from_birth_data( ... "John", 1990, 1, 1, 12, 0, "New York", "US" ... ) second_person = AstrologicalSubjectFactory.from_birth_data( ... "Jane", 1992, 6, 15, 14, 30, "Los Angeles", "US" ... ) composite = CompositeSubjectFactory(first_person, second_person) composite_model = composite.get_midpoint_composite_subject_model()

Raises: KerykeionException: When subjects have incompatible settings (different zodiac types, sidereal modes, house systems, or perspective types)

CompositeSubjectFactory( first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, chart_name: Optional[str] = None)
118    def __init__(
119            self,
120            first_subject: AstrologicalSubjectModel,
121            second_subject: AstrologicalSubjectModel,
122            chart_name: Union[str, None] = None
123    ):
124        """
125        Initialize the composite subject factory with two astrological subjects.
126
127        Validates that both subjects have compatible settings and extracts common
128        active points for composite chart calculation.
129
130        Args:
131            first_subject (AstrologicalSubjectModel): First astrological subject for the composite
132            second_subject (AstrologicalSubjectModel): Second astrological subject for the composite
133            chart_name (str | None, optional): Custom name for the composite chart.
134                                             If None, generates name from subject names.
135                                             Defaults to None.
136
137        Raises:
138            KerykeionException: If subjects have different zodiac types, sidereal modes,
139                              house systems, house system names, or perspective types.
140
141        Note:
142            Both subjects must have identical astrological calculation settings to ensure
143            meaningful composite chart calculations.
144        """
145        self.model: Union[CompositeSubjectModel, None] = None
146        self.composite_chart_type = "Midpoint"
147
148        self.first_subject = first_subject
149        self.second_subject = second_subject
150        self.active_points = find_common_active_points(
151            first_subject.active_points,
152            second_subject.active_points
153        )
154
155        # Name
156        if chart_name is None:
157            self.name = f"{first_subject.name} and {second_subject.name} Composite Chart"
158        else:
159            self.name = chart_name
160
161        # Zodiac Type
162        if first_subject.zodiac_type != second_subject.zodiac_type:
163            raise KerykeionException("Both subjects must have the same zodiac type")
164        self.zodiac_type = first_subject.zodiac_type
165
166        # Sidereal Mode
167        if first_subject.sidereal_mode != second_subject.sidereal_mode:
168            raise KerykeionException("Both subjects must have the same sidereal mode")
169
170        if first_subject.sidereal_mode is not None:
171            self.sidereal_mode = first_subject.sidereal_mode
172        else:
173            self.sidereal_mode = None
174
175        # Houses System
176        if first_subject.houses_system_identifier != second_subject.houses_system_identifier:
177            raise KerykeionException("Both subjects must have the same houses system")
178        self.houses_system_identifier = first_subject.houses_system_identifier
179
180        # Houses System Name
181        if first_subject.houses_system_name != second_subject.houses_system_name:
182            raise KerykeionException("Both subjects must have the same houses system name")
183        self.houses_system_name = first_subject.houses_system_name
184
185        # Perspective Type
186        if first_subject.perspective_type != second_subject.perspective_type:
187            raise KerykeionException("Both subjects must have the same perspective type")
188        self.perspective_type = first_subject.perspective_type
189
190        # Planets Names List
191        self.active_points = []
192        for planet in first_subject.active_points:
193            if planet in second_subject.active_points:
194                self.active_points.append(planet)
195
196        # Houses Names List
197        self.houses_names_list = self.first_subject.houses_names_list

Initialize the composite subject factory with two astrological subjects.

Validates that both subjects have compatible settings and extracts common active points for composite chart calculation.

Args: first_subject (AstrologicalSubjectModel): First astrological subject for the composite second_subject (AstrologicalSubjectModel): Second astrological subject for the composite chart_name (str | None, optional): Custom name for the composite chart. If None, generates name from subject names. Defaults to None.

Raises: KerykeionException: If subjects have different zodiac types, sidereal modes, house systems, house system names, or perspective types.

Note: Both subjects must have identical astrological calculation settings to ensure meaningful composite chart calculations.

model: Optional[kerykeion.schemas.kr_models.CompositeSubjectModel]
first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
name: str
composite_chart_type: Literal['Midpoint']
zodiac_type: Literal['Tropical', 'Sidereal']
sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']]
houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y']
houses_system_name: str
perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric']
houses_names_list: list[typing.Literal['First_House', 'Second_House', 'Third_House', 'Fourth_House', 'Fifth_House', 'Sixth_House', 'Seventh_House', 'Eighth_House', 'Ninth_House', 'Tenth_House', 'Eleventh_House', 'Twelfth_House']]
active_points: list[typing.Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]
def get_midpoint_composite_subject_model(self):
365    def get_midpoint_composite_subject_model(self):
366        """
367        Generate the complete composite chart model using the midpoint technique.
368
369        This is the main public method for creating a composite chart. It orchestrates
370        the calculation of all composite positions and creates a complete CompositeSubjectModel
371        containing all necessary astrological data for the relationship chart.
372
373        The process includes:
374        1. Calculating midpoint positions for all planets and house cusps
375        2. Computing the composite lunar phase
376        3. Assembling all data into a comprehensive model
377
378        Returns:
379            CompositeSubjectModel: Complete composite chart data model containing:
380                - All calculated planetary positions and their house placements
381                - House cusp positions maintaining proper house system order
382                - Lunar phase information for the composite chart
383                - All metadata from the original subjects (names, chart type, etc.)
384
385        Example:
386            >>> composite = CompositeSubjectFactory(person1, person2, "Our Relationship")
387            >>> model = composite.get_midpoint_composite_subject_model()
388            >>> print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}")
389
390        Note:
391            This method performs all calculations internally and returns a complete,
392            ready-to-use composite chart model suitable for analysis or chart drawing.
393        """
394        self._calculate_midpoint_composite_points_and_houses()
395        self._calculate_composite_lunar_phase()
396
397        return CompositeSubjectModel(
398            **self.__dict__
399        )

Generate the complete composite chart model using the midpoint technique.

This is the main public method for creating a composite chart. It orchestrates the calculation of all composite positions and creates a complete CompositeSubjectModel containing all necessary astrological data for the relationship chart.

The process includes:

  1. Calculating midpoint positions for all planets and house cusps
  2. Computing the composite lunar phase
  3. Assembling all data into a comprehensive model

Returns: CompositeSubjectModel: Complete composite chart data model containing: - All calculated planetary positions and their house placements - House cusp positions maintaining proper house system order - Lunar phase information for the composite chart - All metadata from the original subjects (names, chart type, etc.)

Example:

composite = CompositeSubjectFactory(person1, person2, "Our Relationship") model = composite.get_midpoint_composite_subject_model() print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}")

Note: This method performs all calculations internally and returns a complete, ready-to-use composite chart model suitable for analysis or chart drawing.

class EphemerisDataFactory:
 70class EphemerisDataFactory:
 71    """
 72    A factory class for generating ephemeris data over a specified date range.
 73
 74    This class calculates astrological ephemeris data (planetary positions and house cusps)
 75    for a sequence of dates, allowing for detailed astronomical calculations across time periods.
 76    It supports different time intervals (days, hours, or minutes) and various astrological
 77    calculation systems.
 78
 79    The factory creates data points at regular intervals between start and end dates,
 80    with built-in safeguards to prevent excessive computational loads through configurable
 81    maximum limits.
 82
 83    Args:
 84        start_datetime (datetime): The starting date and time for ephemeris calculations.
 85        end_datetime (datetime): The ending date and time for ephemeris calculations.
 86        step_type (Literal["days", "hours", "minutes"], optional): The time interval unit
 87            for data points. Defaults to "days".
 88        step (int, optional): The number of units to advance for each data point.
 89            For example, step=2 with step_type="days" creates data points every 2 days.
 90            Defaults to 1.
 91        lat (float, optional): Geographic latitude in decimal degrees for calculations.
 92            Positive values for North, negative for South. Defaults to 51.4769 (Greenwich).
 93        lng (float, optional): Geographic longitude in decimal degrees for calculations.
 94            Positive values for East, negative for West. Defaults to 0.0005 (Greenwich).
 95        tz_str (str, optional): Timezone identifier (e.g., "Europe/London", "America/New_York").
 96            Defaults to "Etc/UTC".
 97        is_dst (bool, optional): Whether daylight saving time is active for the location.
 98            Only relevant for certain timezone calculations. Defaults to False.
 99        zodiac_type (ZodiacType, optional): The zodiac system to use (tropical or sidereal).
100            Defaults to DEFAULT_ZODIAC_TYPE.
101        sidereal_mode (Union[SiderealMode, None], optional): The sidereal calculation mode
102            if using sidereal zodiac. Only applies when zodiac_type is sidereal.
103            Defaults to None.
104        houses_system_identifier (HousesSystemIdentifier, optional): The house system
105            for astrological house calculations (e.g., Placidus, Koch, Equal).
106            Defaults to DEFAULT_HOUSES_SYSTEM_IDENTIFIER.
107        perspective_type (PerspectiveType, optional): The calculation perspective
108            (geocentric, heliocentric, etc.). Defaults to DEFAULT_PERSPECTIVE_TYPE.
109        max_days (Union[int, None], optional): Maximum number of daily data points allowed.
110            Set to None to disable this safety check. Defaults to 730 (2 years).
111        max_hours (Union[int, None], optional): Maximum number of hourly data points allowed.
112            Set to None to disable this safety check. Defaults to 8760 (1 year).
113        max_minutes (Union[int, None], optional): Maximum number of minute-interval data points.
114            Set to None to disable this safety check. Defaults to 525600 (1 year).
115
116    Raises:
117        ValueError: If step_type is not one of "days", "hours", or "minutes".
118        ValueError: If the calculated number of data points exceeds the respective maximum limit.
119        ValueError: If no valid dates are generated from the input parameters.
120
121    Examples:
122        Create daily ephemeris data for a month:
123
124        >>> from datetime import datetime
125        >>> start = datetime(2024, 1, 1)
126        >>> end = datetime(2024, 1, 31)
127        >>> factory = EphemerisDataFactory(start, end)
128        >>> data = factory.get_ephemeris_data()
129
130        Create hourly data for a specific location:
131
132        >>> factory = EphemerisDataFactory(
133        ...     start, end,
134        ...     step_type="hours",
135        ...     lat=40.7128,  # New York
136        ...     lng=-74.0060,
137        ...     tz_str="America/New_York"
138        ... )
139        >>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
140
141    Note:
142        Large date ranges with small step intervals can generate thousands of data points,
143        which may require significant computation time and memory. The factory includes
144        warnings for calculations exceeding 1000 data points and enforces maximum limits
145        to prevent system overload.
146    """
147
148    def __init__(
149        self,
150        start_datetime: datetime,
151        end_datetime: datetime,
152        step_type: Literal["days", "hours", "minutes"] = "days",
153        step: int = 1,
154        lat: float = 51.4769,
155        lng: float = 0.0005,
156        tz_str: str = "Etc/UTC",
157        is_dst: bool = False,
158        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
159        sidereal_mode: Union[SiderealMode, None] = None,
160        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
161        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
162        max_days: Union[int, None] = 730,
163        max_hours: Union[int, None] = 8760,
164        max_minutes: Union[int, None] = 525600,
165    ):
166        self.start_datetime = start_datetime
167        self.end_datetime = end_datetime
168        self.step_type = step_type
169        self.step = step
170        self.lat = lat
171        self.lng = lng
172        self.tz_str = tz_str
173        self.is_dst = is_dst
174        self.zodiac_type = normalize_zodiac_type(zodiac_type)
175        self.sidereal_mode = sidereal_mode
176        self.houses_system_identifier = houses_system_identifier
177        self.perspective_type = perspective_type
178        self.max_days = max_days
179        self.max_hours = max_hours
180        self.max_minutes = max_minutes
181
182        self.dates_list = []
183        if self.step_type == "days":
184            self.dates_list = [self.start_datetime + timedelta(days=i * self.step) for i in range((self.end_datetime - self.start_datetime).days // self.step + 1)]
185            if max_days and (len(self.dates_list) > max_days):
186                raise ValueError(f"Too many days: {len(self.dates_list)} > {self.max_days}. To prevent this error, set max_days to a higher value or reduce the date range.")
187
188        elif self.step_type == "hours":
189            hours_diff = (self.end_datetime - self.start_datetime).total_seconds() / 3600
190            self.dates_list = [self.start_datetime + timedelta(hours=i * self.step) for i in range(int(hours_diff) // self.step + 1)]
191            if max_hours and (len(self.dates_list) > max_hours):
192                raise ValueError(f"Too many hours: {len(self.dates_list)} > {self.max_hours}. To prevent this error, set max_hours to a higher value or reduce the date range.")
193
194        elif self.step_type == "minutes":
195            minutes_diff = (self.end_datetime - self.start_datetime).total_seconds() / 60
196            self.dates_list = [self.start_datetime + timedelta(minutes=i * self.step) for i in range(int(minutes_diff) // self.step + 1)]
197            if max_minutes and (len(self.dates_list) > max_minutes):
198                raise ValueError(f"Too many minutes: {len(self.dates_list)} > {self.max_minutes}. To prevent this error, set max_minutes to a higher value or reduce the date range.")
199
200        else:
201            raise ValueError(f"Invalid step type: {self.step_type}")
202
203        if not self.dates_list:
204            raise ValueError("No dates found. Check the date range and step values.")
205
206        if len(self.dates_list) > 1000:
207            logging.warning(f"Large number of dates: {len(self.dates_list)}. The calculation may take a while.")
208
209    def get_ephemeris_data(self, as_model: bool = False) -> list:
210        """
211        Generate ephemeris data for the specified date range.
212
213        This method creates a comprehensive dataset containing planetary positions and
214        astrological house cusps for each date in the configured time series. The data
215        is structured for easy consumption by astrological applications and analysis tools.
216
217        The returned data includes all available astrological points (planets, asteroids,
218        lunar nodes, etc.) as configured by the perspective type, along with complete
219        house cusp information for each calculated moment.
220
221        Args:
222            as_model (bool, optional): If True, returns data as validated model instances
223                (EphemerisDictModel objects) which provide type safety and validation.
224                If False, returns raw dictionary data for maximum flexibility.
225                Defaults to False.
226
227        Returns:
228            list: A list of ephemeris data points, where each element represents one
229                calculated moment in time. The structure depends on the as_model parameter:
230
231                If as_model=False (default):
232                    List of dictionaries with keys:
233                    - "date" (str): ISO format datetime string (e.g., "2020-01-01T00:00:00")
234                    - "planets" (list): List of dictionaries, each containing planetary data
235                      with keys like 'name', 'abs_pos', 'lon', 'lat', 'dist', 'speed', etc.
236                    - "houses" (list): List of dictionaries containing house cusp data
237                      with keys like 'name', 'abs_pos', 'lon', etc.
238
239                If as_model=True:
240                    List of EphemerisDictModel instances providing the same data
241                    with type validation and structured access.
242
243        Examples:
244            Basic usage with dictionary output:
245
246            >>> factory = EphemerisDataFactory(start_date, end_date)
247            >>> data = factory.get_ephemeris_data()
248            >>> print(f"Sun position: {data[0]['planets'][0]['abs_pos']}")
249            >>> print(f"First house cusp: {data[0]['houses'][0]['abs_pos']}")
250
251            Using model instances for type safety:
252
253            >>> data_models = factory.get_ephemeris_data(as_model=True)
254            >>> first_point = data_models[0]
255            >>> print(f"Date: {first_point.date}")
256            >>> print(f"Number of planets: {len(first_point.planets)}")
257
258        Note:
259            - The calculation time is proportional to the number of data points
260            - For large datasets (>1000 points), consider using the method in batches
261            - Planet order and availability depend on the configured perspective type
262            - House system affects the house cusp calculations
263            - All positions are in the configured zodiac system (tropical/sidereal)
264        """
265        ephemeris_data_list = []
266        for date in self.dates_list:
267            subject = AstrologicalSubjectFactory.from_birth_data(
268                year=date.year,
269                month=date.month,
270                day=date.day,
271                hour=date.hour,
272                minute=date.minute,
273                lng=self.lng,
274                lat=self.lat,
275                tz_str=self.tz_str,
276                city="Placeholder",
277                nation="Placeholder",
278                online=False,
279                zodiac_type=self.zodiac_type,
280                sidereal_mode=self.sidereal_mode,
281                houses_system_identifier=self.houses_system_identifier,
282                perspective_type=self.perspective_type,
283                is_dst=self.is_dst,
284            )
285
286            houses_list = get_houses_list(subject)
287            available_planets = get_available_astrological_points_list(subject)
288
289            ephemeris_data_list.append({"date": date.isoformat(), "planets": available_planets, "houses": houses_list})
290
291        if as_model:
292            # Type narrowing: at this point, the dict structure matches EphemerisDictModel
293            return [EphemerisDictModel(date=data["date"], planets=data["planets"], houses=data["houses"]) for data in ephemeris_data_list]  # type: ignore
294
295        return ephemeris_data_list
296
297    def get_ephemeris_data_as_astrological_subjects(self, as_model: bool = False) -> List[AstrologicalSubjectModel]:
298        """
299        Generate ephemeris data as complete AstrologicalSubject instances.
300
301        This method creates fully-featured AstrologicalSubject objects for each date in the
302        configured time series, providing access to all astrological calculation methods
303        and properties. Unlike the dictionary-based approach of get_ephemeris_data(),
304        this method returns objects with the complete Kerykeion API available.
305
306        Each AstrologicalSubject instance represents a complete astrological chart for
307        the specified moment, location, and calculation settings. This allows direct
308        access to methods like get_sun(), get_all_points(), draw_chart(), calculate
309        aspects, and all other astrological analysis features.
310
311        Args:
312            as_model (bool, optional): If True, returns AstrologicalSubjectModel instances
313                (Pydantic model versions) which provide serialization and validation features.
314                If False, returns raw AstrologicalSubject instances with full method access.
315                Defaults to False.
316
317        Returns:
318            List[AstrologicalSubjectModel]: A list of AstrologicalSubject or
319                AstrologicalSubjectModel instances (depending on as_model parameter).
320                Each element represents one calculated moment in time with full
321                astrological chart data and methods available.
322
323                Each subject contains:
324                - All planetary and astrological point positions
325                - Complete house system calculations
326                - Chart drawing capabilities
327                - Aspect calculation methods
328                - Access to all Kerykeion astrological features
329
330        Examples:
331            Basic usage for accessing individual chart features:
332
333            >>> factory = EphemerisDataFactory(start_date, end_date)
334            >>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
335            >>>
336            >>> # Access specific planetary data
337            >>> sun_data = subjects[0].get_sun()
338            >>> moon_data = subjects[0].get_moon()
339            >>>
340            >>> # Get all astrological points
341            >>> all_points = subjects[0].get_all_points()
342            >>>
343            >>> # Generate chart visualization
344            >>> chart_svg = subjects[0].draw_chart()
345
346            Using model instances for serialization:
347
348            >>> subjects_models = factory.get_ephemeris_data_as_astrological_subjects(as_model=True)
349            >>> # Model instances can be easily serialized to JSON
350            >>> json_data = subjects_models[0].model_dump_json()
351
352            Batch processing for analysis:
353
354            >>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
355            >>> sun_positions = [subj.sun['abs_pos'] for subj in subjects if subj.sun]
356            >>> # Analyze sun position changes over time
357
358        Use Cases:
359            - Time-series astrological analysis
360            - Planetary motion tracking
361            - Aspect pattern analysis over time
362            - Chart animation data generation
363            - Astrological research and statistics
364            - Progressive chart calculations
365
366        Performance Notes:
367            - More computationally intensive than get_ephemeris_data()
368            - Each subject performs full astrological calculations
369            - Memory usage scales with the number of data points
370            - Consider processing in batches for very large date ranges
371            - Ideal for comprehensive analysis requiring full chart features
372
373        See Also:
374            get_ephemeris_data(): For lightweight dictionary-based ephemeris data
375            AstrologicalSubject: For details on available methods and properties
376        """
377        subjects_list = []
378        for date in self.dates_list:
379            subject = AstrologicalSubjectFactory.from_birth_data(
380                year=date.year,
381                month=date.month,
382                day=date.day,
383                hour=date.hour,
384                minute=date.minute,
385                lng=self.lng,
386                lat=self.lat,
387                tz_str=self.tz_str,
388                city="Placeholder",
389                nation="Placeholder",
390                online=False,
391                zodiac_type=self.zodiac_type,
392                sidereal_mode=self.sidereal_mode,
393                houses_system_identifier=self.houses_system_identifier,
394                perspective_type=self.perspective_type,
395                is_dst=self.is_dst,
396            )
397
398            if as_model:
399                subjects_list.append(subject)
400            else:
401                subjects_list.append(subject)
402
403        return subjects_list

A factory class for generating ephemeris data over a specified date range.

This class calculates astrological ephemeris data (planetary positions and house cusps) for a sequence of dates, allowing for detailed astronomical calculations across time periods. It supports different time intervals (days, hours, or minutes) and various astrological calculation systems.

The factory creates data points at regular intervals between start and end dates, with built-in safeguards to prevent excessive computational loads through configurable maximum limits.

Args: start_datetime (datetime): The starting date and time for ephemeris calculations. end_datetime (datetime): The ending date and time for ephemeris calculations. step_type (Literal["days", "hours", "minutes"], optional): The time interval unit for data points. Defaults to "days". step (int, optional): The number of units to advance for each data point. For example, step=2 with step_type="days" creates data points every 2 days. Defaults to 1. lat (float, optional): Geographic latitude in decimal degrees for calculations. Positive values for North, negative for South. Defaults to 51.4769 (Greenwich). lng (float, optional): Geographic longitude in decimal degrees for calculations. Positive values for East, negative for West. Defaults to 0.0005 (Greenwich). tz_str (str, optional): Timezone identifier (e.g., "Europe/London", "America/New_York"). Defaults to "Etc/UTC". is_dst (bool, optional): Whether daylight saving time is active for the location. Only relevant for certain timezone calculations. Defaults to False. zodiac_type (ZodiacType, optional): The zodiac system to use (tropical or sidereal). Defaults to DEFAULT_ZODIAC_TYPE. sidereal_mode (Union[SiderealMode, None], optional): The sidereal calculation mode if using sidereal zodiac. Only applies when zodiac_type is sidereal. Defaults to None. houses_system_identifier (HousesSystemIdentifier, optional): The house system for astrological house calculations (e.g., Placidus, Koch, Equal). Defaults to DEFAULT_HOUSES_SYSTEM_IDENTIFIER. perspective_type (PerspectiveType, optional): The calculation perspective (geocentric, heliocentric, etc.). Defaults to DEFAULT_PERSPECTIVE_TYPE. max_days (Union[int, None], optional): Maximum number of daily data points allowed. Set to None to disable this safety check. Defaults to 730 (2 years). max_hours (Union[int, None], optional): Maximum number of hourly data points allowed. Set to None to disable this safety check. Defaults to 8760 (1 year). max_minutes (Union[int, None], optional): Maximum number of minute-interval data points. Set to None to disable this safety check. Defaults to 525600 (1 year).

Raises: ValueError: If step_type is not one of "days", "hours", or "minutes". ValueError: If the calculated number of data points exceeds the respective maximum limit. ValueError: If no valid dates are generated from the input parameters.

Examples: Create daily ephemeris data for a month:

>>> from datetime import datetime
>>> start = datetime(2024, 1, 1)
>>> end = datetime(2024, 1, 31)
>>> factory = EphemerisDataFactory(start, end)
>>> data = factory.get_ephemeris_data()

Create hourly data for a specific location:

>>> factory = EphemerisDataFactory(
...     start, end,
...     step_type="hours",
...     lat=40.7128,  # New York
...     lng=-74.0060,
...     tz_str="America/New_York"
... )
>>> subjects = factory.get_ephemeris_data_as_astrological_subjects()

Note: Large date ranges with small step intervals can generate thousands of data points, which may require significant computation time and memory. The factory includes warnings for calculations exceeding 1000 data points and enforces maximum limits to prevent system overload.

EphemerisDataFactory( start_datetime: datetime.datetime, end_datetime: datetime.datetime, step_type: Literal['days', 'hours', 'minutes'] = 'days', step: int = 1, lat: float = 51.4769, lng: float = 0.0005, tz_str: str = 'Etc/UTC', is_dst: bool = False, zodiac_type: Literal['Tropical', 'Sidereal'] = 'Tropical', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', max_days: Optional[int] = 730, max_hours: Optional[int] = 8760, max_minutes: Optional[int] = 525600)
148    def __init__(
149        self,
150        start_datetime: datetime,
151        end_datetime: datetime,
152        step_type: Literal["days", "hours", "minutes"] = "days",
153        step: int = 1,
154        lat: float = 51.4769,
155        lng: float = 0.0005,
156        tz_str: str = "Etc/UTC",
157        is_dst: bool = False,
158        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
159        sidereal_mode: Union[SiderealMode, None] = None,
160        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
161        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
162        max_days: Union[int, None] = 730,
163        max_hours: Union[int, None] = 8760,
164        max_minutes: Union[int, None] = 525600,
165    ):
166        self.start_datetime = start_datetime
167        self.end_datetime = end_datetime
168        self.step_type = step_type
169        self.step = step
170        self.lat = lat
171        self.lng = lng
172        self.tz_str = tz_str
173        self.is_dst = is_dst
174        self.zodiac_type = normalize_zodiac_type(zodiac_type)
175        self.sidereal_mode = sidereal_mode
176        self.houses_system_identifier = houses_system_identifier
177        self.perspective_type = perspective_type
178        self.max_days = max_days
179        self.max_hours = max_hours
180        self.max_minutes = max_minutes
181
182        self.dates_list = []
183        if self.step_type == "days":
184            self.dates_list = [self.start_datetime + timedelta(days=i * self.step) for i in range((self.end_datetime - self.start_datetime).days // self.step + 1)]
185            if max_days and (len(self.dates_list) > max_days):
186                raise ValueError(f"Too many days: {len(self.dates_list)} > {self.max_days}. To prevent this error, set max_days to a higher value or reduce the date range.")
187
188        elif self.step_type == "hours":
189            hours_diff = (self.end_datetime - self.start_datetime).total_seconds() / 3600
190            self.dates_list = [self.start_datetime + timedelta(hours=i * self.step) for i in range(int(hours_diff) // self.step + 1)]
191            if max_hours and (len(self.dates_list) > max_hours):
192                raise ValueError(f"Too many hours: {len(self.dates_list)} > {self.max_hours}. To prevent this error, set max_hours to a higher value or reduce the date range.")
193
194        elif self.step_type == "minutes":
195            minutes_diff = (self.end_datetime - self.start_datetime).total_seconds() / 60
196            self.dates_list = [self.start_datetime + timedelta(minutes=i * self.step) for i in range(int(minutes_diff) // self.step + 1)]
197            if max_minutes and (len(self.dates_list) > max_minutes):
198                raise ValueError(f"Too many minutes: {len(self.dates_list)} > {self.max_minutes}. To prevent this error, set max_minutes to a higher value or reduce the date range.")
199
200        else:
201            raise ValueError(f"Invalid step type: {self.step_type}")
202
203        if not self.dates_list:
204            raise ValueError("No dates found. Check the date range and step values.")
205
206        if len(self.dates_list) > 1000:
207            logging.warning(f"Large number of dates: {len(self.dates_list)}. The calculation may take a while.")
start_datetime
end_datetime
step_type
step
lat
lng
tz_str
is_dst
zodiac_type
sidereal_mode
houses_system_identifier
perspective_type
max_days
max_hours
max_minutes
dates_list
def get_ephemeris_data(self, as_model: bool = False) -> list:
209    def get_ephemeris_data(self, as_model: bool = False) -> list:
210        """
211        Generate ephemeris data for the specified date range.
212
213        This method creates a comprehensive dataset containing planetary positions and
214        astrological house cusps for each date in the configured time series. The data
215        is structured for easy consumption by astrological applications and analysis tools.
216
217        The returned data includes all available astrological points (planets, asteroids,
218        lunar nodes, etc.) as configured by the perspective type, along with complete
219        house cusp information for each calculated moment.
220
221        Args:
222            as_model (bool, optional): If True, returns data as validated model instances
223                (EphemerisDictModel objects) which provide type safety and validation.
224                If False, returns raw dictionary data for maximum flexibility.
225                Defaults to False.
226
227        Returns:
228            list: A list of ephemeris data points, where each element represents one
229                calculated moment in time. The structure depends on the as_model parameter:
230
231                If as_model=False (default):
232                    List of dictionaries with keys:
233                    - "date" (str): ISO format datetime string (e.g., "2020-01-01T00:00:00")
234                    - "planets" (list): List of dictionaries, each containing planetary data
235                      with keys like 'name', 'abs_pos', 'lon', 'lat', 'dist', 'speed', etc.
236                    - "houses" (list): List of dictionaries containing house cusp data
237                      with keys like 'name', 'abs_pos', 'lon', etc.
238
239                If as_model=True:
240                    List of EphemerisDictModel instances providing the same data
241                    with type validation and structured access.
242
243        Examples:
244            Basic usage with dictionary output:
245
246            >>> factory = EphemerisDataFactory(start_date, end_date)
247            >>> data = factory.get_ephemeris_data()
248            >>> print(f"Sun position: {data[0]['planets'][0]['abs_pos']}")
249            >>> print(f"First house cusp: {data[0]['houses'][0]['abs_pos']}")
250
251            Using model instances for type safety:
252
253            >>> data_models = factory.get_ephemeris_data(as_model=True)
254            >>> first_point = data_models[0]
255            >>> print(f"Date: {first_point.date}")
256            >>> print(f"Number of planets: {len(first_point.planets)}")
257
258        Note:
259            - The calculation time is proportional to the number of data points
260            - For large datasets (>1000 points), consider using the method in batches
261            - Planet order and availability depend on the configured perspective type
262            - House system affects the house cusp calculations
263            - All positions are in the configured zodiac system (tropical/sidereal)
264        """
265        ephemeris_data_list = []
266        for date in self.dates_list:
267            subject = AstrologicalSubjectFactory.from_birth_data(
268                year=date.year,
269                month=date.month,
270                day=date.day,
271                hour=date.hour,
272                minute=date.minute,
273                lng=self.lng,
274                lat=self.lat,
275                tz_str=self.tz_str,
276                city="Placeholder",
277                nation="Placeholder",
278                online=False,
279                zodiac_type=self.zodiac_type,
280                sidereal_mode=self.sidereal_mode,
281                houses_system_identifier=self.houses_system_identifier,
282                perspective_type=self.perspective_type,
283                is_dst=self.is_dst,
284            )
285
286            houses_list = get_houses_list(subject)
287            available_planets = get_available_astrological_points_list(subject)
288
289            ephemeris_data_list.append({"date": date.isoformat(), "planets": available_planets, "houses": houses_list})
290
291        if as_model:
292            # Type narrowing: at this point, the dict structure matches EphemerisDictModel
293            return [EphemerisDictModel(date=data["date"], planets=data["planets"], houses=data["houses"]) for data in ephemeris_data_list]  # type: ignore
294
295        return ephemeris_data_list

Generate ephemeris data for the specified date range.

This method creates a comprehensive dataset containing planetary positions and astrological house cusps for each date in the configured time series. The data is structured for easy consumption by astrological applications and analysis tools.

The returned data includes all available astrological points (planets, asteroids, lunar nodes, etc.) as configured by the perspective type, along with complete house cusp information for each calculated moment.

Args: as_model (bool, optional): If True, returns data as validated model instances (EphemerisDictModel objects) which provide type safety and validation. If False, returns raw dictionary data for maximum flexibility. Defaults to False.

Returns: list: A list of ephemeris data points, where each element represents one calculated moment in time. The structure depends on the as_model parameter:

    If as_model=False (default):
        List of dictionaries with keys:
        - "date" (str): ISO format datetime string (e.g., "2020-01-01T00:00:00")
        - "planets" (list): List of dictionaries, each containing planetary data
          with keys like 'name', 'abs_pos', 'lon', 'lat', 'dist', 'speed', etc.
        - "houses" (list): List of dictionaries containing house cusp data
          with keys like 'name', 'abs_pos', 'lon', etc.

    If as_model=True:
        List of EphemerisDictModel instances providing the same data
        with type validation and structured access.

Examples: Basic usage with dictionary output:

>>> factory = EphemerisDataFactory(start_date, end_date)
>>> data = factory.get_ephemeris_data()
>>> print(f"Sun position: {data[0]['planets'][0]['abs_pos']}")
>>> print(f"First house cusp: {data[0]['houses'][0]['abs_pos']}")

Using model instances for type safety:

>>> data_models = factory.get_ephemeris_data(as_model=True)
>>> first_point = data_models[0]
>>> print(f"Date: {first_point.date}")
>>> print(f"Number of planets: {len(first_point.planets)}")

Note: - The calculation time is proportional to the number of data points - For large datasets (>1000 points), consider using the method in batches - Planet order and availability depend on the configured perspective type - House system affects the house cusp calculations - All positions are in the configured zodiac system (tropical/sidereal)

def get_ephemeris_data_as_astrological_subjects( self, as_model: bool = False) -> List[kerykeion.schemas.kr_models.AstrologicalSubjectModel]:
297    def get_ephemeris_data_as_astrological_subjects(self, as_model: bool = False) -> List[AstrologicalSubjectModel]:
298        """
299        Generate ephemeris data as complete AstrologicalSubject instances.
300
301        This method creates fully-featured AstrologicalSubject objects for each date in the
302        configured time series, providing access to all astrological calculation methods
303        and properties. Unlike the dictionary-based approach of get_ephemeris_data(),
304        this method returns objects with the complete Kerykeion API available.
305
306        Each AstrologicalSubject instance represents a complete astrological chart for
307        the specified moment, location, and calculation settings. This allows direct
308        access to methods like get_sun(), get_all_points(), draw_chart(), calculate
309        aspects, and all other astrological analysis features.
310
311        Args:
312            as_model (bool, optional): If True, returns AstrologicalSubjectModel instances
313                (Pydantic model versions) which provide serialization and validation features.
314                If False, returns raw AstrologicalSubject instances with full method access.
315                Defaults to False.
316
317        Returns:
318            List[AstrologicalSubjectModel]: A list of AstrologicalSubject or
319                AstrologicalSubjectModel instances (depending on as_model parameter).
320                Each element represents one calculated moment in time with full
321                astrological chart data and methods available.
322
323                Each subject contains:
324                - All planetary and astrological point positions
325                - Complete house system calculations
326                - Chart drawing capabilities
327                - Aspect calculation methods
328                - Access to all Kerykeion astrological features
329
330        Examples:
331            Basic usage for accessing individual chart features:
332
333            >>> factory = EphemerisDataFactory(start_date, end_date)
334            >>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
335            >>>
336            >>> # Access specific planetary data
337            >>> sun_data = subjects[0].get_sun()
338            >>> moon_data = subjects[0].get_moon()
339            >>>
340            >>> # Get all astrological points
341            >>> all_points = subjects[0].get_all_points()
342            >>>
343            >>> # Generate chart visualization
344            >>> chart_svg = subjects[0].draw_chart()
345
346            Using model instances for serialization:
347
348            >>> subjects_models = factory.get_ephemeris_data_as_astrological_subjects(as_model=True)
349            >>> # Model instances can be easily serialized to JSON
350            >>> json_data = subjects_models[0].model_dump_json()
351
352            Batch processing for analysis:
353
354            >>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
355            >>> sun_positions = [subj.sun['abs_pos'] for subj in subjects if subj.sun]
356            >>> # Analyze sun position changes over time
357
358        Use Cases:
359            - Time-series astrological analysis
360            - Planetary motion tracking
361            - Aspect pattern analysis over time
362            - Chart animation data generation
363            - Astrological research and statistics
364            - Progressive chart calculations
365
366        Performance Notes:
367            - More computationally intensive than get_ephemeris_data()
368            - Each subject performs full astrological calculations
369            - Memory usage scales with the number of data points
370            - Consider processing in batches for very large date ranges
371            - Ideal for comprehensive analysis requiring full chart features
372
373        See Also:
374            get_ephemeris_data(): For lightweight dictionary-based ephemeris data
375            AstrologicalSubject: For details on available methods and properties
376        """
377        subjects_list = []
378        for date in self.dates_list:
379            subject = AstrologicalSubjectFactory.from_birth_data(
380                year=date.year,
381                month=date.month,
382                day=date.day,
383                hour=date.hour,
384                minute=date.minute,
385                lng=self.lng,
386                lat=self.lat,
387                tz_str=self.tz_str,
388                city="Placeholder",
389                nation="Placeholder",
390                online=False,
391                zodiac_type=self.zodiac_type,
392                sidereal_mode=self.sidereal_mode,
393                houses_system_identifier=self.houses_system_identifier,
394                perspective_type=self.perspective_type,
395                is_dst=self.is_dst,
396            )
397
398            if as_model:
399                subjects_list.append(subject)
400            else:
401                subjects_list.append(subject)
402
403        return subjects_list

Generate ephemeris data as complete AstrologicalSubject instances.

This method creates fully-featured AstrologicalSubject objects for each date in the configured time series, providing access to all astrological calculation methods and properties. Unlike the dictionary-based approach of get_ephemeris_data(), this method returns objects with the complete Kerykeion API available.

Each AstrologicalSubject instance represents a complete astrological chart for the specified moment, location, and calculation settings. This allows direct access to methods like get_sun(), get_all_points(), draw_chart(), calculate aspects, and all other astrological analysis features.

Args: as_model (bool, optional): If True, returns AstrologicalSubjectModel instances (Pydantic model versions) which provide serialization and validation features. If False, returns raw AstrologicalSubject instances with full method access. Defaults to False.

Returns: List[AstrologicalSubjectModel]: A list of AstrologicalSubject or AstrologicalSubjectModel instances (depending on as_model parameter). Each element represents one calculated moment in time with full astrological chart data and methods available.

    Each subject contains:
    - All planetary and astrological point positions
    - Complete house system calculations
    - Chart drawing capabilities
    - Aspect calculation methods
    - Access to all Kerykeion astrological features

Examples: Basic usage for accessing individual chart features:

>>> factory = EphemerisDataFactory(start_date, end_date)
>>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
>>>
>>> # Access specific planetary data
>>> sun_data = subjects[0].get_sun()
>>> moon_data = subjects[0].get_moon()
>>>
>>> # Get all astrological points
>>> all_points = subjects[0].get_all_points()
>>>
>>> # Generate chart visualization
>>> chart_svg = subjects[0].draw_chart()

Using model instances for serialization:

>>> subjects_models = factory.get_ephemeris_data_as_astrological_subjects(as_model=True)
>>> # Model instances can be easily serialized to JSON
>>> json_data = subjects_models[0].model_dump_json()

Batch processing for analysis:

>>> subjects = factory.get_ephemeris_data_as_astrological_subjects()
>>> sun_positions = [subj.sun['abs_pos'] for subj in subjects if subj.sun]
>>> # Analyze sun position changes over time

Use Cases: - Time-series astrological analysis - Planetary motion tracking - Aspect pattern analysis over time - Chart animation data generation - Astrological research and statistics - Progressive chart calculations

Performance Notes: - More computationally intensive than get_ephemeris_data() - Each subject performs full astrological calculations - Memory usage scales with the number of data points - Consider processing in batches for very large date ranges - Ideal for comprehensive analysis requiring full chart features

See Also: get_ephemeris_data(): For lightweight dictionary-based ephemeris data AstrologicalSubject: For details on available methods and properties

class HouseComparisonFactory:
 22class HouseComparisonFactory:
 23    """
 24    Factory for creating house comparison analyses between two astrological subjects.
 25
 26    Analyzes placement of astrological points from one subject within the house system
 27    of another subject, performing bidirectional analysis for synastry studies and
 28    subject comparisons. Supports both natal subjects and planetary return subjects.
 29
 30    Attributes:
 31        first_subject: First astrological subject (natal or return subject)
 32        second_subject: Second astrological subject (natal or return subject)
 33        active_points: List of astrological points to include in analysis
 34
 35    Example:
 36        >>> natal_chart = AstrologicalSubjectFactory.from_birth_data(
 37        ...     "Person A", 1990, 5, 15, 10, 30, "Rome", "IT"
 38        ... )
 39        >>> partner_chart = AstrologicalSubjectFactory.from_birth_data(
 40        ...     "Person B", 1992, 8, 23, 14, 45, "Milan", "IT"
 41        ... )
 42        >>> factory = HouseComparisonFactory(natal_chart, partner_chart)
 43        >>> comparison = factory.get_house_comparison()
 44
 45    """
 46    def __init__(self,
 47                 first_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"],
 48                 second_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"],
 49                active_points: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
 50
 51        ):
 52        """
 53        Initialize the house comparison factory.
 54
 55        Args:
 56            first_subject: First astrological subject for comparison
 57            second_subject: Second astrological subject for comparison
 58            active_points: List of astrological points to include in analysis.
 59                          Defaults to standard active points.
 60
 61        Note:
 62            Both subjects must have valid house system data for accurate analysis.
 63        """
 64        self.first_subject = first_subject
 65        self.second_subject = second_subject
 66        self.active_points = active_points
 67
 68    def get_house_comparison(self) -> "HouseComparisonModel":
 69        """
 70        Generate bidirectional house comparison analysis between the two subjects.
 71
 72        Calculates where each active astrological point from one subject falls within
 73        the house system of the other subject, and vice versa.
 74
 75        Returns:
 76            HouseComparisonModel: Model containing:
 77                - first_subject_name: Name of the first subject
 78                - second_subject_name: Name of the second subject
 79                - first_points_in_second_houses: First subject's points in second subject's houses
 80                - second_points_in_first_houses: Second subject's points in first subject's houses
 81
 82        Note:
 83            Analysis scope is determined by the active_points list. Only specified
 84            points will be included in the results.
 85        """
 86        first_points_in_second_houses = calculate_points_in_reciprocal_houses(self.first_subject, self.second_subject, self.active_points)
 87        second_points_in_first_houses = calculate_points_in_reciprocal_houses(self.second_subject, self.first_subject, self.active_points)
 88
 89        # Calculate cusp placements in reciprocal houses
 90        first_cusps_in_second_houses = calculate_cusps_in_reciprocal_houses(self.first_subject, self.second_subject)
 91        second_cusps_in_first_houses = calculate_cusps_in_reciprocal_houses(self.second_subject, self.first_subject)
 92
 93        return HouseComparisonModel(
 94            first_subject_name=self.first_subject.name,
 95            second_subject_name=self.second_subject.name,
 96            first_points_in_second_houses=first_points_in_second_houses,
 97            second_points_in_first_houses=second_points_in_first_houses,
 98            first_cusps_in_second_houses=first_cusps_in_second_houses,
 99            second_cusps_in_first_houses=second_cusps_in_first_houses,
100        )

Factory for creating house comparison analyses between two astrological subjects.

Analyzes placement of astrological points from one subject within the house system of another subject, performing bidirectional analysis for synastry studies and subject comparisons. Supports both natal subjects and planetary return subjects.

Attributes: first_subject: First astrological subject (natal or return subject) second_subject: Second astrological subject (natal or return subject) active_points: List of astrological points to include in analysis

Example:

natal_chart = AstrologicalSubjectFactory.from_birth_data( ... "Person A", 1990, 5, 15, 10, 30, "Rome", "IT" ... ) partner_chart = AstrologicalSubjectFactory.from_birth_data( ... "Person B", 1992, 8, 23, 14, 45, "Milan", "IT" ... ) factory = HouseComparisonFactory(natal_chart, partner_chart) comparison = factory.get_house_comparison()

HouseComparisonFactory( first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel], active_points: list[typing.Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_North_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'])
46    def __init__(self,
47                 first_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"],
48                 second_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"],
49                active_points: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
50
51        ):
52        """
53        Initialize the house comparison factory.
54
55        Args:
56            first_subject: First astrological subject for comparison
57            second_subject: Second astrological subject for comparison
58            active_points: List of astrological points to include in analysis.
59                          Defaults to standard active points.
60
61        Note:
62            Both subjects must have valid house system data for accurate analysis.
63        """
64        self.first_subject = first_subject
65        self.second_subject = second_subject
66        self.active_points = active_points

Initialize the house comparison factory.

Args: first_subject: First astrological subject for comparison second_subject: Second astrological subject for comparison active_points: List of astrological points to include in analysis. Defaults to standard active points.

Note: Both subjects must have valid house system data for accurate analysis.

first_subject
second_subject
active_points
def get_house_comparison(self) -> HouseComparisonModel:
 68    def get_house_comparison(self) -> "HouseComparisonModel":
 69        """
 70        Generate bidirectional house comparison analysis between the two subjects.
 71
 72        Calculates where each active astrological point from one subject falls within
 73        the house system of the other subject, and vice versa.
 74
 75        Returns:
 76            HouseComparisonModel: Model containing:
 77                - first_subject_name: Name of the first subject
 78                - second_subject_name: Name of the second subject
 79                - first_points_in_second_houses: First subject's points in second subject's houses
 80                - second_points_in_first_houses: Second subject's points in first subject's houses
 81
 82        Note:
 83            Analysis scope is determined by the active_points list. Only specified
 84            points will be included in the results.
 85        """
 86        first_points_in_second_houses = calculate_points_in_reciprocal_houses(self.first_subject, self.second_subject, self.active_points)
 87        second_points_in_first_houses = calculate_points_in_reciprocal_houses(self.second_subject, self.first_subject, self.active_points)
 88
 89        # Calculate cusp placements in reciprocal houses
 90        first_cusps_in_second_houses = calculate_cusps_in_reciprocal_houses(self.first_subject, self.second_subject)
 91        second_cusps_in_first_houses = calculate_cusps_in_reciprocal_houses(self.second_subject, self.first_subject)
 92
 93        return HouseComparisonModel(
 94            first_subject_name=self.first_subject.name,
 95            second_subject_name=self.second_subject.name,
 96            first_points_in_second_houses=first_points_in_second_houses,
 97            second_points_in_first_houses=second_points_in_first_houses,
 98            first_cusps_in_second_houses=first_cusps_in_second_houses,
 99            second_cusps_in_first_houses=second_cusps_in_first_houses,
100        )

Generate bidirectional house comparison analysis between the two subjects.

Calculates where each active astrological point from one subject falls within the house system of the other subject, and vice versa.

Returns: HouseComparisonModel: Model containing: - first_subject_name: Name of the first subject - second_subject_name: Name of the second subject - first_points_in_second_houses: First subject's points in second subject's houses - second_points_in_first_houses: Second subject's points in first subject's houses

Note: Analysis scope is determined by the active_points list. Only specified points will be included in the results.

class HouseComparisonModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
453class HouseComparisonModel(SubscriptableBaseModel):
454    """
455    Bidirectional house comparison analysis between two astrological subjects.
456
457    Contains results of how astrological points from each subject interact with
458    the house system of the other subject.
459
460    Attributes:
461        first_subject_name: Name of the first subject
462        second_subject_name: Name of the second subject
463        first_points_in_second_houses: First subject's points in second subject's houses
464        second_points_in_first_houses: Second subject's points in first subject's houses
465    """
466
467    first_subject_name: str
468    """Name of the first subject"""
469    second_subject_name: str
470    """Name of the second subject"""
471    first_points_in_second_houses: List[PointInHouseModel]
472    """First subject's points positioned in second subject's houses"""
473    second_points_in_first_houses: List[PointInHouseModel]
474    """Second subject's points positioned in first subject's houses"""
475    first_cusps_in_second_houses: List[PointInHouseModel] = Field(default_factory=list)
476    """First subject's house cusps positioned in second subject's houses"""
477    second_cusps_in_first_houses: List[PointInHouseModel] = Field(default_factory=list)
478    """Second subject's house cusps positioned in first subject's houses"""

Bidirectional house comparison analysis between two astrological subjects.

Contains results of how astrological points from each subject interact with the house system of the other subject.

Attributes: first_subject_name: Name of the first subject second_subject_name: Name of the second subject first_points_in_second_houses: First subject's points in second subject's houses second_points_in_first_houses: Second subject's points in first subject's houses

first_subject_name: str

Name of the first subject

second_subject_name: str

Name of the second subject

first_points_in_second_houses: List[kerykeion.schemas.kr_models.PointInHouseModel]

First subject's points positioned in second subject's houses

second_points_in_first_houses: List[kerykeion.schemas.kr_models.PointInHouseModel]

Second subject's points positioned in first subject's houses

first_cusps_in_second_houses: List[kerykeion.schemas.kr_models.PointInHouseModel]

First subject's house cusps positioned in second subject's houses

second_cusps_in_first_houses: List[kerykeion.schemas.kr_models.PointInHouseModel]

Second subject's house cusps positioned in first subject's houses

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class KerykeionException(builtins.Exception):
 8class KerykeionException(Exception):
 9    """
10    Custom Kerykeion Exception
11    """
12
13    def __init__(self, message):
14        """
15        Initialize a new KerykeionException.
16
17        Args:
18            message: The error message to be displayed.
19        """
20        # Call the base class constructor with the parameters it needs
21        super().__init__(message)

Custom Kerykeion Exception

KerykeionException(message)
13    def __init__(self, message):
14        """
15        Initialize a new KerykeionException.
16
17        Args:
18            message: The error message to be displayed.
19        """
20        # Call the base class constructor with the parameters it needs
21        super().__init__(message)

Initialize a new KerykeionException.

Args: message: The error message to be displayed.

class PlanetaryReturnFactory:
 91class PlanetaryReturnFactory:
 92    """
 93    A factory class for calculating and generating planetary return charts.
 94
 95    This class specializes in computing precise planetary return moments using the Swiss
 96    Ephemeris library and creating complete astrological charts for those calculated times.
 97    It supports both Solar Returns (annual) and Lunar Returns (monthly), providing
 98    comprehensive astrological analysis capabilities for timing and forecasting applications.
 99
100    Planetary returns are fundamental concepts in predictive astrology:
101    - Solar Returns: Occur when the Sun returns to its exact natal position (~365.25 days)
102    - Lunar Returns: Occur when the Moon returns to its exact natal position (~27-29 days)
103
104    The factory handles complex astronomical calculations automatically, including:
105    - Precise celestial mechanics computations
106    - Timezone conversions and UTC coordination
107    - Location-based calculations for return chart casting
108    - Integration with online geocoding services
109    - Complete chart generation with all astrological points
110
111    Args:
112        subject (AstrologicalSubjectModel): The natal astrological subject for whom
113            returns are calculated. Must contain complete birth data including
114            planetary positions at birth.
115        city (Optional[str]): City name for return chart location. Required when
116            using online mode for location data retrieval.
117        nation (Optional[str]): Nation/country code for return chart location.
118            Required when using online mode (e.g., "US", "GB", "FR").
119        lng (Optional[Union[int, float]]): Geographic longitude in decimal degrees
120            for return chart location. Positive values for East, negative for West.
121            Required when using offline mode.
122        lat (Optional[Union[int, float]]): Geographic latitude in decimal degrees
123            for return chart location. Positive values for North, negative for South.
124            Required when using offline mode.
125        tz_str (Optional[str]): Timezone identifier for return chart location
126            (e.g., "America/New_York", "Europe/London", "Asia/Tokyo").
127            Required when using offline mode.
128        online (bool, optional): Whether to fetch location data online via Geonames
129            service. When True, requires city, nation, and geonames_username.
130            When False, requires lng, lat, and tz_str. Defaults to True.
131        geonames_username (Optional[str]): Username for Geonames API access.
132            Required when online=True and coordinates are not provided.
133            Register at http://www.geonames.org/login for free account.
134        cache_expire_after_days (int, optional): Number of days to cache Geonames
135            location data before refreshing. Defaults to system setting.
136        altitude (Optional[Union[float, int]]): Elevation above sea level in meters
137            for the return chart location. Reserved for future astronomical
138            calculations. Defaults to None.
139
140    Raises:
141        KerykeionException: If required location parameters are missing for the
142            chosen mode (online/offline).
143        KerykeionException: If Geonames API fails to retrieve location data.
144        KerykeionException: If online mode is used without proper API credentials.
145
146    Attributes:
147        subject (AstrologicalSubjectModel): The natal subject for calculations.
148        city (Optional[str]): Return chart city name.
149        nation (Optional[str]): Return chart nation code.
150        lng (float): Return chart longitude coordinate.
151        lat (float): Return chart latitude coordinate.
152        tz_str (str): Return chart timezone identifier.
153        online (bool): Location data retrieval mode.
154        city_data (Optional[dict]): Cached location data from Geonames.
155
156    Examples:
157        Online mode with automatic location lookup:
158
159        >>> subject = AstrologicalSubjectFactory.from_birth_data(
160        ...     name="Alice", year=1985, month=3, day=21,
161        ...     hour=14, minute=30, lat=51.5074, lng=-0.1278,
162        ...     tz_str="Europe/London"
163        ... )
164        >>> factory = PlanetaryReturnFactory(
165        ...     subject,
166        ...     city="London",
167        ...     nation="GB",
168        ...     online=True,
169        ...     geonames_username="your_username"
170        ... )
171
172        Offline mode with manual coordinates:
173
174        >>> factory = PlanetaryReturnFactory(
175        ...     subject,
176        ...     lng=-74.0060,
177        ...     lat=40.7128,
178        ...     tz_str="America/New_York",
179        ...     online=False
180        ... )
181
182        Different location for return chart:
183
184        >>> # Calculate return as if living in a different city
185        >>> factory = PlanetaryReturnFactory(
186        ...     natal_subject,  # Born in London
187        ...     city="Paris",   # But living in Paris
188        ...     nation="FR",
189        ...     online=True
190        ... )
191
192    Use Cases:
193        - Annual Solar Return charts for yearly forecasting
194        - Monthly Lunar Return charts for timing analysis
195        - Relocation returns for different geographic locations
196        - Research into planetary cycle effects
197        - Astrological consultation and chart analysis
198        - Educational demonstrations of celestial mechanics
199
200    Note:
201        Return calculations use the exact degree and minute of natal planetary
202        positions. The resulting charts are cast for the precise moment when
203        the transiting planet reaches this position, which may not align with
204        calendar dates (especially for Solar Returns, which can occur on
205        different dates depending on leap years and location).
206    """
207
208    def __init__(
209            self,
210            subject: AstrologicalSubjectModel,
211            city: Union[str, None] = None,
212            nation: Union[str, None] = None,
213            lng: Union[int, float, None] = None,
214            lat: Union[int, float, None] = None,
215            tz_str: Union[str, None] = None,
216            online: bool = True,
217            geonames_username: Union[str, None] = None,
218            *,
219            cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
220            altitude: Union[float, int, None] = None,
221        ):
222
223        """
224        Initialize a PlanetaryReturnFactory instance with location and configuration settings.
225
226        This constructor sets up the factory with all necessary parameters for calculating
227        planetary returns at a specified location. It supports both online mode (with
228        automatic geocoding via Geonames) and offline mode (with manual coordinates).
229
230        The factory validates input parameters based on the chosen mode and automatically
231        retrieves missing location data when operating online. All location parameters
232        are stored and used for casting return charts at the exact calculated moments.
233
234        Args:
235            subject (AstrologicalSubjectModel): The natal astrological subject containing
236                birth data and planetary positions. This subject's natal planetary
237                positions serve as reference points for calculating returns.
238            city (Optional[str]): City name for the return chart location. Must be a
239                recognizable city name for Geonames geocoding when using online mode.
240                Examples: "New York", "London", "Tokyo", "Paris".
241            nation (Optional[str]): Country or nation code for the return chart location.
242                Use ISO country codes for best results (e.g., "US", "GB", "JP", "FR").
243                Required when online=True.
244            lng (Optional[Union[int, float]]): Geographic longitude coordinate in decimal
245                degrees for return chart location. Range: -180.0 to +180.0.
246                Positive values represent East longitude, negative values West longitude.
247                Required when online=False.
248            lat (Optional[Union[int, float]]): Geographic latitude coordinate in decimal
249                degrees for return chart location. Range: -90.0 to +90.0.
250                Positive values represent North latitude, negative values South latitude.
251                Required when online=False.
252            tz_str (Optional[str]): Timezone identifier string for return chart location.
253                Must be a valid timezone from the IANA Time Zone Database
254                (e.g., "America/New_York", "Europe/London", "Asia/Tokyo").
255                Required when online=False.
256            online (bool, optional): Location data retrieval mode. When True, uses
257                Geonames web service to automatically fetch coordinates and timezone
258                from city/nation parameters. When False, uses manually provided
259                coordinates and timezone. Defaults to True.
260            geonames_username (Optional[str]): Username for Geonames API access.
261                Required when online=True and coordinates are not manually provided.
262                Free accounts available at http://www.geonames.org/login.
263                If None and required, uses default username with warning.
264            cache_expire_after_days (int, optional): Number of days to cache Geonames
265                location data locally before requiring refresh. Helps reduce API
266                calls and improve performance for repeated calculations.
267                Defaults to system configuration value.
268            altitude (Optional[Union[float, int]]): Elevation above sea level in meters
269                for the return chart location. Currently reserved for future use in
270                advanced astronomical calculations. Defaults to None.
271
272        Raises:
273            KerykeionException: If city is not provided when online=True.
274            KerykeionException: If nation is not provided when online=True.
275            KerykeionException: If coordinates (lat/lng) are not provided when online=False.
276            KerykeionException: If timezone (tz_str) is not provided when online=False.
277            KerykeionException: If Geonames API fails to retrieve valid location data.
278            KerykeionException: If required parameters are missing for the chosen mode.
279
280        Examples:
281            Initialize with online geocoding:
282
283            >>> factory = PlanetaryReturnFactory(
284            ...     subject,
285            ...     city="San Francisco",
286            ...     nation="US",
287            ...     online=True,
288            ...     geonames_username="your_username"
289            ... )
290
291            Initialize with manual coordinates:
292
293            >>> factory = PlanetaryReturnFactory(
294            ...     subject,
295            ...     lng=-122.4194,
296            ...     lat=37.7749,
297            ...     tz_str="America/Los_Angeles",
298            ...     online=False
299            ... )
300
301            Initialize with mixed parameters (coordinates override online lookup):
302
303            >>> factory = PlanetaryReturnFactory(
304            ...     subject,
305            ...     city="Custom Location",
306            ...     lng=-74.0060,
307            ...     lat=40.7128,
308            ...     tz_str="America/New_York",
309            ...     online=False
310            ... )
311
312        Note:
313            - When both online and manual coordinates are provided, offline mode takes precedence
314            - Geonames cache helps reduce API calls for frequently used locations
315            - Timezone accuracy is crucial for precise return calculations
316            - Location parameters affect house cusps and angular positions in return charts
317        """
318        # Store basic configuration
319        self.subject = subject
320        self.online = online
321        self.cache_expire_after_days = cache_expire_after_days
322        self.altitude = altitude
323
324        # Geonames username
325        if geonames_username is None and online and (not lat or not lng or not tz_str):
326            logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
327            self.geonames_username = DEFAULT_GEONAMES_USERNAME
328        else:
329            self.geonames_username = geonames_username # type: ignore
330
331        # City
332        if not city and online:
333            raise KerykeionException("You need to set the city if you want to use the online mode!")
334        else:
335            self.city = city
336
337        # Nation
338        if not nation and online:
339            raise KerykeionException("You need to set the nation if you want to use the online mode!")
340        else:
341            self.nation = nation
342
343        # Latitude
344        if not lat and not online:
345            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
346        else:
347            self.lat = lat # type: ignore
348
349        # Longitude
350        if not lng and not online:
351            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
352        else:
353            self.lng = lng # type: ignore
354
355        # Timezone
356        if (not online) and (not tz_str):
357            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
358        else:
359            self.tz_str = tz_str # type: ignore
360
361        # Online mode
362        if (self.online) and (not self.tz_str) and (not self.lat) and (not self.lng):
363            logging.info("Fetching timezone/coordinates from geonames")
364
365            if not self.city or not self.nation or not self.geonames_username:
366                raise KerykeionException("You need to set the city and nation if you want to use the online mode!")
367
368            geonames = FetchGeonames(
369                self.city,
370                self.nation,
371                username=self.geonames_username,
372                cache_expire_after_days=self.cache_expire_after_days
373            )
374            self.city_data: dict[str, str] = geonames.get_serialized_data()
375
376            if (
377                "countryCode" not in self.city_data
378                or "timezonestr" not in self.city_data
379                or "lat" not in self.city_data
380                or "lng" not in self.city_data
381            ):
382                raise KerykeionException("No data found for this city, try again! Maybe check your connection?")
383
384            self.nation = self.city_data["countryCode"]
385            self.lng = float(self.city_data["lng"])
386            self.lat = float(self.city_data["lat"])
387            self.tz_str = self.city_data["timezonestr"]
388
389    def next_return_from_iso_formatted_time(
390        self,
391        iso_formatted_time: str,
392        return_type: ReturnType
393    ) -> PlanetReturnModel:
394        """
395        Calculate the next planetary return occurring after a specified ISO-formatted datetime.
396
397        This method computes the exact moment when the specified planet (Sun or Moon) returns
398        to its natal position, starting the search from the provided datetime. It uses precise
399        Swiss Ephemeris calculations to determine the exact return moment and generates a
400        complete astrological chart for that calculated time.
401
402        The calculation process:
403        1. Converts the ISO datetime to Julian Day format for astronomical calculations
404        2. Uses Swiss Ephemeris functions (solcross_ut/mooncross_ut) to find the exact
405           return moment when the planet reaches its natal degree and minute
406        3. Creates a complete AstrologicalSubject instance for the calculated return time
407        4. Returns a comprehensive PlanetReturnModel with all chart data
408
409        Args:
410            iso_formatted_time (str): Starting datetime in ISO format for the search.
411                Must be a valid ISO 8601 datetime string (e.g., "2024-01-15T10:30:00"
412                or "2024-01-15T10:30:00+00:00"). The method will find the next return
413                occurring after this moment.
414            return_type (ReturnType): Type of planetary return to calculate.
415                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
416                This determines which planet's return cycle to compute.
417
418        Returns:
419            PlanetReturnModel: A comprehensive Pydantic model containing complete
420                astrological chart data for the calculated return moment, including:
421                - Exact return datetime (UTC and local timezone)
422                - All planetary positions at the return moment
423                - House cusps and angles for the return location
424                - Complete astrological subject data with all calculated points
425                - Return type identifier and subject name
426                - Julian Day Number for the return moment
427
428        Raises:
429            KerykeionException: If return_type is not "Solar" or "Lunar".
430            ValueError: If iso_formatted_time is not a valid ISO datetime format.
431            SwissEphException: If Swiss Ephemeris calculations fail due to invalid
432                date ranges or astronomical calculation errors.
433
434        Examples:
435            Calculate next Solar Return after a specific date:
436
437            >>> factory = PlanetaryReturnFactory(subject, ...)
438            >>> solar_return = factory.next_return_from_iso_formatted_time(
439            ...     "2024-06-15T12:00:00",
440            ...     "Solar"
441            ... )
442            >>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")
443            >>> print(f"Sun position: {solar_return.sun.abs_pos}°")
444
445            Calculate next Lunar Return with timezone:
446
447            >>> lunar_return = factory.next_return_from_iso_formatted_time(
448            ...     "2024-01-01T00:00:00+00:00",
449            ...     "Lunar"
450            ... )
451            >>> print(f"Moon return in {lunar_return.tz_str}")
452            >>> print(f"Return occurs: {lunar_return.iso_formatted_local_datetime}")
453
454            Access complete chart data from return:
455
456            >>> return_chart = factory.next_return_from_iso_formatted_time(
457            ...     datetime.now().isoformat(),
458            ...     "Solar"
459            ... )
460            >>> # Access all planetary positions
461            >>> for planet in return_chart.planets_list:
462            ...     print(f"{planet.name}: {planet.abs_pos}° in {planet.sign}")
463            >>> # Access house cusps
464            >>> for house in return_chart.houses_list:
465            ...     print(f"House {house.number}: {house.abs_pos}°")
466
467        Technical Notes:
468            - Solar returns typically occur within 1-2 days of the natal birthday
469            - Lunar returns occur approximately every 27.3 days (sidereal month)
470            - Return moments are calculated to the second for maximum precision
471            - The method accounts for leap years and varying orbital speeds
472            - Return charts use the factory's configured location, not the natal location
473
474        Use Cases:
475            - Annual birthday return chart calculations
476            - Monthly lunar return timing for astrological consultation
477            - Research into planetary cycle patterns and timing
478            - Forecasting and predictive astrology applications
479            - Educational demonstrations of astronomical cycles
480
481        See Also:
482            next_return_from_year(): Simplified interface for yearly calculations
483            next_return_from_date(): Date-based calculation interface
484        """
485
486        date = datetime.fromisoformat(iso_formatted_time)
487        julian_day = datetime_to_julian(date)
488
489        return_julian_date = None
490        if return_type == "Solar":
491            if self.subject.sun is None:
492                raise KerykeionException("Sun position is required for Solar return but is not available in the subject.")
493            return_julian_date = swe.solcross_ut(
494                self.subject.sun.abs_pos,
495                julian_day,
496            )
497        elif return_type == "Lunar":
498            if self.subject.moon is None:
499                raise KerykeionException("Moon position is required for Lunar return but is not available in the subject.")
500            return_julian_date = swe.mooncross_ut(
501                self.subject.moon.abs_pos,
502                julian_day,
503            )
504        else:
505            raise KerykeionException(f"Invalid return type {return_type}. Use 'Solar' or 'Lunar'.")
506
507        solar_return_date_utc = julian_to_datetime(return_julian_date)
508        solar_return_date_utc = solar_return_date_utc.replace(tzinfo=timezone.utc)
509
510        solar_return_astrological_subject = AstrologicalSubjectFactory.from_iso_utc_time(
511            name=self.subject.name,
512            iso_utc_time=solar_return_date_utc.isoformat(),
513            lng=self.lng,       # type: ignore
514            lat=self.lat,       # type: ignore
515            tz_str=self.tz_str, # type: ignore
516            city=self.city,     # type: ignore
517            nation=self.nation, # type: ignore
518            online=False,
519            altitude=self.altitude,
520            active_points=self.subject.active_points,
521        )
522
523        model_data = solar_return_astrological_subject.model_dump()
524        model_data['name'] = f"{self.subject.name} {return_type} Return"
525        model_data['return_type'] = return_type
526
527        return PlanetReturnModel(
528            **model_data,
529        )
530
531    def next_return_from_year(
532        self,
533        year: int,
534        return_type: ReturnType
535    ) -> PlanetReturnModel:
536        """
537        Calculate the planetary return occurring within a specified year.
538
539        This is a convenience method that finds the first planetary return (Solar or Lunar)
540        that occurs in the given calendar year. It automatically searches from January 1st
541        of the specified year and returns the first return found, making it ideal for
542        annual forecasting and birthday return calculations.
543
544        For Solar Returns, this typically finds the return closest to the natal birthday
545        within that year. For Lunar Returns, it finds the first lunar return occurring
546        in January of the specified year.
547
548        The method internally uses next_return_from_iso_formatted_time() with a starting
549        point of January 1st at midnight UTC for the specified year.
550
551        Args:
552            year (int): The calendar year to search for the return. Must be a valid
553                year (typically between 1800-2200 for reliable ephemeris data).
554                Examples: 2024, 2025, 1990, 2050.
555            return_type (ReturnType): The type of planetary return to calculate.
556                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
557
558        Returns:
559            PlanetReturnModel: A comprehensive model containing the return chart data
560                for the first return found in the specified year. Includes:
561                - Exact return datetime in both UTC and local timezone
562                - Complete planetary positions at the return moment
563                - House cusps calculated for the factory's configured location
564                - All astrological chart features and calculated points
565                - Return type and subject identification
566
567        Raises:
568            KerykeionException: If return_type is not "Solar" or "Lunar".
569            ValueError: If year is outside the valid range for ephemeris calculations.
570            SwissEphException: If astronomical calculations fail for the given year.
571
572        Examples:
573            Calculate Solar Return for 2024:
574
575            >>> factory = PlanetaryReturnFactory(subject, ...)
576            >>> solar_return_2024 = factory.next_return_from_year(2024, "Solar")
577            >>> print(f"2024 Solar Return: {solar_return_2024.iso_formatted_local_datetime}")
578            >>> print(f"Birthday location: {solar_return_2024.city}, {solar_return_2024.nation}")
579
580            Calculate first Lunar Return of 2025:
581
582            >>> lunar_return = factory.next_return_from_year(2025, "Lunar")
583            >>> print(f"First 2025 Lunar Return: {lunar_return.iso_formatted_local_datetime}")
584
585            Compare multiple years:
586
587            >>> for year in [2023, 2024, 2025]:
588            ...     solar_return = factory.next_return_from_year(year, "Solar")
589            ...     print(f"{year}: {solar_return.iso_formatted_local_datetime}")
590
591        Practical Applications:
592            - Annual Solar Return chart casting for birthday forecasting
593            - Comparative analysis of return charts across multiple years
594            - Research into planetary return timing patterns
595            - Automated birthday return calculations for consultation
596            - Educational demonstrations of annual astrological cycles
597
598        Technical Notes:
599            - Solar returns in a given year occur near but not exactly on the birthday
600            - The exact date can vary by 1-2 days due to leap years and orbital mechanics
601            - Lunar returns occur approximately every 27.3 days throughout the year
602            - This method finds the chronologically first return in the year
603            - Return moment precision is calculated to the second
604
605        Use Cases:
606            - Birthday return chart interpretation
607            - Annual astrological forecasting
608            - Timing analysis for major life events
609            - Comparative return chart studies
610            - Astrological consultation preparation
611
612        See Also:
613            next_return_from_date(): For more specific date-based searches
614            next_return_from_iso_formatted_time(): For custom starting dates
615        """
616        import warnings
617        warnings.warn(
618            "next_return_from_year is deprecated, use next_return_from_date instead",
619            DeprecationWarning,
620            stacklevel=2
621        )
622        return self.next_return_from_date(year, 1, 1, return_type=return_type)
623
624    def next_return_from_date(
625        self,
626        year: int,
627        month: int,
628        day: int = 1,
629        *,
630        return_type: ReturnType
631    ) -> PlanetReturnModel:
632        """
633        Calculate the first planetary return occurring on or after a specified date.
634
635        This method provides precise timing control for planetary return calculations by
636        searching from a specific day, month, and year. It's particularly useful for
637        finding Lunar Returns when multiple returns occur within a single month
638        (approximately every 27.3 days).
639
640        The method searches from midnight (00:00:00 UTC) of the specified date,
641        finding the next return that occurs from that point forward.
642
643        Args:
644            year (int): The calendar year to search within. Must be a valid year
645                within the ephemeris data range (typically 1800-2200).
646            month (int): The month to start the search from. Must be between 1 and 12.
647            day (int): The day to start the search from. Must be a valid day for the
648                specified month (1-28/29/30/31 depending on month). Defaults to 1.
649            return_type (ReturnType): The type of planetary return to calculate.
650                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
651
652        Returns:
653            PlanetReturnModel: Comprehensive return chart data for the first return
654                found on or after the specified date.
655
656        Raises:
657            KerykeionException: If month is not between 1 and 12.
658            KerykeionException: If day is not valid for the given month/year.
659            KerykeionException: If return_type is not "Solar" or "Lunar".
660
661        Examples:
662            Find first Lunar Return after January 15, 2024:
663
664            >>> lunar_return = factory.next_return_from_date(
665            ...     2024, 1, 15, return_type="Lunar"
666            ... )
667
668            Find second Lunar Return in a month (after the first one):
669
670            >>> # First return from start of month
671            >>> first_lr = factory.next_return_from_date(2024, 1, 1, return_type="Lunar")
672            >>> # Second return from middle of month
673            >>> second_lr = factory.next_return_from_date(2024, 1, 15, return_type="Lunar")
674
675        See Also:
676            next_return_from_year(): For annual return calculations
677            next_return_from_iso_formatted_time(): For custom datetime searches
678        """
679        # Validate month input
680        if month < 1 or month > 12:
681            raise KerykeionException(f"Invalid month {month}. Month must be between 1 and 12.")
682
683        # Validate day input
684        max_day = calendar.monthrange(year, month)[1]
685        if day < 1 or day > max_day:
686            raise KerykeionException(
687                f"Invalid day {day} for {year}-{month:02d}. Day must be between 1 and {max_day}."
688            )
689
690        # Create datetime for the specified date (UTC)
691        start_date = datetime(year, month, day, 0, 0, tzinfo=timezone.utc)
692
693        # Get the return using the existing method
694        return self.next_return_from_iso_formatted_time(
695            start_date.isoformat(),
696            return_type
697        )
698
699    def next_return_from_month_and_year(
700        self,
701        year: int,
702        month: int,
703        return_type: ReturnType
704    ) -> PlanetReturnModel:
705        """
706        DEPRECATED: Use next_return_from_date() instead.
707
708        Calculate the first planetary return occurring in or after a specified month and year.
709        This method is kept for backward compatibility and will be removed in a future version.
710
711        Args:
712            year (int): The calendar year to search within.
713            month (int): The month to start the search from (1-12).
714            return_type (ReturnType): "Solar" or "Lunar".
715
716        Returns:
717            PlanetReturnModel: Return chart data for the first return found.
718        """
719        import warnings
720        warnings.warn(
721            "next_return_from_month_and_year is deprecated, use next_return_from_date instead",
722            DeprecationWarning,
723            stacklevel=2
724        )
725        return self.next_return_from_date(year, month, 1, return_type=return_type)

A factory class for calculating and generating planetary return charts.

This class specializes in computing precise planetary return moments using the Swiss Ephemeris library and creating complete astrological charts for those calculated times. It supports both Solar Returns (annual) and Lunar Returns (monthly), providing comprehensive astrological analysis capabilities for timing and forecasting applications.

Planetary returns are fundamental concepts in predictive astrology:

  • Solar Returns: Occur when the Sun returns to its exact natal position (~365.25 days)
  • Lunar Returns: Occur when the Moon returns to its exact natal position (~27-29 days)

The factory handles complex astronomical calculations automatically, including:

  • Precise celestial mechanics computations
  • Timezone conversions and UTC coordination
  • Location-based calculations for return chart casting
  • Integration with online geocoding services
  • Complete chart generation with all astrological points

Args: subject (AstrologicalSubjectModel): The natal astrological subject for whom returns are calculated. Must contain complete birth data including planetary positions at birth. city (Optional[str]): City name for return chart location. Required when using online mode for location data retrieval. nation (Optional[str]): Nation/country code for return chart location. Required when using online mode (e.g., "US", "GB", "FR"). lng (Optional[Union[int, float]]): Geographic longitude in decimal degrees for return chart location. Positive values for East, negative for West. Required when using offline mode. lat (Optional[Union[int, float]]): Geographic latitude in decimal degrees for return chart location. Positive values for North, negative for South. Required when using offline mode. tz_str (Optional[str]): Timezone identifier for return chart location (e.g., "America/New_York", "Europe/London", "Asia/Tokyo"). Required when using offline mode. online (bool, optional): Whether to fetch location data online via Geonames service. When True, requires city, nation, and geonames_username. When False, requires lng, lat, and tz_str. Defaults to True. geonames_username (Optional[str]): Username for Geonames API access. Required when online=True and coordinates are not provided. Register at http://www.geonames.org/login for free account. cache_expire_after_days (int, optional): Number of days to cache Geonames location data before refreshing. Defaults to system setting. altitude (Optional[Union[float, int]]): Elevation above sea level in meters for the return chart location. Reserved for future astronomical calculations. Defaults to None.

Raises: KerykeionException: If required location parameters are missing for the chosen mode (online/offline). KerykeionException: If Geonames API fails to retrieve location data. KerykeionException: If online mode is used without proper API credentials.

Attributes: subject (AstrologicalSubjectModel): The natal subject for calculations. city (Optional[str]): Return chart city name. nation (Optional[str]): Return chart nation code. lng (float): Return chart longitude coordinate. lat (float): Return chart latitude coordinate. tz_str (str): Return chart timezone identifier. online (bool): Location data retrieval mode. city_data (Optional[dict]): Cached location data from Geonames.

Examples: Online mode with automatic location lookup:

>>> subject = AstrologicalSubjectFactory.from_birth_data(
...     name="Alice", year=1985, month=3, day=21,
...     hour=14, minute=30, lat=51.5074, lng=-0.1278,
...     tz_str="Europe/London"
... )
>>> factory = PlanetaryReturnFactory(
...     subject,
...     city="London",
...     nation="GB",
...     online=True,
...     geonames_username="your_username"
... )

Offline mode with manual coordinates:

>>> factory = PlanetaryReturnFactory(
...     subject,
...     lng=-74.0060,
...     lat=40.7128,
...     tz_str="America/New_York",
...     online=False
... )

Different location for return chart:

>>> # Calculate return as if living in a different city
>>> factory = PlanetaryReturnFactory(
...     natal_subject,  # Born in London
...     city="Paris",   # But living in Paris
...     nation="FR",
...     online=True
... )

Use Cases: - Annual Solar Return charts for yearly forecasting - Monthly Lunar Return charts for timing analysis - Relocation returns for different geographic locations - Research into planetary cycle effects - Astrological consultation and chart analysis - Educational demonstrations of celestial mechanics

Note: Return calculations use the exact degree and minute of natal planetary positions. The resulting charts are cast for the precise moment when the transiting planet reaches this position, which may not align with calendar dates (especially for Solar Returns, which can occur on different dates depending on leap years and location).

PlanetaryReturnFactory( subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, city: Optional[str] = None, nation: Optional[str] = None, lng: Union[int, float, NoneType] = None, lat: Union[int, float, NoneType] = None, tz_str: Optional[str] = None, online: bool = True, geonames_username: Optional[str] = None, *, cache_expire_after_days: int = 30, altitude: Union[float, int, NoneType] = None)
208    def __init__(
209            self,
210            subject: AstrologicalSubjectModel,
211            city: Union[str, None] = None,
212            nation: Union[str, None] = None,
213            lng: Union[int, float, None] = None,
214            lat: Union[int, float, None] = None,
215            tz_str: Union[str, None] = None,
216            online: bool = True,
217            geonames_username: Union[str, None] = None,
218            *,
219            cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
220            altitude: Union[float, int, None] = None,
221        ):
222
223        """
224        Initialize a PlanetaryReturnFactory instance with location and configuration settings.
225
226        This constructor sets up the factory with all necessary parameters for calculating
227        planetary returns at a specified location. It supports both online mode (with
228        automatic geocoding via Geonames) and offline mode (with manual coordinates).
229
230        The factory validates input parameters based on the chosen mode and automatically
231        retrieves missing location data when operating online. All location parameters
232        are stored and used for casting return charts at the exact calculated moments.
233
234        Args:
235            subject (AstrologicalSubjectModel): The natal astrological subject containing
236                birth data and planetary positions. This subject's natal planetary
237                positions serve as reference points for calculating returns.
238            city (Optional[str]): City name for the return chart location. Must be a
239                recognizable city name for Geonames geocoding when using online mode.
240                Examples: "New York", "London", "Tokyo", "Paris".
241            nation (Optional[str]): Country or nation code for the return chart location.
242                Use ISO country codes for best results (e.g., "US", "GB", "JP", "FR").
243                Required when online=True.
244            lng (Optional[Union[int, float]]): Geographic longitude coordinate in decimal
245                degrees for return chart location. Range: -180.0 to +180.0.
246                Positive values represent East longitude, negative values West longitude.
247                Required when online=False.
248            lat (Optional[Union[int, float]]): Geographic latitude coordinate in decimal
249                degrees for return chart location. Range: -90.0 to +90.0.
250                Positive values represent North latitude, negative values South latitude.
251                Required when online=False.
252            tz_str (Optional[str]): Timezone identifier string for return chart location.
253                Must be a valid timezone from the IANA Time Zone Database
254                (e.g., "America/New_York", "Europe/London", "Asia/Tokyo").
255                Required when online=False.
256            online (bool, optional): Location data retrieval mode. When True, uses
257                Geonames web service to automatically fetch coordinates and timezone
258                from city/nation parameters. When False, uses manually provided
259                coordinates and timezone. Defaults to True.
260            geonames_username (Optional[str]): Username for Geonames API access.
261                Required when online=True and coordinates are not manually provided.
262                Free accounts available at http://www.geonames.org/login.
263                If None and required, uses default username with warning.
264            cache_expire_after_days (int, optional): Number of days to cache Geonames
265                location data locally before requiring refresh. Helps reduce API
266                calls and improve performance for repeated calculations.
267                Defaults to system configuration value.
268            altitude (Optional[Union[float, int]]): Elevation above sea level in meters
269                for the return chart location. Currently reserved for future use in
270                advanced astronomical calculations. Defaults to None.
271
272        Raises:
273            KerykeionException: If city is not provided when online=True.
274            KerykeionException: If nation is not provided when online=True.
275            KerykeionException: If coordinates (lat/lng) are not provided when online=False.
276            KerykeionException: If timezone (tz_str) is not provided when online=False.
277            KerykeionException: If Geonames API fails to retrieve valid location data.
278            KerykeionException: If required parameters are missing for the chosen mode.
279
280        Examples:
281            Initialize with online geocoding:
282
283            >>> factory = PlanetaryReturnFactory(
284            ...     subject,
285            ...     city="San Francisco",
286            ...     nation="US",
287            ...     online=True,
288            ...     geonames_username="your_username"
289            ... )
290
291            Initialize with manual coordinates:
292
293            >>> factory = PlanetaryReturnFactory(
294            ...     subject,
295            ...     lng=-122.4194,
296            ...     lat=37.7749,
297            ...     tz_str="America/Los_Angeles",
298            ...     online=False
299            ... )
300
301            Initialize with mixed parameters (coordinates override online lookup):
302
303            >>> factory = PlanetaryReturnFactory(
304            ...     subject,
305            ...     city="Custom Location",
306            ...     lng=-74.0060,
307            ...     lat=40.7128,
308            ...     tz_str="America/New_York",
309            ...     online=False
310            ... )
311
312        Note:
313            - When both online and manual coordinates are provided, offline mode takes precedence
314            - Geonames cache helps reduce API calls for frequently used locations
315            - Timezone accuracy is crucial for precise return calculations
316            - Location parameters affect house cusps and angular positions in return charts
317        """
318        # Store basic configuration
319        self.subject = subject
320        self.online = online
321        self.cache_expire_after_days = cache_expire_after_days
322        self.altitude = altitude
323
324        # Geonames username
325        if geonames_username is None and online and (not lat or not lng or not tz_str):
326            logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
327            self.geonames_username = DEFAULT_GEONAMES_USERNAME
328        else:
329            self.geonames_username = geonames_username # type: ignore
330
331        # City
332        if not city and online:
333            raise KerykeionException("You need to set the city if you want to use the online mode!")
334        else:
335            self.city = city
336
337        # Nation
338        if not nation and online:
339            raise KerykeionException("You need to set the nation if you want to use the online mode!")
340        else:
341            self.nation = nation
342
343        # Latitude
344        if not lat and not online:
345            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
346        else:
347            self.lat = lat # type: ignore
348
349        # Longitude
350        if not lng and not online:
351            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
352        else:
353            self.lng = lng # type: ignore
354
355        # Timezone
356        if (not online) and (not tz_str):
357            raise KerykeionException("You need to set the coordinates and timezone if you want to use the offline mode!")
358        else:
359            self.tz_str = tz_str # type: ignore
360
361        # Online mode
362        if (self.online) and (not self.tz_str) and (not self.lat) and (not self.lng):
363            logging.info("Fetching timezone/coordinates from geonames")
364
365            if not self.city or not self.nation or not self.geonames_username:
366                raise KerykeionException("You need to set the city and nation if you want to use the online mode!")
367
368            geonames = FetchGeonames(
369                self.city,
370                self.nation,
371                username=self.geonames_username,
372                cache_expire_after_days=self.cache_expire_after_days
373            )
374            self.city_data: dict[str, str] = geonames.get_serialized_data()
375
376            if (
377                "countryCode" not in self.city_data
378                or "timezonestr" not in self.city_data
379                or "lat" not in self.city_data
380                or "lng" not in self.city_data
381            ):
382                raise KerykeionException("No data found for this city, try again! Maybe check your connection?")
383
384            self.nation = self.city_data["countryCode"]
385            self.lng = float(self.city_data["lng"])
386            self.lat = float(self.city_data["lat"])
387            self.tz_str = self.city_data["timezonestr"]

Initialize a PlanetaryReturnFactory instance with location and configuration settings.

This constructor sets up the factory with all necessary parameters for calculating planetary returns at a specified location. It supports both online mode (with automatic geocoding via Geonames) and offline mode (with manual coordinates).

The factory validates input parameters based on the chosen mode and automatically retrieves missing location data when operating online. All location parameters are stored and used for casting return charts at the exact calculated moments.

Args: subject (AstrologicalSubjectModel): The natal astrological subject containing birth data and planetary positions. This subject's natal planetary positions serve as reference points for calculating returns. city (Optional[str]): City name for the return chart location. Must be a recognizable city name for Geonames geocoding when using online mode. Examples: "New York", "London", "Tokyo", "Paris". nation (Optional[str]): Country or nation code for the return chart location. Use ISO country codes for best results (e.g., "US", "GB", "JP", "FR"). Required when online=True. lng (Optional[Union[int, float]]): Geographic longitude coordinate in decimal degrees for return chart location. Range: -180.0 to +180.0. Positive values represent East longitude, negative values West longitude. Required when online=False. lat (Optional[Union[int, float]]): Geographic latitude coordinate in decimal degrees for return chart location. Range: -90.0 to +90.0. Positive values represent North latitude, negative values South latitude. Required when online=False. tz_str (Optional[str]): Timezone identifier string for return chart location. Must be a valid timezone from the IANA Time Zone Database (e.g., "America/New_York", "Europe/London", "Asia/Tokyo"). Required when online=False. online (bool, optional): Location data retrieval mode. When True, uses Geonames web service to automatically fetch coordinates and timezone from city/nation parameters. When False, uses manually provided coordinates and timezone. Defaults to True. geonames_username (Optional[str]): Username for Geonames API access. Required when online=True and coordinates are not manually provided. Free accounts available at http://www.geonames.org/login. If None and required, uses default username with warning. cache_expire_after_days (int, optional): Number of days to cache Geonames location data locally before requiring refresh. Helps reduce API calls and improve performance for repeated calculations. Defaults to system configuration value. altitude (Optional[Union[float, int]]): Elevation above sea level in meters for the return chart location. Currently reserved for future use in advanced astronomical calculations. Defaults to None.

Raises: KerykeionException: If city is not provided when online=True. KerykeionException: If nation is not provided when online=True. KerykeionException: If coordinates (lat/lng) are not provided when online=False. KerykeionException: If timezone (tz_str) is not provided when online=False. KerykeionException: If Geonames API fails to retrieve valid location data. KerykeionException: If required parameters are missing for the chosen mode.

Examples: Initialize with online geocoding:

>>> factory = PlanetaryReturnFactory(
...     subject,
...     city="San Francisco",
...     nation="US",
...     online=True,
...     geonames_username="your_username"
... )

Initialize with manual coordinates:

>>> factory = PlanetaryReturnFactory(
...     subject,
...     lng=-122.4194,
...     lat=37.7749,
...     tz_str="America/Los_Angeles",
...     online=False
... )

Initialize with mixed parameters (coordinates override online lookup):

>>> factory = PlanetaryReturnFactory(
...     subject,
...     city="Custom Location",
...     lng=-74.0060,
...     lat=40.7128,
...     tz_str="America/New_York",
...     online=False
... )

Note: - When both online and manual coordinates are provided, offline mode takes precedence - Geonames cache helps reduce API calls for frequently used locations - Timezone accuracy is crucial for precise return calculations - Location parameters affect house cusps and angular positions in return charts

subject
online
cache_expire_after_days
altitude
def next_return_from_iso_formatted_time( self, iso_formatted_time: str, return_type: Literal['Lunar', 'Solar']) -> PlanetReturnModel:
389    def next_return_from_iso_formatted_time(
390        self,
391        iso_formatted_time: str,
392        return_type: ReturnType
393    ) -> PlanetReturnModel:
394        """
395        Calculate the next planetary return occurring after a specified ISO-formatted datetime.
396
397        This method computes the exact moment when the specified planet (Sun or Moon) returns
398        to its natal position, starting the search from the provided datetime. It uses precise
399        Swiss Ephemeris calculations to determine the exact return moment and generates a
400        complete astrological chart for that calculated time.
401
402        The calculation process:
403        1. Converts the ISO datetime to Julian Day format for astronomical calculations
404        2. Uses Swiss Ephemeris functions (solcross_ut/mooncross_ut) to find the exact
405           return moment when the planet reaches its natal degree and minute
406        3. Creates a complete AstrologicalSubject instance for the calculated return time
407        4. Returns a comprehensive PlanetReturnModel with all chart data
408
409        Args:
410            iso_formatted_time (str): Starting datetime in ISO format for the search.
411                Must be a valid ISO 8601 datetime string (e.g., "2024-01-15T10:30:00"
412                or "2024-01-15T10:30:00+00:00"). The method will find the next return
413                occurring after this moment.
414            return_type (ReturnType): Type of planetary return to calculate.
415                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
416                This determines which planet's return cycle to compute.
417
418        Returns:
419            PlanetReturnModel: A comprehensive Pydantic model containing complete
420                astrological chart data for the calculated return moment, including:
421                - Exact return datetime (UTC and local timezone)
422                - All planetary positions at the return moment
423                - House cusps and angles for the return location
424                - Complete astrological subject data with all calculated points
425                - Return type identifier and subject name
426                - Julian Day Number for the return moment
427
428        Raises:
429            KerykeionException: If return_type is not "Solar" or "Lunar".
430            ValueError: If iso_formatted_time is not a valid ISO datetime format.
431            SwissEphException: If Swiss Ephemeris calculations fail due to invalid
432                date ranges or astronomical calculation errors.
433
434        Examples:
435            Calculate next Solar Return after a specific date:
436
437            >>> factory = PlanetaryReturnFactory(subject, ...)
438            >>> solar_return = factory.next_return_from_iso_formatted_time(
439            ...     "2024-06-15T12:00:00",
440            ...     "Solar"
441            ... )
442            >>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")
443            >>> print(f"Sun position: {solar_return.sun.abs_pos}°")
444
445            Calculate next Lunar Return with timezone:
446
447            >>> lunar_return = factory.next_return_from_iso_formatted_time(
448            ...     "2024-01-01T00:00:00+00:00",
449            ...     "Lunar"
450            ... )
451            >>> print(f"Moon return in {lunar_return.tz_str}")
452            >>> print(f"Return occurs: {lunar_return.iso_formatted_local_datetime}")
453
454            Access complete chart data from return:
455
456            >>> return_chart = factory.next_return_from_iso_formatted_time(
457            ...     datetime.now().isoformat(),
458            ...     "Solar"
459            ... )
460            >>> # Access all planetary positions
461            >>> for planet in return_chart.planets_list:
462            ...     print(f"{planet.name}: {planet.abs_pos}° in {planet.sign}")
463            >>> # Access house cusps
464            >>> for house in return_chart.houses_list:
465            ...     print(f"House {house.number}: {house.abs_pos}°")
466
467        Technical Notes:
468            - Solar returns typically occur within 1-2 days of the natal birthday
469            - Lunar returns occur approximately every 27.3 days (sidereal month)
470            - Return moments are calculated to the second for maximum precision
471            - The method accounts for leap years and varying orbital speeds
472            - Return charts use the factory's configured location, not the natal location
473
474        Use Cases:
475            - Annual birthday return chart calculations
476            - Monthly lunar return timing for astrological consultation
477            - Research into planetary cycle patterns and timing
478            - Forecasting and predictive astrology applications
479            - Educational demonstrations of astronomical cycles
480
481        See Also:
482            next_return_from_year(): Simplified interface for yearly calculations
483            next_return_from_date(): Date-based calculation interface
484        """
485
486        date = datetime.fromisoformat(iso_formatted_time)
487        julian_day = datetime_to_julian(date)
488
489        return_julian_date = None
490        if return_type == "Solar":
491            if self.subject.sun is None:
492                raise KerykeionException("Sun position is required for Solar return but is not available in the subject.")
493            return_julian_date = swe.solcross_ut(
494                self.subject.sun.abs_pos,
495                julian_day,
496            )
497        elif return_type == "Lunar":
498            if self.subject.moon is None:
499                raise KerykeionException("Moon position is required for Lunar return but is not available in the subject.")
500            return_julian_date = swe.mooncross_ut(
501                self.subject.moon.abs_pos,
502                julian_day,
503            )
504        else:
505            raise KerykeionException(f"Invalid return type {return_type}. Use 'Solar' or 'Lunar'.")
506
507        solar_return_date_utc = julian_to_datetime(return_julian_date)
508        solar_return_date_utc = solar_return_date_utc.replace(tzinfo=timezone.utc)
509
510        solar_return_astrological_subject = AstrologicalSubjectFactory.from_iso_utc_time(
511            name=self.subject.name,
512            iso_utc_time=solar_return_date_utc.isoformat(),
513            lng=self.lng,       # type: ignore
514            lat=self.lat,       # type: ignore
515            tz_str=self.tz_str, # type: ignore
516            city=self.city,     # type: ignore
517            nation=self.nation, # type: ignore
518            online=False,
519            altitude=self.altitude,
520            active_points=self.subject.active_points,
521        )
522
523        model_data = solar_return_astrological_subject.model_dump()
524        model_data['name'] = f"{self.subject.name} {return_type} Return"
525        model_data['return_type'] = return_type
526
527        return PlanetReturnModel(
528            **model_data,
529        )

Calculate the next planetary return occurring after a specified ISO-formatted datetime.

This method computes the exact moment when the specified planet (Sun or Moon) returns to its natal position, starting the search from the provided datetime. It uses precise Swiss Ephemeris calculations to determine the exact return moment and generates a complete astrological chart for that calculated time.

The calculation process:

  1. Converts the ISO datetime to Julian Day format for astronomical calculations
  2. Uses Swiss Ephemeris functions (solcross_ut/mooncross_ut) to find the exact return moment when the planet reaches its natal degree and minute
  3. Creates a complete AstrologicalSubject instance for the calculated return time
  4. Returns a comprehensive PlanetReturnModel with all chart data

Args: iso_formatted_time (str): Starting datetime in ISO format for the search. Must be a valid ISO 8601 datetime string (e.g., "2024-01-15T10:30:00" or "2024-01-15T10:30:00+00:00"). The method will find the next return occurring after this moment. return_type (ReturnType): Type of planetary return to calculate. Must be either "Solar" for Sun returns or "Lunar" for Moon returns. This determines which planet's return cycle to compute.

Returns: PlanetReturnModel: A comprehensive Pydantic model containing complete astrological chart data for the calculated return moment, including: - Exact return datetime (UTC and local timezone) - All planetary positions at the return moment - House cusps and angles for the return location - Complete astrological subject data with all calculated points - Return type identifier and subject name - Julian Day Number for the return moment

Raises: KerykeionException: If return_type is not "Solar" or "Lunar". ValueError: If iso_formatted_time is not a valid ISO datetime format. SwissEphException: If Swiss Ephemeris calculations fail due to invalid date ranges or astronomical calculation errors.

Examples: Calculate next Solar Return after a specific date:

>>> factory = PlanetaryReturnFactory(subject, ...)
>>> solar_return = factory.next_return_from_iso_formatted_time(
...     "2024-06-15T12:00:00",
...     "Solar"
... )
>>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")
>>> print(f"Sun position: {solar_return.sun.abs_pos}°")

Calculate next Lunar Return with timezone:

>>> lunar_return = factory.next_return_from_iso_formatted_time(
...     "2024-01-01T00:00:00+00:00",
...     "Lunar"
... )
>>> print(f"Moon return in {lunar_return.tz_str}")
>>> print(f"Return occurs: {lunar_return.iso_formatted_local_datetime}")

Access complete chart data from return:

>>> return_chart = factory.next_return_from_iso_formatted_time(
...     datetime.now().isoformat(),
...     "Solar"
... )
>>> # Access all planetary positions
>>> for planet in return_chart.planets_list:
...     print(f"{planet.name}: {planet.abs_pos}° in {planet.sign}")
>>> # Access house cusps
>>> for house in return_chart.houses_list:
...     print(f"House {house.number}: {house.abs_pos}°")

Technical Notes: - Solar returns typically occur within 1-2 days of the natal birthday - Lunar returns occur approximately every 27.3 days (sidereal month) - Return moments are calculated to the second for maximum precision - The method accounts for leap years and varying orbital speeds - Return charts use the factory's configured location, not the natal location

Use Cases: - Annual birthday return chart calculations - Monthly lunar return timing for astrological consultation - Research into planetary cycle patterns and timing - Forecasting and predictive astrology applications - Educational demonstrations of astronomical cycles

See Also: next_return_from_year(): Simplified interface for yearly calculations next_return_from_date(): Date-based calculation interface

def next_return_from_year( self, year: int, return_type: Literal['Lunar', 'Solar']) -> PlanetReturnModel:
531    def next_return_from_year(
532        self,
533        year: int,
534        return_type: ReturnType
535    ) -> PlanetReturnModel:
536        """
537        Calculate the planetary return occurring within a specified year.
538
539        This is a convenience method that finds the first planetary return (Solar or Lunar)
540        that occurs in the given calendar year. It automatically searches from January 1st
541        of the specified year and returns the first return found, making it ideal for
542        annual forecasting and birthday return calculations.
543
544        For Solar Returns, this typically finds the return closest to the natal birthday
545        within that year. For Lunar Returns, it finds the first lunar return occurring
546        in January of the specified year.
547
548        The method internally uses next_return_from_iso_formatted_time() with a starting
549        point of January 1st at midnight UTC for the specified year.
550
551        Args:
552            year (int): The calendar year to search for the return. Must be a valid
553                year (typically between 1800-2200 for reliable ephemeris data).
554                Examples: 2024, 2025, 1990, 2050.
555            return_type (ReturnType): The type of planetary return to calculate.
556                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
557
558        Returns:
559            PlanetReturnModel: A comprehensive model containing the return chart data
560                for the first return found in the specified year. Includes:
561                - Exact return datetime in both UTC and local timezone
562                - Complete planetary positions at the return moment
563                - House cusps calculated for the factory's configured location
564                - All astrological chart features and calculated points
565                - Return type and subject identification
566
567        Raises:
568            KerykeionException: If return_type is not "Solar" or "Lunar".
569            ValueError: If year is outside the valid range for ephemeris calculations.
570            SwissEphException: If astronomical calculations fail for the given year.
571
572        Examples:
573            Calculate Solar Return for 2024:
574
575            >>> factory = PlanetaryReturnFactory(subject, ...)
576            >>> solar_return_2024 = factory.next_return_from_year(2024, "Solar")
577            >>> print(f"2024 Solar Return: {solar_return_2024.iso_formatted_local_datetime}")
578            >>> print(f"Birthday location: {solar_return_2024.city}, {solar_return_2024.nation}")
579
580            Calculate first Lunar Return of 2025:
581
582            >>> lunar_return = factory.next_return_from_year(2025, "Lunar")
583            >>> print(f"First 2025 Lunar Return: {lunar_return.iso_formatted_local_datetime}")
584
585            Compare multiple years:
586
587            >>> for year in [2023, 2024, 2025]:
588            ...     solar_return = factory.next_return_from_year(year, "Solar")
589            ...     print(f"{year}: {solar_return.iso_formatted_local_datetime}")
590
591        Practical Applications:
592            - Annual Solar Return chart casting for birthday forecasting
593            - Comparative analysis of return charts across multiple years
594            - Research into planetary return timing patterns
595            - Automated birthday return calculations for consultation
596            - Educational demonstrations of annual astrological cycles
597
598        Technical Notes:
599            - Solar returns in a given year occur near but not exactly on the birthday
600            - The exact date can vary by 1-2 days due to leap years and orbital mechanics
601            - Lunar returns occur approximately every 27.3 days throughout the year
602            - This method finds the chronologically first return in the year
603            - Return moment precision is calculated to the second
604
605        Use Cases:
606            - Birthday return chart interpretation
607            - Annual astrological forecasting
608            - Timing analysis for major life events
609            - Comparative return chart studies
610            - Astrological consultation preparation
611
612        See Also:
613            next_return_from_date(): For more specific date-based searches
614            next_return_from_iso_formatted_time(): For custom starting dates
615        """
616        import warnings
617        warnings.warn(
618            "next_return_from_year is deprecated, use next_return_from_date instead",
619            DeprecationWarning,
620            stacklevel=2
621        )
622        return self.next_return_from_date(year, 1, 1, return_type=return_type)

Calculate the planetary return occurring within a specified year.

This is a convenience method that finds the first planetary return (Solar or Lunar) that occurs in the given calendar year. It automatically searches from January 1st of the specified year and returns the first return found, making it ideal for annual forecasting and birthday return calculations.

For Solar Returns, this typically finds the return closest to the natal birthday within that year. For Lunar Returns, it finds the first lunar return occurring in January of the specified year.

The method internally uses next_return_from_iso_formatted_time() with a starting point of January 1st at midnight UTC for the specified year.

Args: year (int): The calendar year to search for the return. Must be a valid year (typically between 1800-2200 for reliable ephemeris data). Examples: 2024, 2025, 1990, 2050. return_type (ReturnType): The type of planetary return to calculate. Must be either "Solar" for Sun returns or "Lunar" for Moon returns.

Returns: PlanetReturnModel: A comprehensive model containing the return chart data for the first return found in the specified year. Includes: - Exact return datetime in both UTC and local timezone - Complete planetary positions at the return moment - House cusps calculated for the factory's configured location - All astrological chart features and calculated points - Return type and subject identification

Raises: KerykeionException: If return_type is not "Solar" or "Lunar". ValueError: If year is outside the valid range for ephemeris calculations. SwissEphException: If astronomical calculations fail for the given year.

Examples: Calculate Solar Return for 2024:

>>> factory = PlanetaryReturnFactory(subject, ...)
>>> solar_return_2024 = factory.next_return_from_year(2024, "Solar")
>>> print(f"2024 Solar Return: {solar_return_2024.iso_formatted_local_datetime}")
>>> print(f"Birthday location: {solar_return_2024.city}, {solar_return_2024.nation}")

Calculate first Lunar Return of 2025:

>>> lunar_return = factory.next_return_from_year(2025, "Lunar")
>>> print(f"First 2025 Lunar Return: {lunar_return.iso_formatted_local_datetime}")

Compare multiple years:

>>> for year in [2023, 2024, 2025]:
...     solar_return = factory.next_return_from_year(year, "Solar")
...     print(f"{year}: {solar_return.iso_formatted_local_datetime}")

Practical Applications: - Annual Solar Return chart casting for birthday forecasting - Comparative analysis of return charts across multiple years - Research into planetary return timing patterns - Automated birthday return calculations for consultation - Educational demonstrations of annual astrological cycles

Technical Notes: - Solar returns in a given year occur near but not exactly on the birthday - The exact date can vary by 1-2 days due to leap years and orbital mechanics - Lunar returns occur approximately every 27.3 days throughout the year - This method finds the chronologically first return in the year - Return moment precision is calculated to the second

Use Cases: - Birthday return chart interpretation - Annual astrological forecasting - Timing analysis for major life events - Comparative return chart studies - Astrological consultation preparation

See Also: next_return_from_date(): For more specific date-based searches next_return_from_iso_formatted_time(): For custom starting dates

def next_return_from_date( self, year: int, month: int, day: int = 1, *, return_type: Literal['Lunar', 'Solar']) -> PlanetReturnModel:
624    def next_return_from_date(
625        self,
626        year: int,
627        month: int,
628        day: int = 1,
629        *,
630        return_type: ReturnType
631    ) -> PlanetReturnModel:
632        """
633        Calculate the first planetary return occurring on or after a specified date.
634
635        This method provides precise timing control for planetary return calculations by
636        searching from a specific day, month, and year. It's particularly useful for
637        finding Lunar Returns when multiple returns occur within a single month
638        (approximately every 27.3 days).
639
640        The method searches from midnight (00:00:00 UTC) of the specified date,
641        finding the next return that occurs from that point forward.
642
643        Args:
644            year (int): The calendar year to search within. Must be a valid year
645                within the ephemeris data range (typically 1800-2200).
646            month (int): The month to start the search from. Must be between 1 and 12.
647            day (int): The day to start the search from. Must be a valid day for the
648                specified month (1-28/29/30/31 depending on month). Defaults to 1.
649            return_type (ReturnType): The type of planetary return to calculate.
650                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
651
652        Returns:
653            PlanetReturnModel: Comprehensive return chart data for the first return
654                found on or after the specified date.
655
656        Raises:
657            KerykeionException: If month is not between 1 and 12.
658            KerykeionException: If day is not valid for the given month/year.
659            KerykeionException: If return_type is not "Solar" or "Lunar".
660
661        Examples:
662            Find first Lunar Return after January 15, 2024:
663
664            >>> lunar_return = factory.next_return_from_date(
665            ...     2024, 1, 15, return_type="Lunar"
666            ... )
667
668            Find second Lunar Return in a month (after the first one):
669
670            >>> # First return from start of month
671            >>> first_lr = factory.next_return_from_date(2024, 1, 1, return_type="Lunar")
672            >>> # Second return from middle of month
673            >>> second_lr = factory.next_return_from_date(2024, 1, 15, return_type="Lunar")
674
675        See Also:
676            next_return_from_year(): For annual return calculations
677            next_return_from_iso_formatted_time(): For custom datetime searches
678        """
679        # Validate month input
680        if month < 1 or month > 12:
681            raise KerykeionException(f"Invalid month {month}. Month must be between 1 and 12.")
682
683        # Validate day input
684        max_day = calendar.monthrange(year, month)[1]
685        if day < 1 or day > max_day:
686            raise KerykeionException(
687                f"Invalid day {day} for {year}-{month:02d}. Day must be between 1 and {max_day}."
688            )
689
690        # Create datetime for the specified date (UTC)
691        start_date = datetime(year, month, day, 0, 0, tzinfo=timezone.utc)
692
693        # Get the return using the existing method
694        return self.next_return_from_iso_formatted_time(
695            start_date.isoformat(),
696            return_type
697        )

Calculate the first planetary return occurring on or after a specified date.

This method provides precise timing control for planetary return calculations by searching from a specific day, month, and year. It's particularly useful for finding Lunar Returns when multiple returns occur within a single month (approximately every 27.3 days).

The method searches from midnight (00:00:00 UTC) of the specified date, finding the next return that occurs from that point forward.

Args: year (int): The calendar year to search within. Must be a valid year within the ephemeris data range (typically 1800-2200). month (int): The month to start the search from. Must be between 1 and 12. day (int): The day to start the search from. Must be a valid day for the specified month (1-28/29/30/31 depending on month). Defaults to 1. return_type (ReturnType): The type of planetary return to calculate. Must be either "Solar" for Sun returns or "Lunar" for Moon returns.

Returns: PlanetReturnModel: Comprehensive return chart data for the first return found on or after the specified date.

Raises: KerykeionException: If month is not between 1 and 12. KerykeionException: If day is not valid for the given month/year. KerykeionException: If return_type is not "Solar" or "Lunar".

Examples: Find first Lunar Return after January 15, 2024:

>>> lunar_return = factory.next_return_from_date(
...     2024, 1, 15, return_type="Lunar"
... )

Find second Lunar Return in a month (after the first one):

>>> # First return from start of month
>>> first_lr = factory.next_return_from_date(2024, 1, 1, return_type="Lunar")
>>> # Second return from middle of month
>>> second_lr = factory.next_return_from_date(2024, 1, 15, return_type="Lunar")

See Also: next_return_from_year(): For annual return calculations next_return_from_iso_formatted_time(): For custom datetime searches

def next_return_from_month_and_year( self, year: int, month: int, return_type: Literal['Lunar', 'Solar']) -> PlanetReturnModel:
699    def next_return_from_month_and_year(
700        self,
701        year: int,
702        month: int,
703        return_type: ReturnType
704    ) -> PlanetReturnModel:
705        """
706        DEPRECATED: Use next_return_from_date() instead.
707
708        Calculate the first planetary return occurring in or after a specified month and year.
709        This method is kept for backward compatibility and will be removed in a future version.
710
711        Args:
712            year (int): The calendar year to search within.
713            month (int): The month to start the search from (1-12).
714            return_type (ReturnType): "Solar" or "Lunar".
715
716        Returns:
717            PlanetReturnModel: Return chart data for the first return found.
718        """
719        import warnings
720        warnings.warn(
721            "next_return_from_month_and_year is deprecated, use next_return_from_date instead",
722            DeprecationWarning,
723            stacklevel=2
724        )
725        return self.next_return_from_date(year, month, 1, return_type=return_type)

DEPRECATED: Use next_return_from_date() instead.

Calculate the first planetary return occurring in or after a specified month and year. This method is kept for backward compatibility and will be removed in a future version.

Args: year (int): The calendar year to search within. month (int): The month to start the search from (1-12). return_type (ReturnType): "Solar" or "Lunar".

Returns: PlanetReturnModel: Return chart data for the first return found.

class PlanetReturnModel(kerykeion.schemas.kr_models.AstrologicalBaseModel):
275class PlanetReturnModel(AstrologicalBaseModel):
276    """
277    Pydantic Model for Planet Return
278    """
279    # Specific return data
280    return_type: ReturnType = Field(description="Type of return: Solar or Lunar")

Pydantic Model for Planet Return

return_type: Literal['Lunar', 'Solar']
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class RelationshipScoreFactory:
 65class RelationshipScoreFactory:
 66    """
 67    Calculates relationship scores between two subjects using the Ciro Discepolo method.
 68
 69    The scoring system evaluates synastry aspects between planetary positions to generate
 70    numerical compatibility scores with categorical descriptions.
 71
 72    Score Ranges:
 73        - 0-5: Minimal relationship
 74        - 5-10: Medium relationship
 75        - 10-15: Important relationship
 76        - 15-20: Very important relationship
 77        - 20-30: Exceptional relationship
 78        - 30+: Rare exceptional relationship
 79
 80    Args:
 81        first_subject (AstrologicalSubjectModel): First astrological subject
 82        second_subject (AstrologicalSubjectModel): Second astrological subject
 83        use_only_major_aspects (bool, optional): Filter to major aspects only. Defaults to True.
 84        axis_orb_limit (float | None, optional): Optional orb threshold for chart axes
 85            filtering during aspect calculation.
 86
 87    Reference:
 88        http://www.cirodiscepolo.it/Articoli/Discepoloele.htm
 89    """
 90
 91    SCORE_MAPPING = [
 92        ("Minimal", 5),
 93        ("Medium", 10),
 94        ("Important", 15),
 95        ("Very Important", 20),
 96        ("Exceptional", 30),
 97        ("Rare Exceptional", float("inf")),
 98    ]
 99
100    MAJOR_ASPECTS = {"conjunction", "opposition", "square", "trine", "sextile"}
101
102    def __init__(
103        self,
104        first_subject: AstrologicalSubjectModel,
105        second_subject: AstrologicalSubjectModel,
106        use_only_major_aspects: bool = True,
107        *,
108        axis_orb_limit: Optional[float] = None,
109    ):
110        self.use_only_major_aspects = use_only_major_aspects
111        self.first_subject: AstrologicalSubjectModel = first_subject
112        self.second_subject: AstrologicalSubjectModel = second_subject
113
114        self.score_value = 0
115        self.relationship_score_description: RelationshipScoreDescription = "Minimal"
116        self.is_destiny_sign = False
117        self.relationship_score_aspects: list[RelationshipScoreAspectModel] = []
118        self.score_breakdown: list[ScoreBreakdownItemModel] = []
119        self._synastry_aspects = AspectsFactory.dual_chart_aspects(
120            self.first_subject,
121            self.second_subject,
122            axis_orb_limit=axis_orb_limit,
123            first_subject_is_fixed=True,
124            second_subject_is_fixed=True,
125        ).aspects
126
127    def _evaluate_destiny_sign(self):
128        """
129        Checks if subjects share the same sun sign quality and adds points.
130
131        Adds 5 points if both subjects have sun signs with matching quality
132        (cardinal, fixed, or mutable).
133        """
134        if self.first_subject.sun["quality"] == self.second_subject.sun["quality"]: # type: ignore
135            self.is_destiny_sign = True
136            self.score_value += DESTINY_SIGN_POINTS
137            quality = self.first_subject.sun["quality"] # type: ignore
138            self.score_breakdown.append(
139                ScoreBreakdownItemModel(
140                    rule="destiny_sign",
141                    description=f"Both Sun signs share {quality} quality",
142                    points=DESTINY_SIGN_POINTS,
143                    details=f"{self.first_subject.sun['sign']} - {self.second_subject.sun['sign']}"  # type: ignore
144                )
145            )
146            logging.debug(f"Destiny sign found, adding {DESTINY_SIGN_POINTS} points, total score: {self.score_value}")
147
148    def _evaluate_aspect(self, aspect, points, rule: str, description: str):
149        """
150        Processes an aspect and adds points to the total score.
151
152        Args:
153            aspect (dict): Aspect data containing planetary positions and geometry
154            points (int): Points to add to the total score
155            rule (str): Rule identifier for the breakdown
156            description (str): Human-readable description for the breakdown
157        """
158        if self.use_only_major_aspects and aspect["aspect"] not in self.MAJOR_ASPECTS:
159            return
160
161        self.score_value += points
162        self.relationship_score_aspects.append(
163            RelationshipScoreAspectModel(
164                p1_name=aspect["p1_name"],
165                p2_name=aspect["p2_name"],
166                aspect=aspect["aspect"],
167                orbit=aspect["orbit"],
168            )
169        )
170        self.score_breakdown.append(
171            ScoreBreakdownItemModel(
172                rule=rule,
173                description=description,
174                points=points,
175                details=f"{aspect['p1_name']}-{aspect['p2_name']} {aspect['aspect']} (orbit: {aspect['orbit']:.2f}°)"
176            )
177        )
178        logging.debug(f"{aspect['p1_name']}-{aspect['p2_name']} aspect: {aspect['aspect']} with orbit {aspect['orbit']} degrees, adding {points} points, total score: {self.score_value}, total aspects: {len(self.relationship_score_aspects)}")
179
180    def _evaluate_sun_sun_main_aspect(self, aspect):
181        """
182        Evaluates Sun-Sun conjunction, opposition, or square aspects.
183
184        Adds 8 points for standard orbs, 11 points for tight orbs (≤2°).
185
186        Args:
187            aspect (dict): Aspect data
188        """
189        if aspect["p1_name"] == "Sun" and aspect["p2_name"] == "Sun" and aspect["aspect"] in {"conjunction", "opposition", "square"}:
190            is_high_precision = aspect["orbit"] <= HIGH_PRECISION_ORBIT_THRESHOLD
191            points = MAJOR_ASPECT_POINTS_HIGH_PRECISION if is_high_precision else MAJOR_ASPECT_POINTS_STANDARD
192            precision = "high precision (≤2°)" if is_high_precision else "standard"
193            self._evaluate_aspect(
194                aspect, points,
195                rule="sun_sun_major",
196                description=f"Sun-Sun {aspect['aspect']} ({precision})"
197            )
198
199    def _evaluate_sun_moon_conjunction(self, aspect):
200        """
201        Evaluates Sun-Moon conjunction aspects.
202
203        Adds 8 points for standard orbs, 11 points for tight orbs (≤2°).
204
205        Args:
206            aspect (dict): Aspect data
207        """
208        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Sun"} and aspect["aspect"] == "conjunction":
209            is_high_precision = aspect["orbit"] <= HIGH_PRECISION_ORBIT_THRESHOLD
210            points = MAJOR_ASPECT_POINTS_HIGH_PRECISION if is_high_precision else MAJOR_ASPECT_POINTS_STANDARD
211            precision = "high precision (≤2°)" if is_high_precision else "standard"
212            self._evaluate_aspect(
213                aspect, points,
214                rule="sun_moon_conjunction",
215                description=f"Sun-Moon conjunction ({precision})"
216            )
217
218    def _evaluate_sun_sun_other_aspects(self, aspect):
219        """
220        Evaluates Sun-Sun aspects other than conjunction, opposition, or square.
221
222        Adds 4 points for any qualifying aspect.
223
224        Args:
225            aspect (dict): Aspect data
226        """
227        if aspect["p1_name"] == "Sun" and aspect["p2_name"] == "Sun" and aspect["aspect"] not in {"conjunction", "opposition", "square"}:
228            self._evaluate_aspect(
229                aspect, MINOR_ASPECT_POINTS,
230                rule="sun_sun_minor",
231                description=f"Sun-Sun {aspect['aspect']}"
232            )
233
234    def _evaluate_sun_moon_other_aspects(self, aspect):
235        """
236        Evaluates Sun-Moon aspects other than conjunctions.
237
238        Adds 4 points for any qualifying aspect.
239
240        Args:
241            aspect (dict): Aspect data
242        """
243        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Sun"} and aspect["aspect"] != "conjunction":
244            self._evaluate_aspect(
245                aspect, MINOR_ASPECT_POINTS,
246                rule="sun_moon_other",
247                description=f"Sun-Moon {aspect['aspect']}"
248            )
249
250    def _evaluate_sun_ascendant_aspect(self, aspect):
251        """
252        Evaluates Sun-Ascendant aspects.
253
254        Adds 4 points for any aspect between Sun and Ascendant.
255
256        Args:
257            aspect (dict): Aspect data
258        """
259        if {aspect["p1_name"], aspect["p2_name"]} == {"Sun", "Ascendant"}:
260            self._evaluate_aspect(
261                aspect, SUN_ASCENDANT_ASPECT_POINTS,
262                rule="sun_ascendant",
263                description=f"Sun-Ascendant {aspect['aspect']}"
264            )
265
266    def _evaluate_moon_ascendant_aspect(self, aspect):
267        """
268        Evaluates Moon-Ascendant aspects.
269
270        Adds 4 points for any aspect between Moon and Ascendant.
271
272        Args:
273            aspect (dict): Aspect data
274        """
275        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Ascendant"}:
276            self._evaluate_aspect(
277                aspect, MOON_ASCENDANT_ASPECT_POINTS,
278                rule="moon_ascendant",
279                description=f"Moon-Ascendant {aspect['aspect']}"
280            )
281
282    def _evaluate_venus_mars_aspect(self, aspect):
283        """
284        Evaluates Venus-Mars aspects.
285
286        Adds 4 points for any aspect between Venus and Mars.
287
288        Args:
289            aspect (dict): Aspect data
290        """
291        if {aspect["p1_name"], aspect["p2_name"]} == {"Venus", "Mars"}:
292            self._evaluate_aspect(
293                aspect, VENUS_MARS_ASPECT_POINTS,
294                rule="venus_mars",
295                description=f"Venus-Mars {aspect['aspect']}"
296            )
297
298    def _evaluate_relationship_score_description(self):
299        """
300        Determines the categorical description based on the numerical score.
301
302        Maps the total score to predefined description ranges.
303        """
304        for description, threshold in self.SCORE_MAPPING:
305            if self.score_value < threshold:
306                self.relationship_score_description = description
307                break
308
309    def get_relationship_score(self):
310        """
311        Calculates the complete relationship score using all evaluation methods.
312
313        Returns:
314            RelationshipScoreModel: Score object containing numerical value, description,
315                destiny sign status, contributing aspects, and subject data.
316        """
317        self._evaluate_destiny_sign()
318
319        for aspect in self._synastry_aspects:
320            self._evaluate_sun_sun_main_aspect(aspect)
321            self._evaluate_sun_moon_conjunction(aspect)
322            self._evaluate_sun_moon_other_aspects(aspect)
323            self._evaluate_sun_sun_other_aspects(aspect)
324            self._evaluate_sun_ascendant_aspect(aspect)
325            self._evaluate_moon_ascendant_aspect(aspect)
326            self._evaluate_venus_mars_aspect(aspect)
327
328        self._evaluate_relationship_score_description()
329
330        return RelationshipScoreModel(
331            score_value=self.score_value,
332            score_description=self.relationship_score_description,
333            is_destiny_sign=self.is_destiny_sign,
334            aspects=self.relationship_score_aspects,
335            score_breakdown=self.score_breakdown,
336            subjects=[self.first_subject, self.second_subject],
337        )

Calculates relationship scores between two subjects using the Ciro Discepolo method.

The scoring system evaluates synastry aspects between planetary positions to generate numerical compatibility scores with categorical descriptions.

Score Ranges: - 0-5: Minimal relationship - 5-10: Medium relationship - 10-15: Important relationship - 15-20: Very important relationship - 20-30: Exceptional relationship - 30+: Rare exceptional relationship

Args: first_subject (AstrologicalSubjectModel): First astrological subject second_subject (AstrologicalSubjectModel): Second astrological subject use_only_major_aspects (bool, optional): Filter to major aspects only. Defaults to True. axis_orb_limit (float | None, optional): Optional orb threshold for chart axes filtering during aspect calculation.

Reference: http://www.cirodiscepolo.it/Articoli/Discepoloele.htm

RelationshipScoreFactory( first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, use_only_major_aspects: bool = True, *, axis_orb_limit: Optional[float] = None)
102    def __init__(
103        self,
104        first_subject: AstrologicalSubjectModel,
105        second_subject: AstrologicalSubjectModel,
106        use_only_major_aspects: bool = True,
107        *,
108        axis_orb_limit: Optional[float] = None,
109    ):
110        self.use_only_major_aspects = use_only_major_aspects
111        self.first_subject: AstrologicalSubjectModel = first_subject
112        self.second_subject: AstrologicalSubjectModel = second_subject
113
114        self.score_value = 0
115        self.relationship_score_description: RelationshipScoreDescription = "Minimal"
116        self.is_destiny_sign = False
117        self.relationship_score_aspects: list[RelationshipScoreAspectModel] = []
118        self.score_breakdown: list[ScoreBreakdownItemModel] = []
119        self._synastry_aspects = AspectsFactory.dual_chart_aspects(
120            self.first_subject,
121            self.second_subject,
122            axis_orb_limit=axis_orb_limit,
123            first_subject_is_fixed=True,
124            second_subject_is_fixed=True,
125        ).aspects
SCORE_MAPPING = [('Minimal', 5), ('Medium', 10), ('Important', 15), ('Very Important', 20), ('Exceptional', 30), ('Rare Exceptional', inf)]
MAJOR_ASPECTS = {'square', 'trine', 'sextile', 'opposition', 'conjunction'}
use_only_major_aspects
first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
score_value
relationship_score_description: Literal['Minimal', 'Medium', 'Important', 'Very Important', 'Exceptional', 'Rare Exceptional']
is_destiny_sign
relationship_score_aspects: list[kerykeion.schemas.kr_models.RelationshipScoreAspectModel]
score_breakdown: list[kerykeion.schemas.kr_models.ScoreBreakdownItemModel]
def get_relationship_score(self):
309    def get_relationship_score(self):
310        """
311        Calculates the complete relationship score using all evaluation methods.
312
313        Returns:
314            RelationshipScoreModel: Score object containing numerical value, description,
315                destiny sign status, contributing aspects, and subject data.
316        """
317        self._evaluate_destiny_sign()
318
319        for aspect in self._synastry_aspects:
320            self._evaluate_sun_sun_main_aspect(aspect)
321            self._evaluate_sun_moon_conjunction(aspect)
322            self._evaluate_sun_moon_other_aspects(aspect)
323            self._evaluate_sun_sun_other_aspects(aspect)
324            self._evaluate_sun_ascendant_aspect(aspect)
325            self._evaluate_moon_ascendant_aspect(aspect)
326            self._evaluate_venus_mars_aspect(aspect)
327
328        self._evaluate_relationship_score_description()
329
330        return RelationshipScoreModel(
331            score_value=self.score_value,
332            score_description=self.relationship_score_description,
333            is_destiny_sign=self.is_destiny_sign,
334            aspects=self.relationship_score_aspects,
335            score_breakdown=self.score_breakdown,
336            subjects=[self.first_subject, self.second_subject],
337        )

Calculates the complete relationship score using all evaluation methods.

Returns: RelationshipScoreModel: Score object containing numerical value, description, destiny sign status, contributing aspects, and subject data.

class ReportGenerator:
 46class ReportGenerator:
 47    """
 48    Generate textual reports for astrological data models with a structure that mirrors the
 49    chart-specific dispatch logic used in :class:`~kerykeion.charts.chart_drawer.ChartDrawer`.
 50
 51    The generator accepts any of the chart data models handled by ``ChartDrawer`` as well as
 52    raw ``AstrologicalSubjectModel`` instances. The ``print_report`` method automatically
 53    selects the appropriate layout and sections depending on the underlying chart type.
 54    """
 55
 56    def __init__(
 57        self,
 58        model: Union[ChartDataModel, AstrologicalSubjectModel],
 59        *,
 60        include_aspects: bool = True,
 61        max_aspects: Optional[int] = None,
 62    ) -> None:
 63        self.model = model
 64        self._include_aspects_default = include_aspects
 65        self._max_aspects_default = max_aspects
 66
 67        self.chart_type: Optional[str] = None
 68        self._model_kind: LiteralReportKind
 69        self._chart_data: Optional[ChartDataModel] = None
 70        self._primary_subject: SubjectLike
 71        self._secondary_subject: Optional[SubjectLike] = None
 72        self._active_points: List[str] = []
 73        self._active_aspects: List[dict] = []
 74
 75        self._resolve_model()
 76
 77    # ------------------------------------------------------------------ #
 78    # Public API
 79    # ------------------------------------------------------------------ #
 80
 81    def generate_report(
 82        self,
 83        *,
 84        include_aspects: Optional[bool] = None,
 85        max_aspects: Optional[int] = None,
 86    ) -> str:
 87        """
 88        Build the report content without printing it.
 89
 90        Args:
 91            include_aspects: Override the default setting for including the aspects section.
 92            max_aspects: Override the default limit for the number of aspects displayed.
 93        """
 94        include_aspects = self._include_aspects_default if include_aspects is None else include_aspects
 95        max_aspects = self._max_aspects_default if max_aspects is None else max_aspects
 96
 97        if self._model_kind == "subject":
 98            sections = self._build_subject_report()
 99        elif self._model_kind == "single_chart":
100            sections = self._build_single_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
101        else:
102            sections = self._build_dual_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
103
104        title = self._build_title().strip("\n")
105        full_sections = [title, *[section for section in sections if section]]
106        return "\n\n".join(full_sections)
107
108    def print_report(
109        self,
110        *,
111        include_aspects: Optional[bool] = None,
112        max_aspects: Optional[int] = None,
113    ) -> None:
114        """
115        Print the generated report to stdout.
116        """
117        print(self.generate_report(include_aspects=include_aspects, max_aspects=max_aspects))
118
119    # ------------------------------------------------------------------ #
120    # Internal initialisation helpers
121    # ------------------------------------------------------------------ #
122
123    def _resolve_model(self) -> None:
124        if isinstance(self.model, AstrologicalSubjectModel):
125            self._model_kind = "subject"
126            self.chart_type = "Subject"
127            self._primary_subject = self.model
128            self._secondary_subject = None
129            self._active_points = list(self.model.active_points)
130            self._active_aspects = []
131        elif isinstance(self.model, SingleChartDataModel):
132            self._model_kind = "single_chart"
133            self.chart_type = self.model.chart_type
134            self._chart_data = self.model
135            self._primary_subject = self.model.subject
136            self._active_points = list(self.model.active_points)
137            self._active_aspects = [dict(aspect) for aspect in self.model.active_aspects]
138        elif isinstance(self.model, DualChartDataModel):
139            self._model_kind = "dual_chart"
140            self.chart_type = self.model.chart_type
141            self._chart_data = self.model
142            self._primary_subject = self.model.first_subject
143            self._secondary_subject = self.model.second_subject
144            self._active_points = list(self.model.active_points)
145            self._active_aspects = [dict(aspect) for aspect in self.model.active_aspects]
146        else:
147            supported = (
148                "AstrologicalSubjectModel, SingleChartDataModel, DualChartDataModel"
149            )
150            raise TypeError(f"Unsupported model type {type(self.model)!r}. Supported models: {supported}.")
151
152    # ------------------------------------------------------------------ #
153    # Report builders
154    # ------------------------------------------------------------------ #
155
156    def _build_subject_report(self) -> List[str]:
157        sections = [
158            self._subject_data_report(self._primary_subject, "Astrological Subject"),
159            self._celestial_points_report(self._primary_subject, "Celestial Points"),
160            self._houses_report(self._primary_subject, "Houses"),
161            self._lunar_phase_report(self._primary_subject),
162        ]
163        return sections
164
165    def _build_single_chart_report(self, *, include_aspects: bool, max_aspects: Optional[int]) -> List[str]:
166        assert self._chart_data is not None
167        sections: List[str] = [
168            self._subject_data_report(self._primary_subject, self._primary_subject_label()),
169        ]
170
171        if isinstance(self._primary_subject, CompositeSubjectModel):
172            sections.append(
173                self._subject_data_report(
174                    self._primary_subject.first_subject,
175                    "Composite – First Subject",
176                )
177            )
178            sections.append(
179                self._subject_data_report(
180                    self._primary_subject.second_subject,
181                    "Composite – Second Subject",
182                )
183            )
184
185        sections.extend([
186            self._celestial_points_report(self._primary_subject, f"{self._primary_subject_label()} Celestial Points"),
187            self._houses_report(self._primary_subject, f"{self._primary_subject_label()} Houses"),
188            self._lunar_phase_report(self._primary_subject),
189            self._elements_report(),
190            self._qualities_report(),
191            self._active_configuration_report(),
192        ])
193
194        if include_aspects:
195            sections.append(self._aspects_report(max_aspects=max_aspects))
196
197        return sections
198
199    def _build_dual_chart_report(self, *, include_aspects: bool, max_aspects: Optional[int]) -> List[str]:
200        assert self._chart_data is not None
201        primary_label, secondary_label = self._subject_role_labels()
202
203        sections: List[str] = [
204            self._subject_data_report(self._primary_subject, primary_label),
205        ]
206
207        if self._secondary_subject is not None:
208            sections.append(self._subject_data_report(self._secondary_subject, secondary_label))
209
210        sections.extend([
211            self._celestial_points_report(self._primary_subject, f"{primary_label} Celestial Points"),
212        ])
213
214        if self._secondary_subject is not None:
215            sections.append(
216                self._celestial_points_report(self._secondary_subject, f"{secondary_label} Celestial Points")
217            )
218
219        sections.append(self._houses_report(self._primary_subject, f"{primary_label} Houses"))
220
221        if self._secondary_subject is not None:
222            sections.append(self._houses_report(self._secondary_subject, f"{secondary_label} Houses"))
223
224        sections.extend([
225            self._lunar_phase_report(self._primary_subject),
226            self._elements_report(),
227            self._qualities_report(),
228            self._house_comparison_report(),
229            self._relationship_score_report(),
230            self._active_configuration_report(),
231        ])
232
233        if include_aspects:
234            sections.append(self._aspects_report(max_aspects=max_aspects))
235
236        return sections
237
238    # ------------------------------------------------------------------ #
239    # Section helpers
240    # ------------------------------------------------------------------ #
241
242    def _build_title(self) -> str:
243        if self._model_kind == "subject":
244            base_title = f"{self._primary_subject.name} — Subject Report"
245        elif self.chart_type == "Natal":
246            base_title = f"{self._primary_subject.name} — Natal Chart Report"
247        elif self.chart_type == "Composite":
248            if isinstance(self._primary_subject, CompositeSubjectModel):
249                first = self._primary_subject.first_subject.name
250                second = self._primary_subject.second_subject.name
251                base_title = f"{first} & {second} — Composite Report"
252            else:
253                base_title = f"{self._primary_subject.name} — Composite Report"
254        elif self.chart_type == "SingleReturnChart":
255            year = self._extract_year(self._primary_subject.iso_formatted_local_datetime)
256            if isinstance(self._primary_subject, PlanetReturnModel) and self._primary_subject.return_type == "Solar":
257                base_title = f"{self._primary_subject.name} — Solar Return {year or ''}".strip()
258            else:
259                base_title = f"{self._primary_subject.name} — Lunar Return {year or ''}".strip()
260        elif self.chart_type == "Transit":
261            date_str = self._format_date_iso(
262                self._secondary_subject.iso_formatted_local_datetime if self._secondary_subject else None
263            )
264            base_title = f"{self._primary_subject.name} — Transit {date_str}".strip()
265        elif self.chart_type == "Synastry":
266            second_name = self._secondary_subject.name if self._secondary_subject is not None else "Unknown"
267            base_title = f"{self._primary_subject.name} & {second_name} — Synastry Report"
268        elif self.chart_type == "DualReturnChart":
269            year = self._extract_year(
270                self._secondary_subject.iso_formatted_local_datetime if self._secondary_subject else None
271            )
272            if isinstance(self._secondary_subject, PlanetReturnModel) and self._secondary_subject.return_type == "Solar":
273                base_title = f"{self._primary_subject.name} — Solar Return Comparison {year or ''}".strip()
274            else:
275                base_title = f"{self._primary_subject.name} — Lunar Return Comparison {year or ''}".strip()
276        else:
277            base_title = f"{self._primary_subject.name} — Chart Report"
278
279        separator = "=" * len(base_title)
280        return f"\n{separator}\n{base_title}\n{separator}\n"
281
282    def _primary_subject_label(self) -> str:
283        if self.chart_type == "Composite":
284            return "Composite Chart"
285        if self.chart_type == "SingleReturnChart":
286            if isinstance(self._primary_subject, PlanetReturnModel) and self._primary_subject.return_type == "Solar":
287                return "Solar Return Chart"
288            return "Lunar Return Chart"
289        return f"{self.chart_type or 'Chart'}"
290
291    def _subject_role_labels(self) -> Tuple[str, str]:
292        if self.chart_type == "Transit":
293            return "Natal Subject", "Transit Subject"
294        if self.chart_type == "Synastry":
295            return "First Subject", "Second Subject"
296        if self.chart_type == "DualReturnChart":
297            return "Natal Subject", "Return Subject"
298        return "Primary Subject", "Secondary Subject"
299
300    def _subject_data_report(self, subject: SubjectLike, label: str) -> str:
301        birth_data = [["Field", "Value"], ["Name", subject.name]]
302
303        if isinstance(subject, CompositeSubjectModel):
304            composite_members = f"{subject.first_subject.name} & {subject.second_subject.name}"
305            birth_data.append(["Composite Members", composite_members])
306            birth_data.append(["Composite Type", subject.composite_chart_type])
307
308        if isinstance(subject, PlanetReturnModel):
309            birth_data.append(["Return Type", subject.return_type])
310
311        if isinstance(subject, AstrologicalSubjectModel):
312            birth_data.append(
313                ["Date", f"{subject.day:02d}/{subject.month:02d}/{subject.year}"]
314            )
315            birth_data.append(["Time", f"{subject.hour:02d}:{subject.minute:02d}"])
316
317        city = getattr(subject, "city", None)
318        if city:
319            birth_data.append(["City", str(city)])
320
321        nation = getattr(subject, "nation", None)
322        if nation:
323            birth_data.append(["Nation", str(nation)])
324
325        lat = getattr(subject, "lat", None)
326        if lat is not None:
327            birth_data.append(["Latitude", f"{lat:.4f}°"])
328
329        lng = getattr(subject, "lng", None)
330        if lng is not None:
331            birth_data.append(["Longitude", f"{lng:.4f}°"])
332
333        tz_str = getattr(subject, "tz_str", None)
334        if tz_str:
335            birth_data.append(["Timezone", str(tz_str)])
336
337        day_of_week = getattr(subject, "day_of_week", None)
338        if day_of_week:
339            birth_data.append(["Day of Week", str(day_of_week)])
340
341        iso_local = getattr(subject, "iso_formatted_local_datetime", None)
342        if iso_local:
343            birth_data.append(["ISO Local Datetime", iso_local])
344
345        settings_data = [["Setting", "Value"]]
346        settings_data.append(["Zodiac Type", str(subject.zodiac_type)])
347        if getattr(subject, "sidereal_mode", None):
348            settings_data.append(["Sidereal Mode", str(subject.sidereal_mode)])
349        settings_data.append(["Houses System", str(subject.houses_system_name)])
350        settings_data.append(["Perspective Type", str(subject.perspective_type)])
351
352        julian_day = getattr(subject, "julian_day", None)
353        if julian_day is not None:
354            settings_data.append(["Julian Day", f"{julian_day:.6f}"])
355
356        active_points = getattr(subject, "active_points", None)
357        if active_points:
358            settings_data.append(["Active Points Count", str(len(active_points))])
359
360        birth_table = AsciiTable(birth_data, title=f"{label} — Birth Data").table
361        settings_table = AsciiTable(settings_data, title=f"{label} — Settings").table
362        return f"{birth_table}\n\n{settings_table}"
363
364    def _celestial_points_report(self, subject: SubjectLike, title: str) -> str:
365        points = self._collect_celestial_points(subject)
366        if not points:
367            return "No celestial points data available."
368
369        main_planets = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
370        nodes = ["Mean_North_Lunar_Node", "True_North_Lunar_Node"]
371        angles = ["Ascendant", "Medium_Coeli", "Descendant", "Imum_Coeli"]
372
373        sorted_points = []
374        for name in angles + main_planets + nodes:
375            sorted_points.extend([p for p in points if p.name == name])
376
377        used_names = set(angles + main_planets + nodes)
378        sorted_points.extend([p for p in points if p.name not in used_names])
379
380        celestial_data: List[List[str]] = [["Point", "Sign", "Position", "Speed", "Decl.", "Ret.", "House"]]
381        for point in sorted_points:
382            speed_str = f"{point.speed:+.4f}°/d" if point.speed is not None else "N/A"
383            decl_str = f"{point.declination:+.2f}°" if point.declination is not None else "N/A"
384            ret_str = "R" if point.retrograde else "-"
385            house_str = point.house.replace("_", " ") if point.house else "-"
386            celestial_data.append([
387                point.name.replace("_", " "),
388                f"{point.sign} {point.emoji}",
389                f"{point.position:.2f}°",
390                speed_str,
391                decl_str,
392                ret_str,
393                house_str,
394            ])
395
396        return AsciiTable(celestial_data, title=title).table
397
398    def _collect_celestial_points(self, subject: SubjectLike) -> List[KerykeionPointModel]:
399        if isinstance(subject, AstrologicalSubjectModel):
400            return get_available_astrological_points_list(subject)
401
402        points: List[KerykeionPointModel] = []
403        active_points: Optional[Sequence[str]] = getattr(subject, "active_points", None)
404        if not active_points:
405            return points
406
407        for point_name in active_points:
408            attr_name = str(point_name).lower()
409            attr = getattr(subject, attr_name, None)
410            if attr is not None:
411                points.append(attr)
412
413        return points
414
415    def _houses_report(self, subject: SubjectLike, title: str) -> str:
416        try:
417            houses = get_houses_list(subject)  # type: ignore[arg-type]
418        except Exception:
419            return "No houses data available."
420
421        if not houses:
422            return "No houses data available."
423
424        houses_data: List[List[str]] = [["House", "Sign", "Position", "Absolute Position"]]
425        for house in houses:
426            houses_data.append([
427                house.name.replace("_", " "),
428                f"{house.sign} {house.emoji}",
429                f"{house.position:.2f}°",
430                f"{house.abs_pos:.2f}°",
431            ])
432
433        system_name = getattr(subject, "houses_system_name", "")
434        table_title = f"{title} ({system_name})" if system_name else title
435        return AsciiTable(houses_data, title=table_title).table
436
437    def _lunar_phase_report(self, subject: SubjectLike) -> str:
438        lunar = getattr(subject, "lunar_phase", None)
439        if not lunar:
440            return ""
441
442        lunar_data = [
443            ["Lunar Phase Information", "Value"],
444            ["Phase Name", f"{lunar.moon_phase_name} {lunar.moon_emoji}"],
445            ["Sun-Moon Angle", f"{lunar.degrees_between_s_m:.2f}°"],
446            ["Lunation Day", str(lunar.moon_phase)],
447        ]
448        return AsciiTable(lunar_data, title="Lunar Phase").table
449
450    def _elements_report(self) -> str:
451        if not self._chart_data or not getattr(self._chart_data, "element_distribution", None):
452            return ""
453
454        elem = self._chart_data.element_distribution
455        total = elem.fire + elem.earth + elem.air + elem.water
456        if total == 0:
457            return ""
458
459        element_data = [
460            ["Element", "Count", "Percentage"],
461            ["Fire 🔥", elem.fire, f"{(elem.fire / total * 100):.1f}%"],
462            ["Earth 🌍", elem.earth, f"{(elem.earth / total * 100):.1f}%"],
463            ["Air 💨", elem.air, f"{(elem.air / total * 100):.1f}%"],
464            ["Water 💧", elem.water, f"{(elem.water / total * 100):.1f}%"],
465            ["Total", total, "100%"],
466        ]
467        return AsciiTable(element_data, title="Element Distribution").table
468
469    def _qualities_report(self) -> str:
470        if not self._chart_data or not getattr(self._chart_data, "quality_distribution", None):
471            return ""
472
473        qual = self._chart_data.quality_distribution
474        total = qual.cardinal + qual.fixed + qual.mutable
475        if total == 0:
476            return ""
477
478        quality_data = [
479            ["Quality", "Count", "Percentage"],
480            ["Cardinal", qual.cardinal, f"{(qual.cardinal / total * 100):.1f}%"],
481            ["Fixed", qual.fixed, f"{(qual.fixed / total * 100):.1f}%"],
482            ["Mutable", qual.mutable, f"{(qual.mutable / total * 100):.1f}%"],
483            ["Total", total, "100%"],
484        ]
485        return AsciiTable(quality_data, title="Quality Distribution").table
486
487    def _active_configuration_report(self) -> str:
488        if not self._active_points and not self._active_aspects:
489            return ""
490
491        sections: List[str] = []
492
493        if self._active_points:
494            points_table = [["#", "Active Point"]]
495            for idx, point in enumerate(self._active_points, start=1):
496                points_table.append([str(idx), str(point)])
497            sections.append(AsciiTable(points_table, title="Active Celestial Points").table)
498
499        if self._active_aspects:
500            aspects_table = [["Aspect", "Orb (°)"]]
501            for aspect in self._active_aspects:
502                name = str(aspect.get("name", ""))
503                orb = aspect.get("orb")
504                orbit_str = f"{orb}" if orb is not None else "-"
505                aspects_table.append([name, orbit_str])
506            sections.append(AsciiTable(aspects_table, title="Active Aspects Configuration").table)
507
508        return "\n\n".join(sections)
509
510    def _aspects_report(self, *, max_aspects: Optional[int]) -> str:
511        if not self._chart_data or not getattr(self._chart_data, "aspects", None):
512            return ""
513
514        aspects_list = list(self._chart_data.aspects)
515
516        if not aspects_list:
517            return "No aspects data available."
518
519        total_aspects = len(aspects_list)
520        if max_aspects is not None:
521            aspects_list = aspects_list[:max_aspects]
522
523        is_dual = isinstance(self._chart_data, DualChartDataModel)
524        if is_dual:
525            table_header: List[str] = ["Point 1", "Owner 1", "Aspect", "Point 2", "Owner 2", "Orb", "Movement"]
526        else:
527            table_header = ["Point 1", "Aspect", "Point 2", "Orb", "Movement"]
528
529        aspects_table: List[List[str]] = [table_header]
530        for aspect in aspects_list:
531            aspect_name = str(aspect.aspect)
532            symbol = ASPECT_SYMBOLS.get(aspect_name.lower(), aspect_name)
533            movement_symbol = MOVEMENT_SYMBOLS.get(aspect.aspect_movement, "")
534            movement = f"{aspect.aspect_movement} {movement_symbol}".strip()
535
536            if is_dual:
537                aspects_table.append([
538                    aspect.p1_name.replace("_", " "),
539                    aspect.p1_owner,
540                    f"{aspect.aspect} {symbol}",
541                    aspect.p2_name.replace("_", " "),
542                    aspect.p2_owner,
543                    f"{aspect.orbit:.2f}°",
544                    movement,
545                ])
546            else:
547                aspects_table.append([
548                    aspect.p1_name.replace("_", " "),
549                    f"{aspect.aspect} {symbol}",
550                    aspect.p2_name.replace("_", " "),
551                    f"{aspect.orbit:.2f}°",
552                    movement,
553                ])
554
555        suffix = f" (showing {len(aspects_list)} of {total_aspects})" if max_aspects is not None else ""
556        title = f"Aspects{suffix}"
557        return AsciiTable(aspects_table, title=title).table
558
559    def _house_comparison_report(self) -> str:
560        if not isinstance(self._chart_data, DualChartDataModel) or not self._chart_data.house_comparison:
561            return ""
562
563        comparison = self._chart_data.house_comparison
564        sections = []
565
566        sections.append(
567            self._render_point_in_house_table(
568                comparison.first_points_in_second_houses,
569                f"{comparison.first_subject_name} points in {comparison.second_subject_name} houses",
570            )
571        )
572        sections.append(
573            self._render_point_in_house_table(
574                comparison.second_points_in_first_houses,
575                f"{comparison.second_subject_name} points in {comparison.first_subject_name} houses",
576            )
577        )
578
579        # Add cusp comparison sections
580        if comparison.first_cusps_in_second_houses:
581            sections.append(
582                self._render_cusp_in_house_table(
583                    comparison.first_cusps_in_second_houses,
584                    f"{comparison.first_subject_name} cusps in {comparison.second_subject_name} houses",
585                )
586            )
587
588        if comparison.second_cusps_in_first_houses:
589            sections.append(
590                self._render_cusp_in_house_table(
591                    comparison.second_cusps_in_first_houses,
592                    f"{comparison.second_subject_name} cusps in {comparison.first_subject_name} houses",
593                )
594            )
595
596        return "\n\n".join(section for section in sections if section)
597
598    def _render_point_in_house_table(self, points: Sequence[PointInHouseModel], title: str) -> str:
599        if not points:
600            return ""
601
602        table_data: List[List[str]] = [["Point", "Owner House", "Projected House", "Sign", "Degree"]]
603        for point in points:
604            owner_house = "-"
605            if point.point_owner_house_number is not None or point.point_owner_house_name:
606                owner_house = f"{point.point_owner_house_number or '-'} ({point.point_owner_house_name or '-'})"
607
608            projected_house = f"{point.projected_house_number} ({point.projected_house_name})"
609            table_data.append([
610                f"{point.point_owner_name}{point.point_name.replace('_', ' ')}",
611                owner_house,
612                projected_house,
613                point.point_sign,
614                f"{point.point_degree:.2f}°",
615            ])
616
617        return AsciiTable(table_data, title=title).table
618
619    def _render_cusp_in_house_table(self, points: Sequence[PointInHouseModel], title: str) -> str:
620        if not points:
621            return ""
622
623        table_data: List[List[str]] = [["Point", "Projected House", "Sign", "Degree"]]
624        for point in points:
625            projected_house = f"{point.projected_house_number} ({point.projected_house_name})"
626            table_data.append([
627                f"{point.point_owner_name}{point.point_name.replace('_', ' ')}",
628                projected_house,
629                point.point_sign,
630                f"{point.point_degree:.2f}°",
631            ])
632
633        return AsciiTable(table_data, title=title).table
634
635    def _relationship_score_report(self) -> str:
636        if not isinstance(self._chart_data, DualChartDataModel):
637            return ""
638
639        score: Optional[RelationshipScoreModel] = getattr(self._chart_data, "relationship_score", None)
640        if not score:
641            return ""
642
643        summary_table = [
644            ["Metric", "Value"],
645            ["Score", str(score.score_value)],
646            ["Description", str(score.score_description)],
647            ["Destiny Signature", "Yes" if score.is_destiny_sign else "No"],
648        ]
649
650        sections = [AsciiTable(summary_table, title="Relationship Score Summary").table]
651
652        if score.aspects:
653            aspects_table: List[List[str]] = [["Point 1", "Aspect", "Point 2", "Orb"]]
654            for aspect in score.aspects:
655                aspects_table.append([
656                    aspect.p1_name.replace("_", " "),
657                    aspect.aspect,
658                    aspect.p2_name.replace("_", " "),
659                    f"{aspect.orbit:.2f}°",
660                ])
661            sections.append(AsciiTable(aspects_table, title="Score Supporting Aspects").table)
662
663        return "\n\n".join(sections)
664
665    # ------------------------------------------------------------------ #
666    # Utility helpers
667    # ------------------------------------------------------------------ #
668
669    @staticmethod
670    def _extract_year(iso_datetime: Optional[str]) -> Optional[str]:
671        if not iso_datetime:
672            return None
673        try:
674            return datetime.fromisoformat(iso_datetime).strftime("%Y")
675        except ValueError:
676            return None
677
678    @staticmethod
679    def _format_date(iso_datetime: Optional[str]) -> str:
680        """
681        Format datetime in dd/mm/yyyy format.
682
683        .. deprecated::
684            Use _format_date_iso() for internationally unambiguous date formatting.
685        """
686        if not iso_datetime:
687            return ""
688        try:
689            return datetime.fromisoformat(iso_datetime).strftime("%d/%m/%Y")
690        except ValueError:
691            return iso_datetime
692
693    @staticmethod
694    def _format_date_iso(iso_datetime: Optional[str]) -> str:
695        """
696        Format datetime in ISO 8601 format (YYYY-MM-DD).
697
698        This format is internationally unambiguous and follows the ISO 8601 standard.
699        """
700        if not iso_datetime:
701            return ""
702        try:
703            return datetime.fromisoformat(iso_datetime).strftime("%Y-%m-%d")
704        except ValueError:
705            return iso_datetime

Generate textual reports for astrological data models with a structure that mirrors the chart-specific dispatch logic used in ~kerykeion.charts.chart_drawer.ChartDrawer.

The generator accepts any of the chart data models handled by ChartDrawer as well as raw AstrologicalSubjectModel instances. The print_report method automatically selects the appropriate layout and sections depending on the underlying chart type.

ReportGenerator( model: Union[SingleChartDataModel, DualChartDataModel, kerykeion.schemas.kr_models.AstrologicalSubjectModel], *, include_aspects: bool = True, max_aspects: Optional[int] = None)
56    def __init__(
57        self,
58        model: Union[ChartDataModel, AstrologicalSubjectModel],
59        *,
60        include_aspects: bool = True,
61        max_aspects: Optional[int] = None,
62    ) -> None:
63        self.model = model
64        self._include_aspects_default = include_aspects
65        self._max_aspects_default = max_aspects
66
67        self.chart_type: Optional[str] = None
68        self._model_kind: LiteralReportKind
69        self._chart_data: Optional[ChartDataModel] = None
70        self._primary_subject: SubjectLike
71        self._secondary_subject: Optional[SubjectLike] = None
72        self._active_points: List[str] = []
73        self._active_aspects: List[dict] = []
74
75        self._resolve_model()
model
chart_type: Optional[str]
def generate_report( self, *, include_aspects: Optional[bool] = None, max_aspects: Optional[int] = None) -> str:
 81    def generate_report(
 82        self,
 83        *,
 84        include_aspects: Optional[bool] = None,
 85        max_aspects: Optional[int] = None,
 86    ) -> str:
 87        """
 88        Build the report content without printing it.
 89
 90        Args:
 91            include_aspects: Override the default setting for including the aspects section.
 92            max_aspects: Override the default limit for the number of aspects displayed.
 93        """
 94        include_aspects = self._include_aspects_default if include_aspects is None else include_aspects
 95        max_aspects = self._max_aspects_default if max_aspects is None else max_aspects
 96
 97        if self._model_kind == "subject":
 98            sections = self._build_subject_report()
 99        elif self._model_kind == "single_chart":
100            sections = self._build_single_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
101        else:
102            sections = self._build_dual_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
103
104        title = self._build_title().strip("\n")
105        full_sections = [title, *[section for section in sections if section]]
106        return "\n\n".join(full_sections)

Build the report content without printing it.

Args: include_aspects: Override the default setting for including the aspects section. max_aspects: Override the default limit for the number of aspects displayed.

def print_report( self, *, include_aspects: Optional[bool] = None, max_aspects: Optional[int] = None) -> None:
108    def print_report(
109        self,
110        *,
111        include_aspects: Optional[bool] = None,
112        max_aspects: Optional[int] = None,
113    ) -> None:
114        """
115        Print the generated report to stdout.
116        """
117        print(self.generate_report(include_aspects=include_aspects, max_aspects=max_aspects))

Print the generated report to stdout.

class KerykeionSettingsModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
185class KerykeionSettingsModel(SubscriptableBaseModel):
186    """
187    This class is used to define the global settings for the Kerykeion.
188    """
189    language_settings: dict[str, KerykeionLanguageModel] = Field(title="Language Settings", description="The language settings of the chart")

This class is used to define the global settings for the Kerykeion.

language_settings: dict[str, kerykeion.schemas.settings_models.KerykeionLanguageModel]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class TransitsTimeRangeFactory:
 72class TransitsTimeRangeFactory:
 73    """
 74    Factory class for calculating astrological transits over time periods.
 75
 76    This class analyzes the angular relationships (aspects) between transiting
 77    celestial bodies and natal chart positions across multiple time points,
 78    generating structured transit data for astrological analysis.
 79
 80    The factory compares ephemeris data points (representing planetary positions
 81    at different moments) with a natal chart to identify when specific geometric
 82    configurations occur between transiting and natal celestial bodies.
 83
 84    Args:
 85        natal_chart (AstrologicalSubjectModel): The natal chart used as the reference
 86            point for transit calculations. All transiting positions are compared
 87            against this chart's planetary positions.
 88        ephemeris_data_points (List[AstrologicalSubjectModel]): A list of astrological
 89            subject models representing different moments in time, typically generated
 90            by EphemerisDataFactory. Each point contains planetary positions for
 91            a specific date/time.
 92        active_points (List[AstrologicalPoint], optional): List of celestial bodies
 93            to include in aspect calculations (e.g., Sun, Moon, planets, asteroids).
 94            Defaults to DEFAULT_ACTIVE_POINTS.
 95        active_aspects (List[ActiveAspect], optional): List of aspect types to
 96            calculate (e.g., conjunction, opposition, trine, square, sextile).
 97            Defaults to DEFAULT_ACTIVE_ASPECTS.
 98        settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional):
 99            Configuration settings for calculations. Can be a file path, settings
100            model, dictionary, or None for defaults. Defaults to None.
101        axis_orb_limit (float | None, optional): Optional orb threshold applied to chart axes
102            during single-chart aspect calculations. Dual-chart calculations ignore this value.
103
104    Attributes:
105        natal_chart: The reference natal chart for transit calculations.
106        ephemeris_data_points: Time-series planetary position data.
107        active_points: Celestial bodies included in calculations.
108        active_aspects: Aspect types considered for analysis.
109        settings_file: Configuration settings for the calculations.
110        axis_orb_limit: Optional orb override used when calculating single-chart aspects.
111
112    Examples:
113        Basic transit calculation:
114
115        >>> natal_chart = AstrologicalSubjectFactory.from_birth_data(...)
116        >>> ephemeris_data = ephemeris_factory.get_ephemeris_data_as_astrological_subjects()
117        >>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
118        >>> transits = factory.get_transit_moments()
119
120        Custom configuration:
121
122        >>> from kerykeion.schemas import AstrologicalPoint, ActiveAspect
123        >>> custom_points = ["Sun", "Moon"]
124        >>> custom_aspects = [ActiveAspect.CONJUNCTION, ActiveAspect.OPPOSITION]
125        >>> factory = TransitsTimeRangeFactory(
126        ...     natal_chart, ephemeris_data,
127        ...     active_points=custom_points,
128        ...     active_aspects=custom_aspects
129        ... )
130
131    Note:
132        - Calculation time scales with the number of ephemeris data points
133        - More active points and aspects increase computational requirements
134        - The natal chart's coordinate system should match the ephemeris data
135    """
136
137    def __init__(
138        self,
139        natal_chart: AstrologicalSubjectModel,
140        ephemeris_data_points: List[AstrologicalSubjectModel],
141        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
142        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
143        settings_file: Union[Path, KerykeionSettingsModel, dict, None] = None,
144        *,
145        axis_orb_limit: Optional[float] = None,
146    ):
147        """
148        Initialize the TransitsTimeRangeFactory with calculation parameters.
149
150        Sets up the factory with all necessary data and configuration for calculating
151        transits across the specified time period. The natal chart serves as the
152        reference point, while ephemeris data points provide the transiting positions
153        for comparison.
154
155        Args:
156            natal_chart (AstrologicalSubjectModel): Reference natal chart containing
157                the baseline planetary positions for transit calculations.
158            ephemeris_data_points (List[AstrologicalSubjectModel]): Time-ordered list
159                of planetary positions representing different moments in time.
160                Typically generated by EphemerisDataFactory.
161            active_points (List[AstrologicalPoint], optional): Celestial bodies to
162                include in aspect calculations. Determines which planets/points are
163                analyzed for aspects. Defaults to DEFAULT_ACTIVE_POINTS.
164            active_aspects (List[ActiveAspect], optional): Types of angular relationships
165                to calculate between natal and transiting positions. Defaults to
166                DEFAULT_ACTIVE_ASPECTS.
167            settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional):
168                Configuration settings for orb tolerances, calculation methods, and
169                other parameters. Defaults to None (uses system defaults).
170            axis_orb_limit (float | None, optional): Optional orb threshold for
171                chart axes applied during aspect calculations.
172
173        Note:
174            - All ephemeris data points should use the same coordinate system as the natal chart
175            - The order of ephemeris_data_points determines the chronological sequence
176            - Settings affect orb tolerances and calculation precision
177        """
178        self.natal_chart = natal_chart
179        self.ephemeris_data_points = ephemeris_data_points
180        self.active_points = active_points
181        self.active_aspects = active_aspects
182        self.settings_file = settings_file
183        self.axis_orb_limit = axis_orb_limit
184
185    def get_transit_moments(self) -> TransitsTimeRangeModel:
186        """
187        Calculate and generate transit data for all configured time points.
188
189        This method processes each ephemeris data point to identify angular relationships
190        (aspects) between transiting celestial bodies and natal chart positions. It
191        creates a comprehensive model containing all transit moments with their
192        corresponding aspects and timestamps.
193
194        The calculation process:
195        1. Iterates through each ephemeris data point chronologically
196        2. Compares transiting planetary positions with natal chart positions
197        3. Identifies aspects that fall within the configured orb tolerances
198        4. Creates timestamped transit moment records
199        5. Compiles all data into a structured model for analysis
200
201        Returns:
202            TransitsTimeRangeModel: A comprehensive model containing:
203                - dates (List[str]): ISO-formatted datetime strings for all data points
204                - subject (AstrologicalSubjectModel): The natal chart used as reference
205                - transits (List[TransitMomentModel]): Chronological list of transit moments,
206                  each containing:
207                  * date (str): ISO-formatted timestamp for the transit moment
208                  * aspects (List[RelevantAspect]): All aspects formed at this moment
209                    between transiting and natal positions
210
211        Examples:
212            Basic usage:
213
214            >>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
215            >>> results = factory.get_transit_moments()
216            >>>
217            >>> # Access specific data
218            >>> all_dates = results.dates
219            >>> first_transit = results.transits[0]
220            >>> aspects_at_first_moment = first_transit.aspects
221
222            Processing results:
223
224            >>> results = factory.get_transit_moments()
225            >>> for transit_moment in results.transits:
226            ...     print(f"Date: {transit_moment.date}")
227            ...     for aspect in transit_moment.aspects:
228            ...         print(f"  {aspect.p1_name} {aspect.aspect} {aspect.p2_name}")
229
230        Performance Notes:
231            - Calculation time is proportional to: number of time points × active points × active aspects
232            - Large datasets may require significant processing time
233            - Memory usage scales with the number of aspects found
234            - Consider filtering active_points and active_aspects for better performance
235
236        See Also:
237            TransitMomentModel: Individual transit moment structure
238            TransitsTimeRangeModel: Complete transit dataset structure
239            AspectsFactory: Underlying aspect calculation engine
240        """
241        transit_moments = []
242
243        for ephemeris_point in self.ephemeris_data_points:
244            # Calculate aspects between transit positions and natal chart
245            aspects = AspectsFactory.dual_chart_aspects(
246                ephemeris_point,
247                self.natal_chart,
248                active_points=self.active_points,
249                active_aspects=self.active_aspects,
250                axis_orb_limit=self.axis_orb_limit,
251                first_subject_is_fixed=False, # Transit is moving
252                second_subject_is_fixed=True, # Natal is fixed
253            ).aspects
254
255            # Create a transit moment for this point in time
256            transit_moments.append(
257                TransitMomentModel(
258                    date=ephemeris_point.iso_formatted_utc_datetime,
259                    aspects=aspects,
260                )
261            )
262
263        # Create and return the complete transits model
264        return TransitsTimeRangeModel(
265            dates=[point.iso_formatted_utc_datetime for point in self.ephemeris_data_points],
266            subject=self.natal_chart,
267            transits=transit_moments
268        )

Factory class for calculating astrological transits over time periods.

This class analyzes the angular relationships (aspects) between transiting celestial bodies and natal chart positions across multiple time points, generating structured transit data for astrological analysis.

The factory compares ephemeris data points (representing planetary positions at different moments) with a natal chart to identify when specific geometric configurations occur between transiting and natal celestial bodies.

Args: natal_chart (AstrologicalSubjectModel): The natal chart used as the reference point for transit calculations. All transiting positions are compared against this chart's planetary positions. ephemeris_data_points (List[AstrologicalSubjectModel]): A list of astrological subject models representing different moments in time, typically generated by EphemerisDataFactory. Each point contains planetary positions for a specific date/time. active_points (List[AstrologicalPoint], optional): List of celestial bodies to include in aspect calculations (e.g., Sun, Moon, planets, asteroids). Defaults to DEFAULT_ACTIVE_POINTS. active_aspects (List[ActiveAspect], optional): List of aspect types to calculate (e.g., conjunction, opposition, trine, square, sextile). Defaults to DEFAULT_ACTIVE_ASPECTS. settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional): Configuration settings for calculations. Can be a file path, settings model, dictionary, or None for defaults. Defaults to None. axis_orb_limit (float | None, optional): Optional orb threshold applied to chart axes during single-chart aspect calculations. Dual-chart calculations ignore this value.

Attributes: natal_chart: The reference natal chart for transit calculations. ephemeris_data_points: Time-series planetary position data. active_points: Celestial bodies included in calculations. active_aspects: Aspect types considered for analysis. settings_file: Configuration settings for the calculations. axis_orb_limit: Optional orb override used when calculating single-chart aspects.

Examples: Basic transit calculation:

>>> natal_chart = AstrologicalSubjectFactory.from_birth_data(...)
>>> ephemeris_data = ephemeris_factory.get_ephemeris_data_as_astrological_subjects()
>>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
>>> transits = factory.get_transit_moments()

Custom configuration:

>>> from kerykeion.schemas import AstrologicalPoint, ActiveAspect
>>> custom_points = ["Sun", "Moon"]
>>> custom_aspects = [ActiveAspect.CONJUNCTION, ActiveAspect.OPPOSITION]
>>> factory = TransitsTimeRangeFactory(
...     natal_chart, ephemeris_data,
...     active_points=custom_points,
...     active_aspects=custom_aspects
... )

Note: - Calculation time scales with the number of ephemeris data points - More active points and aspects increase computational requirements - The natal chart's coordinate system should match the ephemeris data

TransitsTimeRangeFactory( natal_chart: kerykeion.schemas.kr_models.AstrologicalSubjectModel, ephemeris_data_points: List[kerykeion.schemas.kr_models.AstrologicalSubjectModel], active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_North_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], settings_file: Union[pathlib.Path, KerykeionSettingsModel, dict, NoneType] = None, *, axis_orb_limit: Optional[float] = None)
137    def __init__(
138        self,
139        natal_chart: AstrologicalSubjectModel,
140        ephemeris_data_points: List[AstrologicalSubjectModel],
141        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
142        active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS,
143        settings_file: Union[Path, KerykeionSettingsModel, dict, None] = None,
144        *,
145        axis_orb_limit: Optional[float] = None,
146    ):
147        """
148        Initialize the TransitsTimeRangeFactory with calculation parameters.
149
150        Sets up the factory with all necessary data and configuration for calculating
151        transits across the specified time period. The natal chart serves as the
152        reference point, while ephemeris data points provide the transiting positions
153        for comparison.
154
155        Args:
156            natal_chart (AstrologicalSubjectModel): Reference natal chart containing
157                the baseline planetary positions for transit calculations.
158            ephemeris_data_points (List[AstrologicalSubjectModel]): Time-ordered list
159                of planetary positions representing different moments in time.
160                Typically generated by EphemerisDataFactory.
161            active_points (List[AstrologicalPoint], optional): Celestial bodies to
162                include in aspect calculations. Determines which planets/points are
163                analyzed for aspects. Defaults to DEFAULT_ACTIVE_POINTS.
164            active_aspects (List[ActiveAspect], optional): Types of angular relationships
165                to calculate between natal and transiting positions. Defaults to
166                DEFAULT_ACTIVE_ASPECTS.
167            settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional):
168                Configuration settings for orb tolerances, calculation methods, and
169                other parameters. Defaults to None (uses system defaults).
170            axis_orb_limit (float | None, optional): Optional orb threshold for
171                chart axes applied during aspect calculations.
172
173        Note:
174            - All ephemeris data points should use the same coordinate system as the natal chart
175            - The order of ephemeris_data_points determines the chronological sequence
176            - Settings affect orb tolerances and calculation precision
177        """
178        self.natal_chart = natal_chart
179        self.ephemeris_data_points = ephemeris_data_points
180        self.active_points = active_points
181        self.active_aspects = active_aspects
182        self.settings_file = settings_file
183        self.axis_orb_limit = axis_orb_limit

Initialize the TransitsTimeRangeFactory with calculation parameters.

Sets up the factory with all necessary data and configuration for calculating transits across the specified time period. The natal chart serves as the reference point, while ephemeris data points provide the transiting positions for comparison.

Args: natal_chart (AstrologicalSubjectModel): Reference natal chart containing the baseline planetary positions for transit calculations. ephemeris_data_points (List[AstrologicalSubjectModel]): Time-ordered list of planetary positions representing different moments in time. Typically generated by EphemerisDataFactory. active_points (List[AstrologicalPoint], optional): Celestial bodies to include in aspect calculations. Determines which planets/points are analyzed for aspects. Defaults to DEFAULT_ACTIVE_POINTS. active_aspects (List[ActiveAspect], optional): Types of angular relationships to calculate between natal and transiting positions. Defaults to DEFAULT_ACTIVE_ASPECTS. settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional): Configuration settings for orb tolerances, calculation methods, and other parameters. Defaults to None (uses system defaults). axis_orb_limit (float | None, optional): Optional orb threshold for chart axes applied during aspect calculations.

Note: - All ephemeris data points should use the same coordinate system as the natal chart - The order of ephemeris_data_points determines the chronological sequence - Settings affect orb tolerances and calculation precision

natal_chart
ephemeris_data_points
active_points
active_aspects
settings_file
axis_orb_limit
def get_transit_moments(self) -> kerykeion.schemas.kr_models.TransitsTimeRangeModel:
185    def get_transit_moments(self) -> TransitsTimeRangeModel:
186        """
187        Calculate and generate transit data for all configured time points.
188
189        This method processes each ephemeris data point to identify angular relationships
190        (aspects) between transiting celestial bodies and natal chart positions. It
191        creates a comprehensive model containing all transit moments with their
192        corresponding aspects and timestamps.
193
194        The calculation process:
195        1. Iterates through each ephemeris data point chronologically
196        2. Compares transiting planetary positions with natal chart positions
197        3. Identifies aspects that fall within the configured orb tolerances
198        4. Creates timestamped transit moment records
199        5. Compiles all data into a structured model for analysis
200
201        Returns:
202            TransitsTimeRangeModel: A comprehensive model containing:
203                - dates (List[str]): ISO-formatted datetime strings for all data points
204                - subject (AstrologicalSubjectModel): The natal chart used as reference
205                - transits (List[TransitMomentModel]): Chronological list of transit moments,
206                  each containing:
207                  * date (str): ISO-formatted timestamp for the transit moment
208                  * aspects (List[RelevantAspect]): All aspects formed at this moment
209                    between transiting and natal positions
210
211        Examples:
212            Basic usage:
213
214            >>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
215            >>> results = factory.get_transit_moments()
216            >>>
217            >>> # Access specific data
218            >>> all_dates = results.dates
219            >>> first_transit = results.transits[0]
220            >>> aspects_at_first_moment = first_transit.aspects
221
222            Processing results:
223
224            >>> results = factory.get_transit_moments()
225            >>> for transit_moment in results.transits:
226            ...     print(f"Date: {transit_moment.date}")
227            ...     for aspect in transit_moment.aspects:
228            ...         print(f"  {aspect.p1_name} {aspect.aspect} {aspect.p2_name}")
229
230        Performance Notes:
231            - Calculation time is proportional to: number of time points × active points × active aspects
232            - Large datasets may require significant processing time
233            - Memory usage scales with the number of aspects found
234            - Consider filtering active_points and active_aspects for better performance
235
236        See Also:
237            TransitMomentModel: Individual transit moment structure
238            TransitsTimeRangeModel: Complete transit dataset structure
239            AspectsFactory: Underlying aspect calculation engine
240        """
241        transit_moments = []
242
243        for ephemeris_point in self.ephemeris_data_points:
244            # Calculate aspects between transit positions and natal chart
245            aspects = AspectsFactory.dual_chart_aspects(
246                ephemeris_point,
247                self.natal_chart,
248                active_points=self.active_points,
249                active_aspects=self.active_aspects,
250                axis_orb_limit=self.axis_orb_limit,
251                first_subject_is_fixed=False, # Transit is moving
252                second_subject_is_fixed=True, # Natal is fixed
253            ).aspects
254
255            # Create a transit moment for this point in time
256            transit_moments.append(
257                TransitMomentModel(
258                    date=ephemeris_point.iso_formatted_utc_datetime,
259                    aspects=aspects,
260                )
261            )
262
263        # Create and return the complete transits model
264        return TransitsTimeRangeModel(
265            dates=[point.iso_formatted_utc_datetime for point in self.ephemeris_data_points],
266            subject=self.natal_chart,
267            transits=transit_moments
268        )

Calculate and generate transit data for all configured time points.

This method processes each ephemeris data point to identify angular relationships (aspects) between transiting celestial bodies and natal chart positions. It creates a comprehensive model containing all transit moments with their corresponding aspects and timestamps.

The calculation process:

  1. Iterates through each ephemeris data point chronologically
  2. Compares transiting planetary positions with natal chart positions
  3. Identifies aspects that fall within the configured orb tolerances
  4. Creates timestamped transit moment records
  5. Compiles all data into a structured model for analysis

Returns: TransitsTimeRangeModel: A comprehensive model containing: - dates (List[str]): ISO-formatted datetime strings for all data points - subject (AstrologicalSubjectModel): The natal chart used as reference - transits (List[TransitMomentModel]): Chronological list of transit moments, each containing: * date (str): ISO-formatted timestamp for the transit moment * aspects (List[RelevantAspect]): All aspects formed at this moment between transiting and natal positions

Examples: Basic usage:

>>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
>>> results = factory.get_transit_moments()
>>>
>>> # Access specific data
>>> all_dates = results.dates
>>> first_transit = results.transits[0]
>>> aspects_at_first_moment = first_transit.aspects

Processing results:

>>> results = factory.get_transit_moments()
>>> for transit_moment in results.transits:
...     print(f"Date: {transit_moment.date}")
...     for aspect in transit_moment.aspects:
...         print(f"  {aspect.p1_name} {aspect.aspect} {aspect.p2_name}")

Performance Notes: - Calculation time is proportional to: number of time points × active points × active aspects - Large datasets may require significant processing time - Memory usage scales with the number of aspects found - Consider filtering active_points and active_aspects for better performance

See Also: TransitMomentModel: Individual transit moment structure TransitsTimeRangeModel: Complete transit dataset structure AspectsFactory: Underlying aspect calculation engine

def to_context( model: Union[kerykeion.schemas.kr_models.KerykeionPointModel, kerykeion.schemas.kr_models.LunarPhaseModel, kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel, kerykeion.schemas.kr_models.AspectModel, SingleChartDataModel, DualChartDataModel, ElementDistributionModel, QualityDistributionModel, kerykeion.schemas.kr_models.TransitMomentModel, kerykeion.schemas.kr_models.TransitsTimeRangeModel, kerykeion.schemas.kr_models.PointInHouseModel, HouseComparisonModel]) -> str:
592def to_context(model: Union[
593    KerykeionPointModel,
594    LunarPhaseModel,
595    AstrologicalSubjectModel,
596    CompositeSubjectModel,
597    PlanetReturnModel,
598    AspectModel,
599    SingleChartDataModel,
600    DualChartDataModel,
601    ElementDistributionModel,
602    QualityDistributionModel,
603    TransitMomentModel,
604    TransitsTimeRangeModel,
605    PointInHouseModel,
606    HouseComparisonModel,
607]) -> str:
608    """
609    Main dispatcher function to convert any Kerykeion model to textual context.
610
611    This function automatically detects the model type and routes to the
612    appropriate transformer function.
613
614    Args:
615        model: Any supported Kerykeion Pydantic model.
616
617    Returns:
618        A string containing the textual representation of the model.
619
620    Raises:
621        TypeError: If the model type is not supported.
622
623    Example:
624        >>> from kerykeion import AstrologicalSubjectFactory, to_context
625        >>> subject = AstrologicalSubjectFactory.from_birth_data(...)
626        >>> context = to_context(subject)
627        >>> print(context)
628    """
629    if isinstance(model, SingleChartDataModel):
630        return single_chart_data_to_context(model)
631    elif isinstance(model, DualChartDataModel):
632        return dual_chart_data_to_context(model)
633    elif isinstance(model, TransitsTimeRangeModel):
634        return transits_time_range_to_context(model)
635    elif isinstance(model, TransitMomentModel):
636        return transit_moment_to_context(model)
637    elif isinstance(model, (AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel)):
638        return astrological_subject_to_context(model)
639    elif isinstance(model, KerykeionPointModel):
640        return kerykeion_point_to_context(model)
641    elif isinstance(model, LunarPhaseModel):
642        return lunar_phase_to_context(model)
643    elif isinstance(model, AspectModel):
644        return aspect_to_context(model)
645    elif isinstance(model, ElementDistributionModel):
646        return element_distribution_to_context(model)
647    elif isinstance(model, QualityDistributionModel):
648        return quality_distribution_to_context(model)
649    elif isinstance(model, PointInHouseModel):
650        return point_in_house_to_context(model)
651    elif isinstance(model, HouseComparisonModel):
652        return house_comparison_to_context(model)
653    else:
654        raise TypeError(
655            f"Unsupported model type: {type(model).__name__}. "
656            f"Supported types are: KerykeionPointModel, LunarPhaseModel, "
657            f"AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel, "
658            f"AspectModel, SingleChartDataModel, DualChartDataModel, "
659            f"ElementDistributionModel, QualityDistributionModel, "
660            f"TransitMomentModel, TransitsTimeRangeModel, "
661            f"PointInHouseModel, HouseComparisonModel"
662        )

Main dispatcher function to convert any Kerykeion model to textual context.

This function automatically detects the model type and routes to the appropriate transformer function.

Args: model: Any supported Kerykeion Pydantic model.

Returns: A string containing the textual representation of the model.

Raises: TypeError: If the model type is not supported.

Example:

from kerykeion import AstrologicalSubjectFactory, to_context subject = AstrologicalSubjectFactory.from_birth_data(...) context = to_context(subject) print(context)

class AstrologicalSubject:
152class AstrologicalSubject:
153    """Backward compatible wrapper implementing the requested __init__ signature."""
154
155    from datetime import datetime as _dt
156    NOW = _dt.utcnow()
157
158    def __init__(
159        self,
160        name: str = "Now",
161        year: int = NOW.year,  # type: ignore[misc]
162        month: int = NOW.month,  # type: ignore[misc]
163        day: int = NOW.day,  # type: ignore[misc]
164        hour: int = NOW.hour,  # type: ignore[misc]
165        minute: int = NOW.minute,  # type: ignore[misc]
166        city: Union[str, None] = None,
167        nation: Union[str, None] = None,
168        lng: Union[int, float, None] = None,
169        lat: Union[int, float, None] = None,
170        tz_str: Union[str, None] = None,
171        geonames_username: Union[str, None] = None,
172        zodiac_type: Union[ZodiacType, None] = None,  # default resolved below
173        online: bool = True,
174        disable_chiron: Union[None, bool] = None,  # deprecated
175        sidereal_mode: Union[SiderealMode, None] = None,
176        houses_system_identifier: Union[HousesSystemIdentifier, None] = None,
177        perspective_type: Union[PerspectiveType, None] = None,
178        cache_expire_after_days: Union[int, None] = None,
179        is_dst: Union[None, bool] = None,
180        disable_chiron_and_lilith: bool = False,  # currently not forwarded (not in factory)
181    ) -> None:
182        from .astrological_subject_factory import (
183            DEFAULT_ZODIAC_TYPE,
184            DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
185            DEFAULT_PERSPECTIVE_TYPE,
186            DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
187        )
188
189        _deprecated("AstrologicalSubject", "AstrologicalSubjectFactory.from_birth_data")
190
191        if disable_chiron is not None:
192            warnings.warn("'disable_chiron' è deprecato e ignorato.", DeprecationWarning, stacklevel=2)
193        if disable_chiron_and_lilith:
194            warnings.warn(
195                "'disable_chiron_and_lilith' non è supportato da from_birth_data in questa versione ed è ignorato.",
196                UserWarning,
197                stacklevel=2,
198            )
199
200        # Normalize legacy zodiac type values
201        zodiac_type = _normalize_zodiac_type_with_warning(zodiac_type)
202        zodiac_type = DEFAULT_ZODIAC_TYPE if zodiac_type is None else zodiac_type
203
204        houses_system_identifier = (
205            DEFAULT_HOUSES_SYSTEM_IDENTIFIER if houses_system_identifier is None else houses_system_identifier
206        )
207        perspective_type = DEFAULT_PERSPECTIVE_TYPE if perspective_type is None else perspective_type
208        cache_expire_after_days = (
209            DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS if cache_expire_after_days is None else cache_expire_after_days
210        )
211
212        self._model = AstrologicalSubjectFactory.from_birth_data(
213            name=name,
214            year=year,
215            month=month,
216            day=day,
217            hour=hour,
218            minute=minute,
219            seconds=0,
220            city=city,
221            nation=nation,
222            lng=float(lng) if lng is not None else None,
223            lat=float(lat) if lat is not None else None,
224            tz_str=tz_str,
225            geonames_username=geonames_username,
226            online=online,
227            zodiac_type=zodiac_type,  # type: ignore[arg-type]
228            sidereal_mode=sidereal_mode,  # type: ignore[arg-type]
229            houses_system_identifier=houses_system_identifier,  # type: ignore[arg-type]
230            perspective_type=perspective_type,  # type: ignore[arg-type]
231            cache_expire_after_days=cache_expire_after_days,
232            is_dst=is_dst,  # type: ignore[arg-type]
233        )
234
235        # Legacy filesystem attributes
236        self.json_dir = Path.home()
237
238    # Backward compatibility properties for v4 lunar node names
239    @property
240    def mean_node(self):
241        """Deprecated: Use mean_north_lunar_node instead."""
242        warnings.warn(
243            "'mean_node' is deprecated in Kerykeion v5. Use 'mean_north_lunar_node' instead.",
244            DeprecationWarning,
245            stacklevel=2,
246        )
247        return self._model.mean_north_lunar_node
248
249    @property
250    def true_node(self):
251        """Deprecated: Use true_north_lunar_node instead."""
252        warnings.warn(
253            "'true_node' is deprecated in Kerykeion v5. Use 'true_north_lunar_node' instead.",
254            DeprecationWarning,
255            stacklevel=2,
256        )
257        return self._model.true_north_lunar_node
258
259    @property
260    def mean_south_node(self):
261        """Deprecated: Use mean_south_lunar_node instead."""
262        warnings.warn(
263            "'mean_south_node' is deprecated in Kerykeion v5. Use 'mean_south_lunar_node' instead.",
264            DeprecationWarning,
265            stacklevel=2,
266        )
267        return self._model.mean_south_lunar_node
268
269    @property
270    def true_south_node(self):
271        """Deprecated: Use true_south_lunar_node instead."""
272        warnings.warn(
273            "'true_south_node' is deprecated in Kerykeion v5. Use 'true_south_lunar_node' instead.",
274            DeprecationWarning,
275            stacklevel=2,
276        )
277        return self._model.true_south_lunar_node
278
279    # Provide attribute passthrough for planetary points / houses used in README
280    def __getattr__(self, item: str) -> Any:  # pragma: no cover - dynamic proxy
281        try:
282            return getattr(self._model, item)
283        except AttributeError:
284            raise AttributeError(f"AstrologicalSubject has no attribute '{item}'") from None
285
286    def __repr__(self) -> str:
287        return self.__str__()
288
289    # Provide json() similar convenience
290    def json(
291        self,
292        dump: bool = False,
293        destination_folder: Optional[Union[str, Path]] = None,
294        indent: Optional[int] = None,
295    ) -> str:
296        """Replicate legacy json() behaviour returning a JSON string and optionally dumping to disk."""
297
298        json_string = self._model.model_dump_json(exclude_none=True, indent=indent)
299
300        if not dump:
301            return json_string
302
303        if destination_folder is not None:
304            target_dir = Path(destination_folder)
305        else:
306            target_dir = self.json_dir
307
308        target_dir.mkdir(parents=True, exist_ok=True)
309        json_path = target_dir / f"{self._model.name}_kerykeion.json"
310
311        with open(json_path, "w", encoding="utf-8") as file:
312            file.write(json_string)
313            logging.info("JSON file dumped in %s.", json_path)
314
315        return json_string
316
317    # Legacy helpers -----------------------------------------------------
318    @staticmethod
319    def _parse_iso_datetime(value: str) -> datetime:
320        if value.endswith("Z"):
321            value = value[:-1] + "+00:00"
322        return datetime.fromisoformat(value)
323
324    def model(self) -> AstrologicalSubjectModel:
325        """Return the underlying Pydantic model (legacy compatibility)."""
326
327        return self._model
328
329    def __getitem__(self, item: str) -> Any:
330        return getattr(self, item)
331
332    def get(self, item: str, default: Any = None) -> Any:
333        return getattr(self, item, default)
334
335    def __str__(self) -> str:
336        return (
337            f"Astrological data for: {self._model.name}, {self._model.iso_formatted_utc_datetime} UTC\n"
338            f"Birth location: {self._model.city}, Lat {self._model.lat}, Lon {self._model.lng}"
339        )
340
341    @cached_property
342    def utc_time(self) -> float:
343        """Backwards-compatible float UTC time value."""
344
345        dt = self._parse_iso_datetime(self._model.iso_formatted_utc_datetime)
346        return dt.hour + dt.minute / 60 + dt.second / 3600 + dt.microsecond / 3_600_000_000
347
348    @cached_property
349    def local_time(self) -> float:
350        """Backwards-compatible float local time value."""
351
352        dt = self._parse_iso_datetime(self._model.iso_formatted_local_datetime)
353        return dt.hour + dt.minute / 60 + dt.second / 3600 + dt.microsecond / 3_600_000_000
354
355    # Factory method compatibility (class method in old API)
356    @classmethod
357    def get_from_iso_utc_time(
358        cls,
359        name: str,
360        iso_utc_time: str,
361        city: str = "Greenwich",
362        nation: str = "GB",
363        tz_str: str = "Etc/GMT",
364        online: bool = False,
365        lng: Union[int, float] = 0.0,
366        lat: Union[int, float] = 51.5074,
367        geonames_username: Optional[str] = None,
368        zodiac_type: Optional[ZodiacType] = None,
369        disable_chiron_and_lilith: bool = False,
370        sidereal_mode: Optional[SiderealMode] = None,
371        houses_system_identifier: Optional[HousesSystemIdentifier] = None,
372        perspective_type: Optional[PerspectiveType] = None,
373        **kwargs: Any,
374    ) -> "AstrologicalSubject":
375        from .astrological_subject_factory import (
376            DEFAULT_ZODIAC_TYPE,
377            DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
378            DEFAULT_PERSPECTIVE_TYPE,
379            DEFAULT_GEONAMES_USERNAME,
380            GEONAMES_DEFAULT_USERNAME_WARNING,
381        )
382
383        _deprecated("AstrologicalSubject.get_from_iso_utc_time", "AstrologicalSubjectFactory.from_iso_utc_time")
384
385        if disable_chiron_and_lilith:
386            warnings.warn(
387                "'disable_chiron_and_lilith' is ignored by the new factory pipeline.",
388                UserWarning,
389                stacklevel=2,
390            )
391
392        resolved_geonames = geonames_username or DEFAULT_GEONAMES_USERNAME
393        if online and resolved_geonames == DEFAULT_GEONAMES_USERNAME:
394            warnings.warn(GEONAMES_DEFAULT_USERNAME_WARNING, UserWarning, stacklevel=2)
395
396        # Normalize legacy zodiac type values
397        normalized_zodiac_type = _normalize_zodiac_type_with_warning(zodiac_type)
398
399        model = AstrologicalSubjectFactory.from_iso_utc_time(
400            name=name,
401            iso_utc_time=iso_utc_time,
402            city=city,
403            nation=nation,
404            tz_str=tz_str,
405            online=online,
406            lng=float(lng),
407            lat=float(lat),
408            geonames_username=resolved_geonames,
409            zodiac_type=(normalized_zodiac_type or DEFAULT_ZODIAC_TYPE),  # type: ignore[arg-type]
410            sidereal_mode=sidereal_mode,
411            houses_system_identifier=(houses_system_identifier or DEFAULT_HOUSES_SYSTEM_IDENTIFIER),  # type: ignore[arg-type]
412            perspective_type=(perspective_type or DEFAULT_PERSPECTIVE_TYPE),  # type: ignore[arg-type]
413            **kwargs,
414        )
415
416        obj = cls.__new__(cls)
417        obj._model = model
418        obj.json_dir = Path.home()
419        return obj

Backward compatible wrapper implementing the requested __init__ signature.

AstrologicalSubject( name: str = 'Now', year: int = 2025, month: int = 12, day: int = 29, hour: int = 22, minute: int = 15, city: Optional[str] = None, nation: Optional[str] = None, lng: Union[int, float, NoneType] = None, lat: Union[int, float, NoneType] = None, tz_str: Optional[str] = None, geonames_username: Optional[str] = None, zodiac_type: Optional[Literal['Tropical', 'Sidereal']] = None, online: bool = True, disable_chiron: Optional[bool] = None, sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Optional[Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y']] = None, perspective_type: Optional[Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric']] = None, cache_expire_after_days: Optional[int] = None, is_dst: Optional[bool] = None, disable_chiron_and_lilith: bool = False)
158    def __init__(
159        self,
160        name: str = "Now",
161        year: int = NOW.year,  # type: ignore[misc]
162        month: int = NOW.month,  # type: ignore[misc]
163        day: int = NOW.day,  # type: ignore[misc]
164        hour: int = NOW.hour,  # type: ignore[misc]
165        minute: int = NOW.minute,  # type: ignore[misc]
166        city: Union[str, None] = None,
167        nation: Union[str, None] = None,
168        lng: Union[int, float, None] = None,
169        lat: Union[int, float, None] = None,
170        tz_str: Union[str, None] = None,
171        geonames_username: Union[str, None] = None,
172        zodiac_type: Union[ZodiacType, None] = None,  # default resolved below
173        online: bool = True,
174        disable_chiron: Union[None, bool] = None,  # deprecated
175        sidereal_mode: Union[SiderealMode, None] = None,
176        houses_system_identifier: Union[HousesSystemIdentifier, None] = None,
177        perspective_type: Union[PerspectiveType, None] = None,
178        cache_expire_after_days: Union[int, None] = None,
179        is_dst: Union[None, bool] = None,
180        disable_chiron_and_lilith: bool = False,  # currently not forwarded (not in factory)
181    ) -> None:
182        from .astrological_subject_factory import (
183            DEFAULT_ZODIAC_TYPE,
184            DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
185            DEFAULT_PERSPECTIVE_TYPE,
186            DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
187        )
188
189        _deprecated("AstrologicalSubject", "AstrologicalSubjectFactory.from_birth_data")
190
191        if disable_chiron is not None:
192            warnings.warn("'disable_chiron' è deprecato e ignorato.", DeprecationWarning, stacklevel=2)
193        if disable_chiron_and_lilith:
194            warnings.warn(
195                "'disable_chiron_and_lilith' non è supportato da from_birth_data in questa versione ed è ignorato.",
196                UserWarning,
197                stacklevel=2,
198            )
199
200        # Normalize legacy zodiac type values
201        zodiac_type = _normalize_zodiac_type_with_warning(zodiac_type)
202        zodiac_type = DEFAULT_ZODIAC_TYPE if zodiac_type is None else zodiac_type
203
204        houses_system_identifier = (
205            DEFAULT_HOUSES_SYSTEM_IDENTIFIER if houses_system_identifier is None else houses_system_identifier
206        )
207        perspective_type = DEFAULT_PERSPECTIVE_TYPE if perspective_type is None else perspective_type
208        cache_expire_after_days = (
209            DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS if cache_expire_after_days is None else cache_expire_after_days
210        )
211
212        self._model = AstrologicalSubjectFactory.from_birth_data(
213            name=name,
214            year=year,
215            month=month,
216            day=day,
217            hour=hour,
218            minute=minute,
219            seconds=0,
220            city=city,
221            nation=nation,
222            lng=float(lng) if lng is not None else None,
223            lat=float(lat) if lat is not None else None,
224            tz_str=tz_str,
225            geonames_username=geonames_username,
226            online=online,
227            zodiac_type=zodiac_type,  # type: ignore[arg-type]
228            sidereal_mode=sidereal_mode,  # type: ignore[arg-type]
229            houses_system_identifier=houses_system_identifier,  # type: ignore[arg-type]
230            perspective_type=perspective_type,  # type: ignore[arg-type]
231            cache_expire_after_days=cache_expire_after_days,
232            is_dst=is_dst,  # type: ignore[arg-type]
233        )
234
235        # Legacy filesystem attributes
236        self.json_dir = Path.home()
NOW = datetime.datetime(2025, 12, 29, 22, 15, 6, 439339)
json_dir
mean_node
239    @property
240    def mean_node(self):
241        """Deprecated: Use mean_north_lunar_node instead."""
242        warnings.warn(
243            "'mean_node' is deprecated in Kerykeion v5. Use 'mean_north_lunar_node' instead.",
244            DeprecationWarning,
245            stacklevel=2,
246        )
247        return self._model.mean_north_lunar_node

Deprecated: Use mean_north_lunar_node instead.

true_node
249    @property
250    def true_node(self):
251        """Deprecated: Use true_north_lunar_node instead."""
252        warnings.warn(
253            "'true_node' is deprecated in Kerykeion v5. Use 'true_north_lunar_node' instead.",
254            DeprecationWarning,
255            stacklevel=2,
256        )
257        return self._model.true_north_lunar_node

Deprecated: Use true_north_lunar_node instead.

mean_south_node
259    @property
260    def mean_south_node(self):
261        """Deprecated: Use mean_south_lunar_node instead."""
262        warnings.warn(
263            "'mean_south_node' is deprecated in Kerykeion v5. Use 'mean_south_lunar_node' instead.",
264            DeprecationWarning,
265            stacklevel=2,
266        )
267        return self._model.mean_south_lunar_node

Deprecated: Use mean_south_lunar_node instead.

true_south_node
269    @property
270    def true_south_node(self):
271        """Deprecated: Use true_south_lunar_node instead."""
272        warnings.warn(
273            "'true_south_node' is deprecated in Kerykeion v5. Use 'true_south_lunar_node' instead.",
274            DeprecationWarning,
275            stacklevel=2,
276        )
277        return self._model.true_south_lunar_node

Deprecated: Use true_south_lunar_node instead.

def json( self, dump: bool = False, destination_folder: Union[str, pathlib.Path, NoneType] = None, indent: Optional[int] = None) -> str:
290    def json(
291        self,
292        dump: bool = False,
293        destination_folder: Optional[Union[str, Path]] = None,
294        indent: Optional[int] = None,
295    ) -> str:
296        """Replicate legacy json() behaviour returning a JSON string and optionally dumping to disk."""
297
298        json_string = self._model.model_dump_json(exclude_none=True, indent=indent)
299
300        if not dump:
301            return json_string
302
303        if destination_folder is not None:
304            target_dir = Path(destination_folder)
305        else:
306            target_dir = self.json_dir
307
308        target_dir.mkdir(parents=True, exist_ok=True)
309        json_path = target_dir / f"{self._model.name}_kerykeion.json"
310
311        with open(json_path, "w", encoding="utf-8") as file:
312            file.write(json_string)
313            logging.info("JSON file dumped in %s.", json_path)
314
315        return json_string

Replicate legacy json() behaviour returning a JSON string and optionally dumping to disk.

def model(self) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
324    def model(self) -> AstrologicalSubjectModel:
325        """Return the underlying Pydantic model (legacy compatibility)."""
326
327        return self._model

Return the underlying Pydantic model (legacy compatibility).

def get(self, item: str, default: Any = None) -> Any:
332    def get(self, item: str, default: Any = None) -> Any:
333        return getattr(self, item, default)
utc_time: float
341    @cached_property
342    def utc_time(self) -> float:
343        """Backwards-compatible float UTC time value."""
344
345        dt = self._parse_iso_datetime(self._model.iso_formatted_utc_datetime)
346        return dt.hour + dt.minute / 60 + dt.second / 3600 + dt.microsecond / 3_600_000_000

Backwards-compatible float UTC time value.

local_time: float
348    @cached_property
349    def local_time(self) -> float:
350        """Backwards-compatible float local time value."""
351
352        dt = self._parse_iso_datetime(self._model.iso_formatted_local_datetime)
353        return dt.hour + dt.minute / 60 + dt.second / 3600 + dt.microsecond / 3_600_000_000

Backwards-compatible float local time value.

@classmethod
def get_from_iso_utc_time( cls, name: str, iso_utc_time: str, city: str = 'Greenwich', nation: str = 'GB', tz_str: str = 'Etc/GMT', online: bool = False, lng: Union[int, float] = 0.0, lat: Union[int, float] = 51.5074, geonames_username: Optional[str] = None, zodiac_type: Optional[Literal['Tropical', 'Sidereal']] = None, disable_chiron_and_lilith: bool = False, sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Optional[Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y']] = None, perspective_type: Optional[Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric']] = None, **kwargs: Any) -> AstrologicalSubject:
356    @classmethod
357    def get_from_iso_utc_time(
358        cls,
359        name: str,
360        iso_utc_time: str,
361        city: str = "Greenwich",
362        nation: str = "GB",
363        tz_str: str = "Etc/GMT",
364        online: bool = False,
365        lng: Union[int, float] = 0.0,
366        lat: Union[int, float] = 51.5074,
367        geonames_username: Optional[str] = None,
368        zodiac_type: Optional[ZodiacType] = None,
369        disable_chiron_and_lilith: bool = False,
370        sidereal_mode: Optional[SiderealMode] = None,
371        houses_system_identifier: Optional[HousesSystemIdentifier] = None,
372        perspective_type: Optional[PerspectiveType] = None,
373        **kwargs: Any,
374    ) -> "AstrologicalSubject":
375        from .astrological_subject_factory import (
376            DEFAULT_ZODIAC_TYPE,
377            DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
378            DEFAULT_PERSPECTIVE_TYPE,
379            DEFAULT_GEONAMES_USERNAME,
380            GEONAMES_DEFAULT_USERNAME_WARNING,
381        )
382
383        _deprecated("AstrologicalSubject.get_from_iso_utc_time", "AstrologicalSubjectFactory.from_iso_utc_time")
384
385        if disable_chiron_and_lilith:
386            warnings.warn(
387                "'disable_chiron_and_lilith' is ignored by the new factory pipeline.",
388                UserWarning,
389                stacklevel=2,
390            )
391
392        resolved_geonames = geonames_username or DEFAULT_GEONAMES_USERNAME
393        if online and resolved_geonames == DEFAULT_GEONAMES_USERNAME:
394            warnings.warn(GEONAMES_DEFAULT_USERNAME_WARNING, UserWarning, stacklevel=2)
395
396        # Normalize legacy zodiac type values
397        normalized_zodiac_type = _normalize_zodiac_type_with_warning(zodiac_type)
398
399        model = AstrologicalSubjectFactory.from_iso_utc_time(
400            name=name,
401            iso_utc_time=iso_utc_time,
402            city=city,
403            nation=nation,
404            tz_str=tz_str,
405            online=online,
406            lng=float(lng),
407            lat=float(lat),
408            geonames_username=resolved_geonames,
409            zodiac_type=(normalized_zodiac_type or DEFAULT_ZODIAC_TYPE),  # type: ignore[arg-type]
410            sidereal_mode=sidereal_mode,
411            houses_system_identifier=(houses_system_identifier or DEFAULT_HOUSES_SYSTEM_IDENTIFIER),  # type: ignore[arg-type]
412            perspective_type=(perspective_type or DEFAULT_PERSPECTIVE_TYPE),  # type: ignore[arg-type]
413            **kwargs,
414        )
415
416        obj = cls.__new__(cls)
417        obj._model = model
418        obj.json_dir = Path.home()
419        return obj
class KerykeionChartSVG:
424class KerykeionChartSVG:
425    """Wrapper emulating the v4 chart generation interface.
426
427    Old usage:
428        chart = KerykeionChartSVG(subject, chart_type="ExternalNatal", second_subject)
429        chart.makeSVG(minify_svg=True, remove_css_variables=True)
430
431    Replaced by ChartDataFactory + ChartDrawer.
432    """
433
434    def __init__(
435        self,
436        first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
437        chart_type: ChartType = "Natal",
438        second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None] = None,
439        new_output_directory: Union[str, None] = None,
440        new_settings_file: Union[Path, None, dict] = None,  # retained for signature compatibility (unused)
441        theme: Union[KerykeionChartTheme, None] = "classic",
442        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
443        chart_language: KerykeionChartLanguage = "EN",
444        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,  # type: ignore[assignment]
445        active_aspects: Optional[List[ActiveAspect]] = None,
446        *,
447        language_pack: Optional[Mapping[str, Any]] = None,
448
449    ) -> None:
450        _deprecated("KerykeionChartSVG", "ChartDataFactory + ChartDrawer")
451
452        if new_settings_file is not None:
453            warnings.warn(
454                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
455                DeprecationWarning,
456                stacklevel=2,
457            )
458
459        if isinstance(first_obj, AstrologicalSubject):
460            subject_model: Union[AstrologicalSubjectModel, CompositeSubjectModel] = first_obj.model()
461        else:
462            subject_model = first_obj
463
464        if isinstance(second_obj, AstrologicalSubject):
465            second_model: Optional[Union[AstrologicalSubjectModel, CompositeSubjectModel]] = second_obj.model()
466        else:
467            second_model = second_obj
468
469        if active_aspects is None:
470            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
471        else:
472            active_aspects = list(active_aspects)
473
474        self.chart_type = chart_type
475        self.language_pack = language_pack
476        self.theme = theme  # type: ignore[assignment]
477        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
478        self.chart_language = chart_language  # type: ignore[assignment]
479
480        self._subject_model = subject_model
481        self._second_model = second_model
482        self.user = subject_model
483        self.first_obj = subject_model
484        self.t_user = second_model
485        self.second_obj = second_model
486
487        self.active_points = list(active_points) if active_points is not None else list(DEFAULT_ACTIVE_POINTS)  # type: ignore[list-item]
488        self._active_points = _normalize_active_points(self.active_points)
489        self.active_aspects = active_aspects
490        self._active_aspects = active_aspects
491
492        self.output_directory = Path(new_output_directory) if new_output_directory else Path.home()
493        self._output_directory = self.output_directory
494
495        self.template = ""
496        self.aspects_list: list[dict[str, Any]] = []
497        self.available_planets_setting: list[dict[str, Any]] = []
498        self.t_available_kerykeion_celestial_points = None
499        self.available_kerykeion_celestial_points: list[dict[str, Any]] = []
500        self.chart_colors_settings: dict[str, Any] = {}
501        self.planets_settings: list[dict[str, Any]] = []
502        self.aspects_settings: list[dict[str, Any]] = []
503        self.language_settings: dict[str, Any] = {}
504        self.height = None
505        self.width = None
506        self.location = None
507        self.geolat = None
508        self.geolon = None
509
510        self._chart_drawer: Optional[ChartDrawer] = None
511        self._chart_data: Optional[Union[SingleChartDataModel, DualChartDataModel]] = None
512        self._external_view = False
513
514    def _ensure_chart(self) -> None:
515        if self._chart_drawer is not None:
516            return
517
518        if self._subject_model is None:
519            raise ValueError("First object is required to build charts.")
520
521        chart_type_normalized = str(self.chart_type).lower()
522        active_points = self._active_points
523        active_aspects = self._active_aspects
524        external_view = False
525
526        if chart_type_normalized in ("natal", "birth", "externalnatal", "external_natal"):
527            data = ChartDataFactory.create_natal_chart_data(
528                self._subject_model, active_points=active_points, active_aspects=active_aspects
529            )
530            if chart_type_normalized in ("externalnatal", "external_natal"):
531                external_view = True
532        elif chart_type_normalized == "synastry":
533            if self._second_model is None:
534                raise ValueError("Second object is required for Synastry charts.")
535            if not isinstance(self._subject_model, AstrologicalSubjectModel) or not isinstance(
536                self._second_model, AstrologicalSubjectModel
537            ):
538                raise ValueError("Synastry charts require two AstrologicalSubject instances.")
539            data = ChartDataFactory.create_synastry_chart_data(
540                cast(AstrologicalSubjectModel, self._subject_model),
541                cast(AstrologicalSubjectModel, self._second_model),
542                active_points=active_points,
543                active_aspects=active_aspects,
544            )
545        elif chart_type_normalized == "transit":
546            if self._second_model is None:
547                raise ValueError("Second object is required for Transit charts.")
548            if not isinstance(self._subject_model, AstrologicalSubjectModel) or not isinstance(
549                self._second_model, AstrologicalSubjectModel
550            ):
551                raise ValueError("Transit charts require natal and transit AstrologicalSubject instances.")
552            data = ChartDataFactory.create_transit_chart_data(
553                cast(AstrologicalSubjectModel, self._subject_model),
554                cast(AstrologicalSubjectModel, self._second_model),
555                active_points=active_points,
556                active_aspects=active_aspects,
557            )
558        elif chart_type_normalized == "composite":
559            if not isinstance(self._subject_model, CompositeSubjectModel):
560                raise ValueError("First object must be a CompositeSubjectModel instance for composite charts.")
561            data = ChartDataFactory.create_composite_chart_data(
562                self._subject_model, active_points=active_points, active_aspects=active_aspects
563            )
564        else:
565            raise ValueError(f"Unsupported or improperly configured chart_type '{self.chart_type}'")
566
567        self._external_view = external_view
568        self._chart_data = data
569        self.chart_data = data
570        self._chart_drawer = ChartDrawer(
571            chart_data=data,
572            theme=cast(Optional[KerykeionChartTheme], self.theme),
573            double_chart_aspect_grid_type=cast(Literal["list", "table"], self.double_chart_aspect_grid_type),
574            chart_language=cast(KerykeionChartLanguage, self.chart_language),
575            language_pack=self.language_pack,
576            external_view=external_view,
577        )
578
579        # Mirror commonly accessed attributes from legacy class
580        drawer = self._chart_drawer
581        self.available_planets_setting = getattr(drawer, "available_planets_setting", [])
582        self.available_kerykeion_celestial_points = getattr(drawer, "available_kerykeion_celestial_points", [])
583        self.aspects_list = getattr(drawer, "aspects_list", [])
584        if hasattr(drawer, "t_available_kerykeion_celestial_points"):
585            self.t_available_kerykeion_celestial_points = getattr(drawer, "t_available_kerykeion_celestial_points")
586        self.chart_colors_settings = getattr(drawer, "chart_colors_settings", {})
587        self.planets_settings = getattr(drawer, "planets_settings", [])
588        self.aspects_settings = getattr(drawer, "aspects_settings", [])
589        self.language_settings = getattr(drawer, "language_settings", {})
590        self.height = getattr(drawer, "height", self.height)
591        self.width = getattr(drawer, "width", self.width)
592        self.location = getattr(drawer, "location", self.location)
593        self.geolat = getattr(drawer, "geolat", self.geolat)
594        self.geolon = getattr(drawer, "geolon", self.geolon)
595        for attr in ["main_radius", "first_circle_radius", "second_circle_radius", "third_circle_radius"]:
596            if hasattr(drawer, attr):
597                setattr(self, attr, getattr(drawer, attr))
598
599    # Legacy method names --------------------------------------------------
600    def makeTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
601        self._ensure_chart()
602        assert self._chart_drawer is not None
603        template = self._chart_drawer.generate_svg_string(minify=minify, remove_css_variables=remove_css_variables)
604        self.template = template
605        return template
606
607    def makeSVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
608        self._ensure_chart()
609        assert self._chart_drawer is not None
610        self._chart_drawer.save_svg(
611            output_path=self.output_directory,
612            minify=minify,
613            remove_css_variables=remove_css_variables,
614        )
615        self.template = getattr(self._chart_drawer, "template", self.template)
616
617    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
618        self._ensure_chart()
619        assert self._chart_drawer is not None
620        template = self._chart_drawer.generate_wheel_only_svg_string(
621            minify=minify,
622            remove_css_variables=remove_css_variables,
623        )
624        self.template = template
625        return template
626
627    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
628        self._ensure_chart()
629        assert self._chart_drawer is not None
630        self._chart_drawer.save_wheel_only_svg_file(
631            output_path=self.output_directory,
632            minify=minify,
633            remove_css_variables=remove_css_variables,
634        )
635        self.template = getattr(self._chart_drawer, "template", self.template)
636
637    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
638        self._ensure_chart()
639        assert self._chart_drawer is not None
640        template = self._chart_drawer.generate_aspect_grid_only_svg_string(
641            minify=minify,
642            remove_css_variables=remove_css_variables,
643        )
644        self.template = template
645        return template
646
647    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
648        self._ensure_chart()
649        assert self._chart_drawer is not None
650        self._chart_drawer.save_aspect_grid_only_svg_file(
651            output_path=self.output_directory,
652            minify=minify,
653            remove_css_variables=remove_css_variables,
654        )
655        self.template = getattr(self._chart_drawer, "template", self.template)
656
657    # Aliases for new naming in README next (optional convenience)
658    save_svg = makeSVG
659    save_wheel_only_svg_file = makeWheelOnlySVG
660    save_aspect_grid_only_svg_file = makeAspectGridOnlySVG
661    makeGridOnlySVG = makeAspectGridOnlySVG

Wrapper emulating the v4 chart generation interface.

Old usage: chart = KerykeionChartSVG(subject, chart_type="ExternalNatal", second_subject) chart.makeSVG(minify_svg=True, remove_css_variables=True)

Replaced by ChartDataFactory + ChartDrawer.

KerykeionChartSVG( first_obj: Union[AstrologicalSubject, kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel], chart_type: Literal['Natal', 'Synastry', 'Transit', 'Composite', 'DualReturnChart', 'SingleReturnChart'] = 'Natal', second_obj: Union[AstrologicalSubject, kerykeion.schemas.kr_models.AstrologicalSubjectModel, NoneType] = None, new_output_directory: Optional[str] = None, new_settings_file: Union[pathlib.Path, NoneType, dict] = None, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry', 'black-and-white']] = 'classic', double_chart_aspect_grid_type: Literal['list', 'table'] = 'list', chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI'] = 'EN', active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_North_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, *, language_pack: Optional[Mapping[str, Any]] = None)
434    def __init__(
435        self,
436        first_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
437        chart_type: ChartType = "Natal",
438        second_obj: Union[AstrologicalSubject, AstrologicalSubjectModel, None] = None,
439        new_output_directory: Union[str, None] = None,
440        new_settings_file: Union[Path, None, dict] = None,  # retained for signature compatibility (unused)
441        theme: Union[KerykeionChartTheme, None] = "classic",
442        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
443        chart_language: KerykeionChartLanguage = "EN",
444        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,  # type: ignore[assignment]
445        active_aspects: Optional[List[ActiveAspect]] = None,
446        *,
447        language_pack: Optional[Mapping[str, Any]] = None,
448
449    ) -> None:
450        _deprecated("KerykeionChartSVG", "ChartDataFactory + ChartDrawer")
451
452        if new_settings_file is not None:
453            warnings.warn(
454                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
455                DeprecationWarning,
456                stacklevel=2,
457            )
458
459        if isinstance(first_obj, AstrologicalSubject):
460            subject_model: Union[AstrologicalSubjectModel, CompositeSubjectModel] = first_obj.model()
461        else:
462            subject_model = first_obj
463
464        if isinstance(second_obj, AstrologicalSubject):
465            second_model: Optional[Union[AstrologicalSubjectModel, CompositeSubjectModel]] = second_obj.model()
466        else:
467            second_model = second_obj
468
469        if active_aspects is None:
470            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
471        else:
472            active_aspects = list(active_aspects)
473
474        self.chart_type = chart_type
475        self.language_pack = language_pack
476        self.theme = theme  # type: ignore[assignment]
477        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
478        self.chart_language = chart_language  # type: ignore[assignment]
479
480        self._subject_model = subject_model
481        self._second_model = second_model
482        self.user = subject_model
483        self.first_obj = subject_model
484        self.t_user = second_model
485        self.second_obj = second_model
486
487        self.active_points = list(active_points) if active_points is not None else list(DEFAULT_ACTIVE_POINTS)  # type: ignore[list-item]
488        self._active_points = _normalize_active_points(self.active_points)
489        self.active_aspects = active_aspects
490        self._active_aspects = active_aspects
491
492        self.output_directory = Path(new_output_directory) if new_output_directory else Path.home()
493        self._output_directory = self.output_directory
494
495        self.template = ""
496        self.aspects_list: list[dict[str, Any]] = []
497        self.available_planets_setting: list[dict[str, Any]] = []
498        self.t_available_kerykeion_celestial_points = None
499        self.available_kerykeion_celestial_points: list[dict[str, Any]] = []
500        self.chart_colors_settings: dict[str, Any] = {}
501        self.planets_settings: list[dict[str, Any]] = []
502        self.aspects_settings: list[dict[str, Any]] = []
503        self.language_settings: dict[str, Any] = {}
504        self.height = None
505        self.width = None
506        self.location = None
507        self.geolat = None
508        self.geolon = None
509
510        self._chart_drawer: Optional[ChartDrawer] = None
511        self._chart_data: Optional[Union[SingleChartDataModel, DualChartDataModel]] = None
512        self._external_view = False
chart_type
language_pack
theme
double_chart_aspect_grid_type
chart_language
user
first_obj
t_user
second_obj
active_points
active_aspects
output_directory
template
aspects_list: list[dict[str, typing.Any]]
available_planets_setting: list[dict[str, typing.Any]]
t_available_kerykeion_celestial_points
available_kerykeion_celestial_points: list[dict[str, typing.Any]]
chart_colors_settings: dict[str, typing.Any]
planets_settings: list[dict[str, typing.Any]]
aspects_settings: list[dict[str, typing.Any]]
language_settings: dict[str, typing.Any]
height
width
location
geolat
geolon
def makeTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
600    def makeTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
601        self._ensure_chart()
602        assert self._chart_drawer is not None
603        template = self._chart_drawer.generate_svg_string(minify=minify, remove_css_variables=remove_css_variables)
604        self.template = template
605        return template
def makeSVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
607    def makeSVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
608        self._ensure_chart()
609        assert self._chart_drawer is not None
610        self._chart_drawer.save_svg(
611            output_path=self.output_directory,
612            minify=minify,
613            remove_css_variables=remove_css_variables,
614        )
615        self.template = getattr(self._chart_drawer, "template", self.template)
def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
617    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
618        self._ensure_chart()
619        assert self._chart_drawer is not None
620        template = self._chart_drawer.generate_wheel_only_svg_string(
621            minify=minify,
622            remove_css_variables=remove_css_variables,
623        )
624        self.template = template
625        return template
def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
627    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
628        self._ensure_chart()
629        assert self._chart_drawer is not None
630        self._chart_drawer.save_wheel_only_svg_file(
631            output_path=self.output_directory,
632            minify=minify,
633            remove_css_variables=remove_css_variables,
634        )
635        self.template = getattr(self._chart_drawer, "template", self.template)
def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
637    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables: bool = False) -> str:
638        self._ensure_chart()
639        assert self._chart_drawer is not None
640        template = self._chart_drawer.generate_aspect_grid_only_svg_string(
641            minify=minify,
642            remove_css_variables=remove_css_variables,
643        )
644        self.template = template
645        return template
def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
647    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
648        self._ensure_chart()
649        assert self._chart_drawer is not None
650        self._chart_drawer.save_aspect_grid_only_svg_file(
651            output_path=self.output_directory,
652            minify=minify,
653            remove_css_variables=remove_css_variables,
654        )
655        self.template = getattr(self._chart_drawer, "template", self.template)
def save_svg(self, minify: bool = False, remove_css_variables: bool = False) -> None:
607    def makeSVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
608        self._ensure_chart()
609        assert self._chart_drawer is not None
610        self._chart_drawer.save_svg(
611            output_path=self.output_directory,
612            minify=minify,
613            remove_css_variables=remove_css_variables,
614        )
615        self.template = getattr(self._chart_drawer, "template", self.template)
def save_wheel_only_svg_file(self, minify: bool = False, remove_css_variables: bool = False) -> None:
627    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
628        self._ensure_chart()
629        assert self._chart_drawer is not None
630        self._chart_drawer.save_wheel_only_svg_file(
631            output_path=self.output_directory,
632            minify=minify,
633            remove_css_variables=remove_css_variables,
634        )
635        self.template = getattr(self._chart_drawer, "template", self.template)
def save_aspect_grid_only_svg_file(self, minify: bool = False, remove_css_variables: bool = False) -> None:
647    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
648        self._ensure_chart()
649        assert self._chart_drawer is not None
650        self._chart_drawer.save_aspect_grid_only_svg_file(
651            output_path=self.output_directory,
652            minify=minify,
653            remove_css_variables=remove_css_variables,
654        )
655        self.template = getattr(self._chart_drawer, "template", self.template)
def makeGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
647    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables: bool = False) -> None:
648        self._ensure_chart()
649        assert self._chart_drawer is not None
650        self._chart_drawer.save_aspect_grid_only_svg_file(
651            output_path=self.output_directory,
652            minify=minify,
653            remove_css_variables=remove_css_variables,
654        )
655        self.template = getattr(self._chart_drawer, "template", self.template)
class NatalAspects:
666class NatalAspects:
667    """Wrapper replicating the master branch NatalAspects interface.
668
669    Replacement: AspectsFactory.single_subject_aspects(subject)
670    """
671
672    def __init__(
673        self,
674        user: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
675        new_settings_file: Union[Path, None, dict] = None,
676        active_points: Iterable[Union[str, AstrologicalPoint]] = DEFAULT_ACTIVE_POINTS,
677        active_aspects: Optional[List[ActiveAspect]] = None,
678        *,
679        language_pack: Optional[Mapping[str, Any]] = None,
680        axis_orb_limit: Optional[float] = None,
681    ) -> None:
682        _deprecated("NatalAspects", "AspectsFactory.single_chart_aspects")
683
684        if new_settings_file is not None:
685            warnings.warn(
686                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
687                DeprecationWarning,
688                stacklevel=2,
689            )
690
691        self.user = user.model() if isinstance(user, AstrologicalSubject) else user
692        self.new_settings_file = new_settings_file
693
694        self.language_pack = language_pack
695        self.celestial_points: list[Any] = []
696        self.aspects_settings: list[Any] = []
697        self.axes_orbit_settings = axis_orb_limit
698
699        self.active_points = list(active_points)
700        self._active_points = _normalize_active_points(self.active_points)
701        if active_aspects is None:
702            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
703        else:
704            active_aspects = list(active_aspects)
705        self.active_aspects = active_aspects
706
707        self._aspects_model = None
708        self._all_aspects_cache = None
709        self._relevant_aspects_cache = None
710
711    def _build_aspects_model(self):
712        if self._aspects_model is None:
713            self._aspects_model = AspectsFactory.single_chart_aspects(
714                self.user,
715                active_points=self._active_points,
716                active_aspects=self.active_aspects,
717                axis_orb_limit=self.axes_orbit_settings,
718            )
719        return self._aspects_model
720
721    @cached_property
722    def all_aspects(self):
723        """Legacy property - returns the same as aspects for backwards compatibility."""
724        if self._all_aspects_cache is None:
725            self._all_aspects_cache = list(self._build_aspects_model().aspects)
726        return self._all_aspects_cache
727
728    @cached_property
729    def relevant_aspects(self):
730        """Legacy property - returns the same as aspects for backwards compatibility."""
731        if self._relevant_aspects_cache is None:
732            self._relevant_aspects_cache = list(self._build_aspects_model().aspects)
733        return self._relevant_aspects_cache

Wrapper replicating the master branch NatalAspects interface.

Replacement: AspectsFactory.single_subject_aspects(subject)

NatalAspects( user: Union[AstrologicalSubject, kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel], new_settings_file: Union[pathlib.Path, NoneType, dict] = None, active_points: Iterable[Union[str, Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_North_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, *, language_pack: Optional[Mapping[str, Any]] = None, axis_orb_limit: Optional[float] = None)
672    def __init__(
673        self,
674        user: Union[AstrologicalSubject, AstrologicalSubjectModel, CompositeSubjectModel],
675        new_settings_file: Union[Path, None, dict] = None,
676        active_points: Iterable[Union[str, AstrologicalPoint]] = DEFAULT_ACTIVE_POINTS,
677        active_aspects: Optional[List[ActiveAspect]] = None,
678        *,
679        language_pack: Optional[Mapping[str, Any]] = None,
680        axis_orb_limit: Optional[float] = None,
681    ) -> None:
682        _deprecated("NatalAspects", "AspectsFactory.single_chart_aspects")
683
684        if new_settings_file is not None:
685            warnings.warn(
686                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
687                DeprecationWarning,
688                stacklevel=2,
689            )
690
691        self.user = user.model() if isinstance(user, AstrologicalSubject) else user
692        self.new_settings_file = new_settings_file
693
694        self.language_pack = language_pack
695        self.celestial_points: list[Any] = []
696        self.aspects_settings: list[Any] = []
697        self.axes_orbit_settings = axis_orb_limit
698
699        self.active_points = list(active_points)
700        self._active_points = _normalize_active_points(self.active_points)
701        if active_aspects is None:
702            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
703        else:
704            active_aspects = list(active_aspects)
705        self.active_aspects = active_aspects
706
707        self._aspects_model = None
708        self._all_aspects_cache = None
709        self._relevant_aspects_cache = None
user
new_settings_file
language_pack
celestial_points: list[typing.Any]
aspects_settings: list[typing.Any]
axes_orbit_settings
active_points
active_aspects
all_aspects
721    @cached_property
722    def all_aspects(self):
723        """Legacy property - returns the same as aspects for backwards compatibility."""
724        if self._all_aspects_cache is None:
725            self._all_aspects_cache = list(self._build_aspects_model().aspects)
726        return self._all_aspects_cache

Legacy property - returns the same as aspects for backwards compatibility.

relevant_aspects
728    @cached_property
729    def relevant_aspects(self):
730        """Legacy property - returns the same as aspects for backwards compatibility."""
731        if self._relevant_aspects_cache is None:
732            self._relevant_aspects_cache = list(self._build_aspects_model().aspects)
733        return self._relevant_aspects_cache

Legacy property - returns the same as aspects for backwards compatibility.

class SynastryAspects:
738class SynastryAspects:
739    """Wrapper replicating the master branch synastry aspects interface."""
740
741    def __init__(
742        self,
743        kr_object_one: Union[AstrologicalSubject, AstrologicalSubjectModel],
744        kr_object_two: Union[AstrologicalSubject, AstrologicalSubjectModel],
745        new_settings_file: Union[Path, None, dict] = None,
746        active_points: Iterable[Union[str, AstrologicalPoint]] = DEFAULT_ACTIVE_POINTS,
747        active_aspects: Optional[List[ActiveAspect]] = None,
748        *,
749        language_pack: Optional[Mapping[str, Any]] = None,
750        axis_orb_limit: Optional[float] = None,
751    ) -> None:
752        _deprecated("SynastryAspects", "AspectsFactory.dual_chart_aspects")
753
754        if new_settings_file is not None:
755            warnings.warn(
756                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
757                DeprecationWarning,
758                stacklevel=2,
759            )
760
761        self.first_user = kr_object_one.model() if isinstance(kr_object_one, AstrologicalSubject) else kr_object_one
762        self.second_user = kr_object_two.model() if isinstance(kr_object_two, AstrologicalSubject) else kr_object_two
763        self.new_settings_file = new_settings_file
764
765        self.language_pack = language_pack
766        self.celestial_points: list[Any] = []
767        self.aspects_settings: list[Any] = []
768        self.axes_orbit_settings = axis_orb_limit
769
770        self.active_points = list(active_points)
771        self._active_points = _normalize_active_points(self.active_points)
772        if active_aspects is None:
773            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
774        else:
775            active_aspects = list(active_aspects)
776        self.active_aspects = active_aspects
777
778        self._dual_model = None
779        self._all_aspects_cache = None
780        self._relevant_aspects_cache = None
781        self._all_aspects: Union[list, None] = None
782        self._relevant_aspects: Union[list, None] = None
783
784    def _build_dual_model(self):
785        if self._dual_model is None:
786            self._dual_model = AspectsFactory.dual_chart_aspects(
787                self.first_user,
788                self.second_user,
789                active_points=self._active_points,
790                active_aspects=self.active_aspects,
791                axis_orb_limit=self.axes_orbit_settings,
792                first_subject_is_fixed=True,
793                second_subject_is_fixed=True,
794            )
795        return self._dual_model
796
797    @cached_property
798    def all_aspects(self):
799        """Legacy property - returns the same as aspects for backwards compatibility."""
800        if self._all_aspects_cache is None:
801            self._all_aspects_cache = list(self._build_dual_model().aspects)
802        return self._all_aspects_cache
803
804    @cached_property
805    def relevant_aspects(self):
806        """Legacy property - returns the same as aspects for backwards compatibility."""
807        if self._relevant_aspects_cache is None:
808            self._relevant_aspects_cache = list(self._build_dual_model().aspects)
809        return self._relevant_aspects_cache
810
811    def get_relevant_aspects(self):
812        """Legacy method for compatibility with master branch."""
813        return self.relevant_aspects

Wrapper replicating the master branch synastry aspects interface.

SynastryAspects( kr_object_one: Union[AstrologicalSubject, kerykeion.schemas.kr_models.AstrologicalSubjectModel], kr_object_two: Union[AstrologicalSubject, kerykeion.schemas.kr_models.AstrologicalSubjectModel], new_settings_file: Union[pathlib.Path, NoneType, dict] = None, active_points: Iterable[Union[str, Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_North_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None, *, language_pack: Optional[Mapping[str, Any]] = None, axis_orb_limit: Optional[float] = None)
741    def __init__(
742        self,
743        kr_object_one: Union[AstrologicalSubject, AstrologicalSubjectModel],
744        kr_object_two: Union[AstrologicalSubject, AstrologicalSubjectModel],
745        new_settings_file: Union[Path, None, dict] = None,
746        active_points: Iterable[Union[str, AstrologicalPoint]] = DEFAULT_ACTIVE_POINTS,
747        active_aspects: Optional[List[ActiveAspect]] = None,
748        *,
749        language_pack: Optional[Mapping[str, Any]] = None,
750        axis_orb_limit: Optional[float] = None,
751    ) -> None:
752        _deprecated("SynastryAspects", "AspectsFactory.dual_chart_aspects")
753
754        if new_settings_file is not None:
755            warnings.warn(
756                "'new_settings_file' is deprecated and ignored in Kerykeion v5. Use language_pack instead.",
757                DeprecationWarning,
758                stacklevel=2,
759            )
760
761        self.first_user = kr_object_one.model() if isinstance(kr_object_one, AstrologicalSubject) else kr_object_one
762        self.second_user = kr_object_two.model() if isinstance(kr_object_two, AstrologicalSubject) else kr_object_two
763        self.new_settings_file = new_settings_file
764
765        self.language_pack = language_pack
766        self.celestial_points: list[Any] = []
767        self.aspects_settings: list[Any] = []
768        self.axes_orbit_settings = axis_orb_limit
769
770        self.active_points = list(active_points)
771        self._active_points = _normalize_active_points(self.active_points)
772        if active_aspects is None:
773            active_aspects = list(DEFAULT_ACTIVE_ASPECTS)
774        else:
775            active_aspects = list(active_aspects)
776        self.active_aspects = active_aspects
777
778        self._dual_model = None
779        self._all_aspects_cache = None
780        self._relevant_aspects_cache = None
781        self._all_aspects: Union[list, None] = None
782        self._relevant_aspects: Union[list, None] = None
first_user
second_user
new_settings_file
language_pack
celestial_points: list[typing.Any]
aspects_settings: list[typing.Any]
axes_orbit_settings
active_points
active_aspects
all_aspects
797    @cached_property
798    def all_aspects(self):
799        """Legacy property - returns the same as aspects for backwards compatibility."""
800        if self._all_aspects_cache is None:
801            self._all_aspects_cache = list(self._build_dual_model().aspects)
802        return self._all_aspects_cache

Legacy property - returns the same as aspects for backwards compatibility.

relevant_aspects
804    @cached_property
805    def relevant_aspects(self):
806        """Legacy property - returns the same as aspects for backwards compatibility."""
807        if self._relevant_aspects_cache is None:
808            self._relevant_aspects_cache = list(self._build_dual_model().aspects)
809        return self._relevant_aspects_cache

Legacy property - returns the same as aspects for backwards compatibility.

def get_relevant_aspects(self):
811    def get_relevant_aspects(self):
812        """Legacy method for compatibility with master branch."""
813        return self.relevant_aspects

Legacy method for compatibility with master branch.