kerykeion
This is part of Kerykeion (C) 2025 Giacomo Battaglia
Kerykeion
⭐ 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:
📘 For AI Agents & LLMs If you're building LLM-powered applications (or if you are an AI agent 🙂), see
AI_AGENT_GUIDE.mdfor 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:
It is open source and directly supports this project.
Donate
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:
Table of Contents
- Web API
- Donate
- Table of Contents
- Installation
- Quick Start
- Documentation Map
- Basic Usage
- Generate a SVG Chart
- Wheel Only Charts
- Report Generator
- AI Context Serializer
- Example: Retrieving Aspects
- Element \& Quality Distribution Strategies
- Ayanamsa (Sidereal Modes)
- House Systems
- Perspective Type
- Themes
- Alternative Initialization
- Lunar Nodes (Rahu \& Ketu)
- JSON Support
- Auto Generated Documentation
- Development
- Kerykeion v5.0 – What's New
- Integrating Kerykeion into Your Project
- License
- Contributing
- Citations
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:
- Create an
AstrologicalSubjectwith explicit coordinates and timezone (offline mode). - Build a
ChartDataModelthroughChartDataFactory. - 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. Runpython scripts/test_markdown_snippets.py site-docsto 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=Falseand specifylng,lat, andtz_stras shown above.
Working online: setonline=Trueand providecity,nation, and a valid GeoNames username (seeAstrologicalSubjectFactory.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.
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")
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")
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")
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")
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")
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")
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")
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")
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")
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")
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")
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")
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:
AstrologicalSubjectFactory: Replaces the oldAstrologicalSubjectclassChartDataFactory: Pre-computes enriched chart data (elements, qualities, aspects)ChartDrawer: Pure SVG rendering separated from calculationsAspectsFactory: Unified aspects calculation for natal and synastry chartsPlanetaryReturnFactory: Solar and Lunar returns computationHouseComparisonFactory: House overlay analysis for synastryRelationshipScoreFactory: Compatibility scoring between charts
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:
AstrologicalSubjectModel: Subject data with full validationChartDataModel: Enriched chart data with elements, qualities, aspectsAspectModellist: Raw aspect entries directly onChartDataModel.aspectsPlanetReturnModel: Planetary return dataElementDistributionModel: Element statistics (fire, earth, air, water)QualityDistributionModel: Quality statistics (cardinal, fixed, mutable)
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_limitto 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_methodorcustom_distribution_weightswhen 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.schemasandkerykeion.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)darkdark-high-contrastlightstrawberryblack-and-white
📚 Resources
- Full Release Notes: v5.0.0.md
- Migration Guide (v4 → v5): MIGRATION_V4_TO_V5.md
- Documentation: kerykeion.readthedocs.io
- API Reference: kerykeion.net/pydocs
- Examples: See the
examples/folder for runnable code - Support: GitHub Discussions -
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]
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)
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")
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")
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.
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.
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.
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
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
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
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}")
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
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
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
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
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
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
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
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
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
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
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
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")
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.
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.
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.
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
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.
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
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.
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
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)
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.
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:
- Calculating midpoint positions for all planets and house cusps
- Computing the composite lunar phase
- 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.
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.
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.")
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)
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
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()
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.
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.
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's points positioned in second subject's houses
Second subject's points positioned in first subject's houses
First subject's house cusps positioned in second subject's houses
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
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.
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).
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
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:
- Converts the ISO datetime to Julian Day format for astronomical calculations
- Uses Swiss Ephemeris functions (solcross_ut/mooncross_ut) to find the exact return moment when the planet reaches its natal degree and minute
- Creates a complete AstrologicalSubject instance for the calculated return time
- 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
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
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
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.
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
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
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
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.
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.
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()
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.
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.
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.
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
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
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:
- Iterates through each ephemeris data point chronologically
- Compares transiting planetary positions with natal chart positions
- Identifies aspects that fall within the configured orb tolerances
- Creates timestamped transit moment records
- 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
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)
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.
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()
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.
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.
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.
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.
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.
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).
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.
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.
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
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.
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
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
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)
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
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)
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
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)
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)
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)
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)
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)
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)
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
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.
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.
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.
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
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.
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.