kerykeion

This is part of Kerykeion (C) 2025 Giacomo Battaglia

Kerykeion

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

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

 

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

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

Kerykeion also integrates seamlessly with LLM and AI applications.

Here is an example of a birthchart:

John Lenon Chart

Web API

If you want to use Kerykeion in a web application, you can try the dedicated web API:

AstrologerAPI

It is open source and directly supports this project.

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

ko-fi

⚠️ Development Branch Notice

This branch (next) is not the stable version of Kerykeion. It is the development branch for the upcoming V5 release.

If you're looking for the latest stable version, please check out the master branch instead.

Table of Contents

Installation

Kerykeion requires Python 3.9 or higher.

pip3 install kerykeion

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, "Liverpool", "GB")

# 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'

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

To generate a chart, use the ChartDrawer class. You can create various types of charts, including birth, synastry, transit, and composite charts.

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 kerykeion import AstrologicalSubjectFactory, ChartDrawer

john = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(john)
birth_chart_svg.makeSVG()

The SVG file will be saved in the home directory. John Lennon Birth Chart

External Birth Chart

from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart, chart_type="ExternalNatal")
birth_chart_svg.makeSVG()

John Lennon External Birth Chart

Synastry Chart

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")

synastry_chart = ChartDrawer(first, "Synastry", second)
synastry_chart.makeSVG()

John Lennon and Paul McCartney Synastry

Transit Chart

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

transit = AstrologicalSubjectFactory.from_birth_data("Transit", 2025, 6, 8, 8, 45, "Atlanta", "US")
subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")

transit_chart = ChartDrawer(subject, "Transit", transit)
transit_chart.makeSVG()

John Lennon Transit Chart

Composite Chart

from kerykeion import CompositeSubjectFactory, AstrologicalSubjectFactory, ChartDrawer

angelina = AstrologicalSubjectFactory.from_birth_data("Angelina Jolie", 1975, 6, 4, 9, 9, "Los Angeles", "US", lng=-118.15, lat=34.03, tz_str="America/Los_Angeles")

brad = AstrologicalSubjectFactory.from_birth_data("Brad Pitt", 1963, 12, 18, 6, 31, "Shawnee", "US", lng=-96.56, lat=35.20, tz_str="America/Chicago")

factory = CompositeSubjectFactory(angelina, brad)
composite_model = factory.get_midpoint_composite_subject_model()

composite_chart = ChartDrawer(composite_model, "Composite")
composite_chart.makeSVG()

Angelina Jolie and Brad Pitt Composite Chart

Wheel Only Charts

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

Birth Chart

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeWheelOnlySVG()

John Lennon Birth Chart

Wheel Only Birth Chart (External)

from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart, chart_type="ExternalNatal")
birth_chart_svg.makeWheelOnlySVG(
    wheel_only=True,
    wheel_only_external=True
)

John Lennon Birth Chart

Synastry Chart

from kerykeion import AstrologicalSubjectFactory, ChartDrawer
first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
synastry_chart = ChartDrawer(
    first, "Synastry", second
)
synastry_chart.makeWheelOnlySVG()

John Lennon and Paul McCartney Synastry

Change the Output Directory

To save the SVG file in a custom location, specify new_output_directory:

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")

synastry_chart = ChartDrawer(
    first, "Synastry", second,
    new_output_directory="."
)
synastry_chart.makeSVG()

Change Language

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

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(
    birth_chart,
    chart_language="IT"  # Change to Italian
)
birth_chart_svg.makeSVG()

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 kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeSVG(
    minify=True
)

SVG without CSS Variables

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

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeSVG(
    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 makeAspectGridOnlySVG() method:

from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
aspect_grid_chart = ChartDrawer(birth_chart, "Synastry", second, theme="dark")
aspect_grid_chart.makeAspectGridOnlySVG()

John Lennon Birth Chart

ReportGenerator

from kerykeion import ReportGenerator, AstrologicalSubjectFactory

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",
)
report = ReportGenerator(john)
report.print_report()

ReportGenerator output:

+- Kerykeion report for John Lennon -+
+-----------+-------+---------------+-----------+----------+
| Date      | Time  | Location      | Longitude | Latitude |
+-----------+-------+---------------+-----------+----------+
| 9/10/1940 | 18:30 | Liverpool, GB | -2.9833   | 53.4     |
+-----------+-------+---------------+-----------+----------+
+-------------------+------+-------+------+----------------+
| AstrologicalPoint | Sign | Pos.  | Ret. | House          |
+-------------------+------+-------+------+----------------+
| Sun               | Lib  | 16.27 | -    | Sixth_House    |
| Moon              | Aqu  | 3.55  | -    | Eleventh_House |
| Mercury           | Sco  | 8.56  | -    | Seventh_House  |
| Venus             | Vir  | 3.22  | -    | Sixth_House    |
| Mars              | Lib  | 2.66  | -    | Sixth_House    |
| Jupiter           | Tau  | 13.69 | R    | First_House    |
| Saturn            | Tau  | 13.22 | R    | First_House    |
| Uranus            | Tau  | 25.55 | R    | First_House    |
| Neptune           | Vir  | 26.03 | -    | Sixth_House    |
| Pluto             | Leo  | 4.19  | -    | Fifth_House    |
| Mean_Node         | Lib  | 10.58 | R    | Sixth_House    |
| Mean_South_Node   | Ari  | 10.58 | R    | Twelfth_House  |
| Mean_Lilith       | Ari  | 13.37 | -    | Twelfth_House  |
| Chiron            | Leo  | 0.57  | -    | Fifth_House    |
+-------------------+------+-------+------+----------------+
+----------------+------+----------+
| House          | Sign | Position |
+----------------+------+----------+
| First_House    | Ari  | 19.72    |
| Second_House   | Tau  | 29.52    |
| Third_House    | Gem  | 20.23    |
| Fourth_House   | Can  | 7.07     |
| Fifth_House    | Can  | 25.31    |
| Sixth_House    | Leo  | 22.11    |
| Seventh_House  | Lib  | 19.72    |
| Eighth_House   | Sco  | 29.52    |
| Ninth_House    | Sag  | 20.23    |
| Tenth_House    | Cap  | 7.07     |
| Eleventh_House | Cap  | 25.31    |
| Twelfth_House  | Aqu  | 22.11    |
+----------------+------+----------+

To export to a file:

python3 your_script_name.py > file.txt

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, "Roma", "IT")
jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1991, 10, 25, 21, 0, "Roma", "IT")

# For single chart aspects (natal, return, composite, etc.)
single_chart_aspects = AspectsFactory.single_chart_aspects(jack)
print(f"Found {len(single_chart_aspects)} aspects in Jack's chart")
print(single_chart_aspects[0])
# Output: AspectModel with details like aspect type, orb, planets involved, etc.

# For dual chart aspects (synastry, transits, comparisons, etc.)
dual_chart_aspects = AspectsFactory.dual_chart_aspects(jack, jane)
print(f"Found {len(dual_chart_aspects)} aspects between Jack and Jane's charts")
print(dual_chart_aspects[0])
# Output: AspectModel with cross-chart aspect details

# The factory returns structured AspectModel objects with properties like:
# - 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

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,
    "Owensboro", "US",
    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,
    "Owensboro", "US",
    houses_system="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,
    "Owensboro", "US",
    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

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

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

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

dark_theme_subject = AstrologicalSubjectFactory.from_birth_data("John Lennon - Dark Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
dark_theme_natal_chart = ChartDrawer(dark_theme_subject, theme="dark_high_contrast")
dark_theme_natal_chart.makeSVG()

John Lennon

Alternative Initialization

Create an AstrologicalSubject from a UTC ISO 8601 string:

subject = AstrologicalSubject.get_from_iso_utc_time(
    "Johnny Depp", "1963-06-09T05:00:00Z", "Owensboro", "US"
)

If you set online=True, provide a geonames_username to allow city-based geolocation:

from kerykeion.astrological_subject import AstrologicalSubjectFactory

subject = AstrologicalSubject.get_from_iso_utc_time(
    "Johnny Depp", "1963-06-09T05:00:00Z", "Owensboro", "US", online=True
)

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 ChartDrawer class.

Example:

from kerykeion import AstrologicalSubjectFactory, ChartDrawer

subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")

chart = ChartDrawer(
    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"
    ]
)
chart.makeSVG()

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, "Owensboro", "US")

print(johnny.json(dump=False, 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.

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.

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

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

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

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

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

Example:

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

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

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

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

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

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

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.relevant_aspects)} relevant aspects")

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

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.

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.relevant_aspects)} relevant aspects")

@staticmethod
def natal_aspects( subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None) -> kerykeion.schemas.kr_models.SingleChartAspectsModel:
467    @staticmethod
468    def natal_aspects(
469        subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
470        *,
471        active_points: Optional[List[AstrologicalPoint]] = None,
472        active_aspects: Optional[List[ActiveAspect]] = None,
473    ) -> NatalAspectsModel:
474        """
475        Legacy method - use single_chart_aspects() instead.
476
477        ⚠️  DEPRECATION WARNING ⚠️
478        This method is deprecated. Use AspectsFactory.single_chart_aspects() instead.
479        """
480        return AspectsFactory.single_chart_aspects(subject, active_points=active_points, active_aspects=active_aspects)

Legacy method - use single_chart_aspects() instead.

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

@staticmethod
def synastry_aspects( first_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], *, active_points: Optional[List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: Optional[List[kerykeion.schemas.kr_models.ActiveAspect]] = None) -> kerykeion.schemas.kr_models.DualChartAspectsModel:
482    @staticmethod
483    def synastry_aspects(
484        first_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
485        second_subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
486        *,
487        active_points: Optional[List[AstrologicalPoint]] = None,
488        active_aspects: Optional[List[ActiveAspect]] = None,
489    ) -> SynastryAspectsModel:
490        """
491        Legacy method - use dual_chart_aspects() instead.
492
493        ⚠️  DEPRECATION WARNING ⚠️
494        This method is deprecated. Use AspectsFactory.dual_chart_aspects() instead.
495        """
496        return AspectsFactory.dual_chart_aspects(
497            first_subject, second_subject, active_points=active_points, active_aspects=active_aspects
498        )

Legacy method - use dual_chart_aspects() instead.

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

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

Factory class for creating comprehensive astrological subjects.

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

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

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

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

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

Example:

Create natal chart

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

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

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

@classmethod
def from_birth_data( cls, name: str = 'Now', year: int = 2025, month: int = 8, day: int = 19, hour: int = 14, minute: int = 39, city: Optional[str] = None, nation: Optional[str] = None, lng: Optional[float] = None, lat: Optional[float] = None, tz_str: Optional[str] = None, geonames_username: Optional[str] = None, online: bool = True, zodiac_type: Literal['Tropic', 'Sidereal'] = 'Tropic', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', cache_expire_after_days: int = 30, is_dst: Optional[bool] = None, altitude: Optional[float] = None, active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], calculate_lunar_phase: bool = True, *, seconds: int = 0) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
364    @classmethod
365    def from_birth_data(
366        cls,
367        name: str = "Now",
368        year: int = NOW.year,
369        month: int = NOW.month,
370        day: int = NOW.day,
371        hour: int = NOW.hour,
372        minute: int = NOW.minute,
373        city: Optional[str] = None,
374        nation: Optional[str] = None,
375        lng: Optional[float] = None,
376        lat: Optional[float] = None,
377        tz_str: Optional[str] = None,
378        geonames_username: Optional[str] = None,
379        online: bool = True,
380        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
381        sidereal_mode: Optional[SiderealMode] = None,
382        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
383        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
384        cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
385        is_dst: Optional[bool] = None,
386        altitude: Optional[float] = None,
387        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
388        calculate_lunar_phase: bool = True,
389        *,
390        seconds: int = 0,
391
392    ) -> AstrologicalSubjectModel:
393        """
394        Create an astrological subject from standard birth or event data.
395
396        This is the most flexible and commonly used factory method. It creates a complete
397        astrological subject with planetary positions, house cusps, and specialized points
398        for a specific date, time, and location. Supports both online location resolution
399        and offline calculation modes.
400
401        Args:
402            name (str, optional): Name or identifier for the subject. Defaults to "Now".
403            year (int, optional): Year of birth/event. Defaults to current year.
404            month (int, optional): Month of birth/event (1-12). Defaults to current month.
405            day (int, optional): Day of birth/event (1-31). Defaults to current day.
406            hour (int, optional): Hour of birth/event (0-23). Defaults to current hour.
407            minute (int, optional): Minute of birth/event (0-59). Defaults to current minute.
408            seconds (int, optional): Seconds of birth/event (0-59). Defaults to 0.
409            city (str, optional): City name for location lookup. Used with online=True.
410                Defaults to None (Greenwich if not specified).
411            nation (str, optional): ISO country code (e.g., 'US', 'GB', 'IT'). Used with
412                online=True. Defaults to None ('GB' if not specified).
413            lng (float, optional): Longitude in decimal degrees. East is positive, West
414                is negative. If not provided and online=True, fetched from GeoNames.
415            lat (float, optional): Latitude in decimal degrees. North is positive, South
416                is negative. If not provided and online=True, fetched from GeoNames.
417            tz_str (str, optional): IANA timezone identifier (e.g., 'Europe/London').
418                If not provided and online=True, fetched from GeoNames.
419            geonames_username (str, optional): Username for GeoNames API. Required for
420                online location lookup. Get one free at geonames.org.
421            online (bool, optional): Whether to fetch location data online. If False,
422                lng, lat, and tz_str must be provided. Defaults to True.
423            zodiac_type (ZodiacType, optional): Zodiac system - 'Tropic' or 'Sidereal'.
424                Defaults to 'Tropic'.
425            sidereal_mode (SiderealMode, optional): Sidereal calculation mode (e.g.,
426                'FAGAN_BRADLEY', 'LAHIRI'). Only used with zodiac_type='Sidereal'.
427            houses_system_identifier (HousesSystemIdentifier, optional): House system
428                for cusp calculations (e.g., 'P'=Placidus, 'K'=Koch, 'E'=Equal).
429                Defaults to 'P' (Placidus).
430            perspective_type (PerspectiveType, optional): Calculation perspective:
431                - 'Apparent Geocentric': Standard geocentric with light-time correction
432                - 'True Geocentric': Geometric geocentric positions
433                - 'Heliocentric': Sun-centered coordinates
434                - 'Topocentric': Earth surface perspective (requires altitude)
435                Defaults to 'Apparent Geocentric'.
436            cache_expire_after_days (int, optional): Days to cache GeoNames data locally.
437                Defaults to 30.
438            is_dst (bool, optional): Daylight Saving Time flag for ambiguous times.
439                If None, pytz attempts automatic detection. Set explicitly for
440                times during DST transitions.
441            altitude (float, optional): Altitude above sea level in meters. Used for
442                topocentric calculations and atmospheric corrections. Defaults to None
443                (sea level assumed).
444            active_points (List[AstrologicalPoint], optional): List of astrological
445                points to calculate. Omitting points can improve performance for
446                specialized applications. Defaults to DEFAULT_ACTIVE_POINTS.
447            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
448                Requires Sun and Moon in active_points. Defaults to True.
449
450        Returns:
451            AstrologicalSubjectModel: Complete astrological subject with calculated
452                positions, houses, and metadata. Access planetary positions via
453                attributes like .sun, .moon, .mercury, etc.
454
455        Raises:
456            KerykeionException:
457                - If offline mode is used without required location data
458                - If invalid zodiac/sidereal mode combinations are specified
459                - If GeoNames data is missing or invalid
460                - If timezone localization fails (ambiguous DST times)
461
462        Examples:
463            >>> # Basic natal chart with online location lookup
464            >>> chart = AstrologicalSubjectFactory.from_birth_data(
465            ...     name="Jane Doe",
466            ...     year=1985, month=3, day=21,
467            ...     hour=15, minute=30,
468            ...     city="Paris", nation="FR",
469            ...     geonames_username="your_username"
470            ... )
471
472            >>> # Offline calculation with manual coordinates
473            >>> chart = AstrologicalSubjectFactory.from_birth_data(
474            ...     name="John Smith",
475            ...     year=1990, month=12, day=25,
476            ...     hour=0, minute=0,
477            ...     lng=-74.006, lat=40.7128, tz_str="America/New_York",
478            ...     online=False
479            ... )
480
481            >>> # Sidereal chart with specific points
482            >>> chart = AstrologicalSubjectFactory.from_birth_data(
483            ...     name="Vedic Chart",
484            ...     year=2000, month=6, day=15, hour=12,
485            ...     city="Mumbai", nation="IN",
486            ...     zodiac_type="Sidereal",
487            ...     sidereal_mode="LAHIRI",
488            ...     active_points=["Sun", "Moon", "Mercury", "Venus", "Mars",
489            ...                   "Jupiter", "Saturn", "Ascendant"]
490            ... )
491
492        Note:
493            - For high-precision calculations, consider providing seconds parameter
494            - Use topocentric perspective for observer-specific calculations
495            - Some Arabic parts automatically activate required base points
496            - The method handles polar regions by adjusting extreme latitudes
497            - Time zones are handled with full DST awareness via pytz
498        """
499        # Create a calculation data container
500        calc_data = {}
501
502        # Basic identity
503        calc_data["name"] = name
504        calc_data["json_dir"] = str(Path.home())
505
506        # Create a deep copy of active points to avoid modifying the original list
507        active_points = list(active_points)
508
509        calc_data["active_points"] = active_points
510
511        # Initialize configuration
512        config = ChartConfiguration(
513            zodiac_type=zodiac_type,
514            sidereal_mode=sidereal_mode,
515            houses_system_identifier=houses_system_identifier,
516            perspective_type=perspective_type,
517        )
518        config.validate()
519
520        # Add configuration data to calculation data
521        calc_data["zodiac_type"] = config.zodiac_type
522        calc_data["sidereal_mode"] = config.sidereal_mode
523        calc_data["houses_system_identifier"] = config.houses_system_identifier
524        calc_data["perspective_type"] = config.perspective_type
525
526        # Set up geonames username if needed
527        if geonames_username is None and online and (not lat or not lng or not tz_str):
528            logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
529            geonames_username = DEFAULT_GEONAMES_USERNAME
530
531        # Initialize location data
532        location = LocationData(
533            city=city or "Greenwich",
534            nation=nation or "GB",
535            lat=lat if lat is not None else 51.5074,
536            lng=lng if lng is not None else 0.0,
537            tz_str=tz_str or "Etc/GMT",
538            altitude=altitude
539        )
540
541        # If offline mode is requested but required data is missing, raise error
542        if not online and (not tz_str or lat is None or lng is None):
543            raise KerykeionException(
544                "For offline mode, you must provide timezone (tz_str) and coordinates (lat, lng)"
545            )
546
547        # Fetch location data if needed
548        if online and (not tz_str or lat is None or lng is None):
549            location.fetch_from_geonames(
550                username=geonames_username or DEFAULT_GEONAMES_USERNAME,
551                cache_expire_after_days=cache_expire_after_days
552            )
553
554        # Prepare location for calculations
555        location.prepare_for_calculation()
556
557        # Add location data to calculation data
558        calc_data["city"] = location.city
559        calc_data["nation"] = location.nation
560        calc_data["lat"] = location.lat
561        calc_data["lng"] = location.lng
562        calc_data["tz_str"] = location.tz_str
563        calc_data["altitude"] = location.altitude
564
565        # Store calculation parameters
566        calc_data["year"] = year
567        calc_data["month"] = month
568        calc_data["day"] = day
569        calc_data["hour"] = hour
570        calc_data["minute"] = minute
571        calc_data["seconds"] = seconds
572        calc_data["is_dst"] = is_dst
573
574        # Calculate time conversions
575        cls._calculate_time_conversions(calc_data, location)
576
577        # Initialize Swiss Ephemeris and calculate houses and planets
578        cls._setup_ephemeris(calc_data, config)
579        cls._calculate_houses(calc_data, calc_data["active_points"])
580        cls._calculate_planets(calc_data, calc_data["active_points"])
581        cls._calculate_day_of_week(calc_data)
582
583        # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
584        if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
585            calc_data["lunar_phase"] = calculate_moon_phase(
586                calc_data["moon"].abs_pos,
587                calc_data["sun"].abs_pos
588            )
589
590        # Create and return the AstrologicalSubjectModel
591        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 - 'Tropic' or 'Sidereal'. Defaults to 'Tropic'. 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 (List[AstrologicalPoint], optional): List of astrological points to calculate. Omitting points can improve performance for specialized applications. Defaults to DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Requires Sun and Moon in active_points. Defaults to True.

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

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

Examples:

Basic natal chart with online location lookup

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

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

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

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

@classmethod
def from_iso_utc_time( cls, name: str, iso_utc_time: str, city: str = 'Greenwich', nation: str = 'GB', tz_str: str = 'Etc/GMT', online: bool = True, lng: float = 0.0, lat: float = 51.5074, geonames_username: str = 'century.boy', zodiac_type: Literal['Tropic', 'Sidereal'] = 'Tropic', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', altitude: Optional[float] = None, active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], calculate_lunar_phase: bool = True) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
593    @classmethod
594    def from_iso_utc_time(
595        cls,
596        name: str,
597        iso_utc_time: str,
598        city: str = "Greenwich",
599        nation: str = "GB",
600        tz_str: str = "Etc/GMT",
601        online: bool = True,
602        lng: float = 0.0,
603        lat: float = 51.5074,
604        geonames_username: str = DEFAULT_GEONAMES_USERNAME,
605        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
606        sidereal_mode: Optional[SiderealMode] = None,
607        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
608        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
609        altitude: Optional[float] = None,
610        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
611        calculate_lunar_phase: bool = True
612    ) -> AstrologicalSubjectModel:
613        """
614        Create an astrological subject from an ISO formatted UTC timestamp.
615
616        This method is ideal for creating astrological subjects from standardized
617        time formats, such as those stored in databases or received from APIs.
618        It automatically handles timezone conversion from UTC to the specified
619        local timezone.
620
621        Args:
622            name (str): Name or identifier for the subject.
623            iso_utc_time (str): ISO 8601 formatted UTC timestamp. Supported formats:
624                - "2023-06-15T14:30:00Z" (with Z suffix)
625                - "2023-06-15T14:30:00+00:00" (with UTC offset)
626                - "2023-06-15T14:30:00.123Z" (with milliseconds)
627            city (str, optional): City name for location. Defaults to "Greenwich".
628            nation (str, optional): ISO country code. Defaults to "GB".
629            tz_str (str, optional): IANA timezone identifier for result conversion.
630                The ISO time is assumed to be in UTC and will be converted to this
631                timezone. Defaults to "Etc/GMT".
632            online (bool, optional): Whether to fetch coordinates online. If True,
633                coordinates are fetched via GeoNames API. Defaults to True.
634            lng (float, optional): Longitude in decimal degrees. Used when online=False
635                or as fallback. Defaults to 0.0 (Greenwich).
636            lat (float, optional): Latitude in decimal degrees. Used when online=False
637                or as fallback. Defaults to 51.5074 (Greenwich).
638            geonames_username (str, optional): GeoNames API username. Required when
639                online=True. Defaults to DEFAULT_GEONAMES_USERNAME.
640            zodiac_type (ZodiacType, optional): Zodiac system. Defaults to 'Tropic'.
641            sidereal_mode (SiderealMode, optional): Sidereal mode when zodiac_type
642                is 'Sidereal'. Defaults to None.
643            houses_system_identifier (HousesSystemIdentifier, optional): House system.
644                Defaults to 'P' (Placidus).
645            perspective_type (PerspectiveType, optional): Calculation perspective.
646                Defaults to 'Apparent Geocentric'.
647            altitude (float, optional): Altitude in meters for topocentric calculations.
648                Defaults to None (sea level).
649            active_points (List[AstrologicalPoint], optional): Points to calculate.
650                Defaults to DEFAULT_ACTIVE_POINTS.
651            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
652                Defaults to True.
653
654        Returns:
655            AstrologicalSubjectModel: Astrological subject with positions calculated
656                for the specified UTC time converted to local timezone.
657
658        Raises:
659            ValueError: If the ISO timestamp format is invalid or cannot be parsed.
660            KerykeionException: If location data cannot be fetched or is invalid.
661
662        Examples:
663            >>> # From API timestamp with online location lookup
664            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
665            ...     name="Event Chart",
666            ...     iso_utc_time="2023-12-25T12:00:00Z",
667            ...     city="Tokyo", nation="JP",
668            ...     tz_str="Asia/Tokyo",
669            ...     geonames_username="your_username"
670            ... )
671
672            >>> # From database timestamp with manual coordinates
673            >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
674            ...     name="Historical Event",
675            ...     iso_utc_time="1969-07-20T20:17:00Z",
676            ...     lng=-95.0969, lat=37.4419,  # Houston
677            ...     tz_str="America/Chicago",
678            ...     online=False
679            ... )
680
681        Note:
682            - The method assumes the input timestamp is in UTC
683            - Local time conversion respects DST rules for the target timezone
684            - Milliseconds in the timestamp are supported but truncated to seconds
685            - When online=True, the city/nation parameters override lng/lat
686        """
687        # Parse the ISO time
688        dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
689
690        # Get location data if online mode is enabled
691        if online:
692            if geonames_username == DEFAULT_GEONAMES_USERNAME:
693                logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
694
695            geonames = FetchGeonames(
696                city,
697                nation,
698                username=geonames_username,
699            )
700
701            city_data = geonames.get_serialized_data()
702            lng = float(city_data["lng"])
703            lat = float(city_data["lat"])
704
705        # Convert UTC to local time
706        local_time = pytz.timezone(tz_str)
707        local_datetime = dt.astimezone(local_time)
708
709        # Create the subject with local time
710        return cls.from_birth_data(
711            name=name,
712            year=local_datetime.year,
713            month=local_datetime.month,
714            day=local_datetime.day,
715            hour=local_datetime.hour,
716            minute=local_datetime.minute,
717            seconds=local_datetime.second,
718            city=city,
719            nation=nation,
720            lng=lng,
721            lat=lat,
722            tz_str=tz_str,
723            online=False,  # Already fetched data if needed
724            geonames_username=geonames_username,
725            zodiac_type=zodiac_type,
726            sidereal_mode=sidereal_mode,
727            houses_system_identifier=houses_system_identifier,
728            perspective_type=perspective_type,
729            altitude=altitude,
730            active_points=active_points,
731            calculate_lunar_phase=calculate_lunar_phase
732        )

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 'Tropic'. 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 (List[AstrologicalPoint], optional): Points to calculate. Defaults to DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Defaults to True.

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

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

Examples:

From API timestamp with online location lookup

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

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

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

@classmethod
def from_current_time( cls, name: str = 'Now', city: Optional[str] = None, nation: Optional[str] = None, lng: Optional[float] = None, lat: Optional[float] = None, tz_str: Optional[str] = None, geonames_username: Optional[str] = None, online: bool = True, zodiac_type: Literal['Tropic', 'Sidereal'] = 'Tropic', sidereal_mode: Optional[Literal['FAGAN_BRADLEY', 'LAHIRI', 'DELUCE', 'RAMAN', 'USHASHASHI', 'KRISHNAMURTI', 'DJWHAL_KHUL', 'YUKTESHWAR', 'JN_BHASIN', 'BABYL_KUGLER1', 'BABYL_KUGLER2', 'BABYL_KUGLER3', 'BABYL_HUBER', 'BABYL_ETPSC', 'ALDEBARAN_15TAU', 'HIPPARCHOS', 'SASSANIAN', 'J2000', 'J1900', 'B1950']] = None, houses_system_identifier: Literal['A', 'B', 'C', 'D', 'F', 'H', 'I', 'i', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y'] = 'P', perspective_type: Literal['Apparent Geocentric', 'Heliocentric', 'Topocentric', 'True Geocentric'] = 'Apparent Geocentric', active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'True_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli'], calculate_lunar_phase: bool = True) -> kerykeion.schemas.kr_models.AstrologicalSubjectModel:
734    @classmethod
735    def from_current_time(
736        cls,
737        name: str = "Now",
738        city: Optional[str] = None,
739        nation: Optional[str] = None,
740        lng: Optional[float] = None,
741        lat: Optional[float] = None,
742        tz_str: Optional[str] = None,
743        geonames_username: Optional[str] = None,
744        online: bool = True,
745        zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
746        sidereal_mode: Optional[SiderealMode] = None,
747        houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
748        perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
749        active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
750        calculate_lunar_phase: bool = True
751    ) -> AstrologicalSubjectModel:
752        """
753        Create an astrological subject for the current moment in time.
754
755        This convenience method creates a "now" chart, capturing the current
756        astrological conditions at the moment of execution. Useful for horary
757        astrology, electional astrology, or real-time astrological monitoring.
758
759        Args:
760            name (str, optional): Name for the current moment chart.
761                Defaults to "Now".
762            city (str, optional): City name for location lookup. If not provided
763                and online=True, defaults to Greenwich.
764            nation (str, optional): ISO country code. If not provided and
765                online=True, defaults to 'GB'.
766            lng (float, optional): Longitude in decimal degrees. If not provided
767                and online=True, fetched from GeoNames API.
768            lat (float, optional): Latitude in decimal degrees. If not provided
769                and online=True, fetched from GeoNames API.
770            tz_str (str, optional): IANA timezone identifier. If not provided
771                and online=True, fetched from GeoNames API.
772            geonames_username (str, optional): GeoNames API username for location
773                lookup. Required when online=True and location is not fully specified.
774            online (bool, optional): Whether to fetch location data online.
775                Defaults to True.
776            zodiac_type (ZodiacType, optional): Zodiac system to use.
777                Defaults to 'Tropic'.
778            sidereal_mode (SiderealMode, optional): Sidereal calculation mode.
779                Only used when zodiac_type is 'Sidereal'. Defaults to None.
780            houses_system_identifier (HousesSystemIdentifier, optional): House
781                system for calculations. Defaults to 'P' (Placidus).
782            perspective_type (PerspectiveType, optional): Calculation perspective.
783                Defaults to 'Apparent Geocentric'.
784            active_points (List[AstrologicalPoint], optional): Astrological points
785                to calculate. Defaults to DEFAULT_ACTIVE_POINTS.
786            calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
787                Defaults to True.
788
789        Returns:
790            AstrologicalSubjectModel: Astrological subject representing current
791                astrological conditions at the specified or default location.
792
793        Raises:
794            KerykeionException: If online location lookup fails or if offline mode
795                is used without sufficient location data.
796
797        Examples:
798            >>> # Current moment for your location
799            >>> now_chart = AstrologicalSubjectFactory.from_current_time(
800            ...     name="Current Transits",
801            ...     city="New York", nation="US",
802            ...     geonames_username="your_username"
803            ... )
804
805            >>> # Horary chart with specific coordinates
806            >>> horary = AstrologicalSubjectFactory.from_current_time(
807            ...     name="Horary Question",
808            ...     lng=-0.1278, lat=51.5074,  # London
809            ...     tz_str="Europe/London",
810            ...     online=False
811            ... )
812
813            >>> # Current sidereal positions
814            >>> sidereal_now = AstrologicalSubjectFactory.from_current_time(
815            ...     name="Sidereal Now",
816            ...     city="Mumbai", nation="IN",
817            ...     zodiac_type="Sidereal",
818            ...     sidereal_mode="LAHIRI"
819            ... )
820
821        Note:
822            - The exact time is captured at method execution, including seconds
823            - For horary astrology, consider the moment of understanding the question
824            - System clock accuracy affects precision; ensure accurate system time
825            - Time zone detection is automatic when using online location lookup
826        """
827        now = datetime.now()
828
829        return cls.from_birth_data(
830            name=name,
831            year=now.year,
832            month=now.month,
833            day=now.day,
834            hour=now.hour,
835            minute=now.minute,
836            seconds=now.second,
837            city=city,
838            nation=nation,
839            lng=lng,
840            lat=lat,
841            tz_str=tz_str,
842            geonames_username=geonames_username,
843            online=online,
844            zodiac_type=zodiac_type,
845            sidereal_mode=sidereal_mode,
846            houses_system_identifier=houses_system_identifier,
847            perspective_type=perspective_type,
848            active_points=active_points,
849            calculate_lunar_phase=calculate_lunar_phase
850        )

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 'Tropic'. 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 (List[AstrologicalPoint], optional): Astrological points to calculate. Defaults to DEFAULT_ACTIVE_POINTS. calculate_lunar_phase (bool, optional): Whether to calculate lunar phase. Defaults to True.

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

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

Examples:

Current moment for your location

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

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

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

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

class ChartDrawer:
  80class ChartDrawer:
  81    """
  82    ChartDrawer generates astrological chart visualizations as SVG files.
  83
  84    This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs
  85    for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite.
  86    Charts are rendered using XML templates and drawing utilities, with customizable themes,
  87    language, active points, and aspects.
  88    The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.
  89
  90    NOTE:
  91        The generated SVG files are optimized for web use, opening in browsers. If you want to
  92        use them in other applications, you might need to adjust the SVG settings or styles.
  93
  94    Args:
  95        first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel):
  96            The primary astrological subject for the chart.
  97        chart_type (ChartType, optional):
  98            The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite').
  99            Defaults to 'Natal'.
 100        second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional):
 101            The secondary subject for Transit or Synastry charts. Not required for Natal or Composite.
 102        new_output_directory (str | Path, optional):
 103            Directory to write generated SVG files. Defaults to the user's home directory.
 104        new_settings_file (Path | dict | KerykeionSettingsModel, optional):
 105            Path or settings object to override default chart configuration (colors, fonts, aspects).
 106        theme (KerykeionChartTheme, optional):
 107            CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'.
 108        double_chart_aspect_grid_type (Literal['list', 'table'], optional):
 109            Specifies rendering style for double-chart aspect grids. Defaults to 'list'.
 110        chart_language (KerykeionChartLanguage, optional):
 111            Language code for chart labels. Defaults to 'EN'.
 112        active_points (list[AstrologicalPoint], optional):
 113            List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS.
 114            Example:
 115            ["Sun", "Moon", "Mercury", "Venus"]
 116
 117        active_aspects (list[ActiveAspect], optional):
 118            List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
 119            Example:
 120            [
 121                {"name": "conjunction", "orb": 10},
 122                {"name": "opposition", "orb": 10},
 123                {"name": "trine", "orb": 8},
 124                {"name": "sextile", "orb": 6},
 125                {"name": "square", "orb": 5},
 126                {"name": "quintile", "orb": 1},
 127            ]
 128
 129    Public Methods:
 130        makeTemplate(minify=False, remove_css_variables=False) -> str:
 131            Render the full chart SVG as a string without writing to disk. Use `minify=True`
 132            to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars.
 133
 134        makeSVG(minify=False, remove_css_variables=False) -> None:
 135            Generate and write the full chart SVG file to the output directory.
 136            Filenames follow the pattern:
 137            '{subject.name} - {chart_type} Chart.svg'.
 138
 139        makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 140            Render only the chart wheel (no aspect grid) as an SVG string.
 141
 142        makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
 143            Generate and write the wheel-only SVG file:
 144            '{subject.name} - {chart_type} Chart - Wheel Only.svg'.
 145
 146        makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
 147            Render only the aspect grid as an SVG string.
 148
 149        makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
 150            Generate and write the aspect-grid-only SVG file:
 151            '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
 152    """
 153
 154    # Constants
 155
 156    _DEFAULT_HEIGHT = 550
 157    _DEFAULT_FULL_WIDTH = 1200
 158    _DEFAULT_NATAL_WIDTH = 870
 159    _DEFAULT_FULL_WIDTH_WITH_TABLE = 1200
 160    _DEFAULT_ULTRA_WIDE_WIDTH = 1270
 161
 162    _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}"
 163    _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0"
 164    _ULTRA_WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_ULTRA_WIDE_WIDTH} 546.0"
 165    _TRANSIT_CHART_WITH_TABLE_VIWBOX = f"0 0 {_DEFAULT_FULL_WIDTH_WITH_TABLE} 546.0"
 166
 167    # Set at init
 168    first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
 169    second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None]
 170    chart_type: ChartType
 171    new_output_directory: Union[Path, None]
 172    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
 173    output_directory: Path
 174    new_settings_file: Union[Path, None, KerykeionSettingsModel, dict]
 175    theme: Union[KerykeionChartTheme, None]
 176    double_chart_aspect_grid_type: Literal["list", "table"]
 177    chart_language: KerykeionChartLanguage
 178    active_points: List[AstrologicalPoint]
 179    active_aspects: List[ActiveAspect]
 180    transparent_background: bool
 181
 182    # Internal properties
 183    fire: float
 184    earth: float
 185    air: float
 186    water: float
 187    first_circle_radius: float
 188    second_circle_radius: float
 189    third_circle_radius: float
 190    width: Union[float, int]
 191    language_settings: dict
 192    chart_colors_settings: dict
 193    planets_settings: dict
 194    aspects_settings: dict
 195    available_planets_setting: List[KerykeionSettingsCelestialPointModel]
 196    height: float
 197    location: str
 198    geolat: float
 199    geolon: float
 200    template: str
 201
 202    def __init__(
 203        self,
 204        first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
 205        chart_type: ChartType = "Natal",
 206        second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] = None,
 207        new_output_directory: Union[str, None] = None,
 208        new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
 209        theme: Union[KerykeionChartTheme, None] = "classic",
 210        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
 211        chart_language: KerykeionChartLanguage = "EN",
 212        active_points: Optional[list[AstrologicalPoint]] = None,
 213        active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS,
 214        *,
 215        transparent_background: bool = False,
 216        colors_settings: dict = DEFAULT_CHART_COLORS,
 217        celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
 218        aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
 219    ):
 220        """
 221        Initialize the chart generator with subject data and configuration options.
 222
 223        Args:
 224            first_obj (AstrologicalSubjectModel, or CompositeSubjectModel):
 225                Primary astrological subject instance.
 226            chart_type (ChartType, optional):
 227                Type of chart to generate (e.g., 'Natal', 'Transit').
 228            second_obj (AstrologicalSubject, optional):
 229                Secondary subject for Transit or Synastry charts.
 230            new_output_directory (str or Path, optional):
 231                Base directory to save generated SVG files.
 232            new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
 233                Custom settings source for chart colors, fonts, and aspects.
 234            theme (KerykeionChartTheme or None, optional):
 235                CSS theme to apply; None for default styling.
 236            double_chart_aspect_grid_type (Literal['list','table'], optional):
 237                Layout style for double-chart aspect grids ('list' or 'table').
 238            chart_language (KerykeionChartLanguage, optional):
 239                Language code for chart labels (e.g., 'EN', 'IT').
 240            active_points (List[AstrologicalPoint], optional):
 241                Celestial points to include in the chart visualization.
 242            active_aspects (List[ActiveAspect], optional):
 243                Aspects to calculate, each defined by name and orb.
 244            transparent_background (bool, optional):
 245                Whether to use a transparent background instead of the theme color. Defaults to False.
 246        """
 247        # --------------------
 248        # COMMON INITIALIZATION
 249        # --------------------
 250        home_directory = Path.home()
 251        self.new_settings_file = new_settings_file
 252        self.chart_language = chart_language
 253        self.active_aspects = active_aspects
 254        self.chart_type = chart_type
 255        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
 256        self.transparent_background = transparent_background
 257        self.chart_colors_settings = colors_settings
 258        self.planets_settings = celestial_points_settings
 259        self.aspects_settings = aspects_settings
 260
 261        if not active_points:
 262            self.active_points = first_obj.active_points
 263        else:
 264            self.active_points = find_common_active_points(
 265                active_points,
 266                first_obj.active_points
 267            )
 268
 269        if second_obj:
 270            self.active_points = find_common_active_points(
 271                self.active_points,
 272                second_obj.active_points
 273            )
 274
 275        # Set output directory
 276        if new_output_directory:
 277            self.output_directory = Path(new_output_directory)
 278        else:
 279            self.output_directory = home_directory
 280
 281        # Load settings
 282        self.parse_json_settings(new_settings_file)
 283
 284        # Primary subject
 285        self.first_obj = first_obj
 286
 287        # Default radius for all charts
 288        self.main_radius = 240
 289
 290        # Configure available planets
 291        self.available_planets_setting = []
 292        for body in self.planets_settings:
 293            if body["name"] in self.active_points:
 294                body["is_active"] = True
 295                self.available_planets_setting.append(body)
 296
 297        # Set available celestial points
 298        available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
 299        self.available_kerykeion_celestial_points = []
 300        for body in available_celestial_points_names:
 301            self.available_kerykeion_celestial_points.append(self.first_obj.get(body))
 302
 303        # ------------------------
 304        # CHART TYPE SPECIFIC SETUP
 305        # ------------------------
 306
 307        if self.chart_type in ["Natal", "ExternalNatal"]:
 308            # --- NATAL / EXTERNAL NATAL CHART SETUP ---
 309
 310            # Validate Subject
 311            if not isinstance(self.first_obj, AstrologicalSubjectModel):
 312                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 313
 314            # Calculate aspects
 315            aspects_instance = AspectsFactory.single_chart_aspects(
 316                self.first_obj,
 317                active_points=self.active_points,
 318                active_aspects=active_aspects,
 319            )
 320            self.aspects_list = aspects_instance.relevant_aspects
 321
 322            # Screen size
 323            self.height = self._DEFAULT_HEIGHT
 324            self.width = self._DEFAULT_NATAL_WIDTH
 325
 326            # Location and coordinates
 327            self.location = self.first_obj.city
 328            self.geolat = self.first_obj.lat
 329            self.geolon = self.first_obj.lng
 330
 331            # Circle radii
 332            if self.chart_type == "ExternalNatal":
 333                self.first_circle_radius = 56
 334                self.second_circle_radius = 92
 335                self.third_circle_radius = 112
 336            else:
 337                self.first_circle_radius = 0
 338                self.second_circle_radius = 36
 339                self.third_circle_radius = 120
 340
 341        elif self.chart_type == "Composite":
 342            # --- COMPOSITE CHART SETUP ---
 343
 344            # Validate Subject
 345            if not isinstance(self.first_obj, CompositeSubjectModel):
 346                raise KerykeionException("First object must be a CompositeSubjectModel instance.")
 347
 348            # Calculate aspects
 349            self.aspects_list = AspectsFactory.single_chart_aspects(self.first_obj, active_points=self.active_points).relevant_aspects
 350
 351            # Screen size
 352            self.height = self._DEFAULT_HEIGHT
 353            self.width = self._DEFAULT_NATAL_WIDTH
 354
 355            # Location and coordinates (average of both subjects)
 356            self.location = ""
 357            self.geolat = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
 358            self.geolon = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
 359
 360            # Circle radii
 361            self.first_circle_radius = 0
 362            self.second_circle_radius = 36
 363            self.third_circle_radius = 120
 364
 365        elif self.chart_type == "Transit":
 366            # --- TRANSIT CHART SETUP ---
 367
 368            # Validate Subjects
 369            if not second_obj:
 370                raise KerykeionException("Second object is required for Transit charts.")
 371            if not isinstance(self.first_obj, AstrologicalSubjectModel):
 372                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 373            if not isinstance(second_obj, AstrologicalSubjectModel):
 374                raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 375
 376            # Secondary subject setup
 377            self.second_obj = second_obj
 378
 379            # Calculate aspects (transit to natal)
 380            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
 381                self.first_obj,
 382                self.second_obj,
 383                active_points=self.active_points,
 384                active_aspects=active_aspects,
 385            )
 386            self.aspects_list = synastry_aspects_instance.relevant_aspects
 387
 388            # Secondary subject available points
 389            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
 390
 391            # Screen size
 392            self.height = self._DEFAULT_HEIGHT
 393            if self.double_chart_aspect_grid_type == "table":
 394                self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
 395            else:
 396                self.width = self._DEFAULT_FULL_WIDTH
 397
 398            # Location and coordinates (from transit subject)
 399            self.location = self.second_obj.city
 400            self.geolat = self.second_obj.lat
 401            self.geolon = self.second_obj.lng
 402            self.t_name = self.language_settings["transit_name"]
 403
 404            # Circle radii
 405            self.first_circle_radius = 0
 406            self.second_circle_radius = 36
 407            self.third_circle_radius = 120
 408
 409        elif self.chart_type == "Synastry":
 410            # --- SYNASTRY CHART SETUP ---
 411
 412            # Validate Subjects
 413            if not second_obj:
 414                raise KerykeionException("Second object is required for Synastry charts.")
 415            if not isinstance(self.first_obj, AstrologicalSubjectModel):
 416                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 417            if not isinstance(second_obj, AstrologicalSubjectModel):
 418                raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 419
 420            # Secondary subject setup
 421            self.second_obj = second_obj
 422
 423            # Calculate aspects (natal to partner)
 424            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
 425                self.first_obj,
 426                self.second_obj,
 427                active_points=self.active_points,
 428                active_aspects=active_aspects,
 429            )
 430            self.aspects_list = synastry_aspects_instance.relevant_aspects
 431
 432            # Secondary subject available points
 433            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
 434
 435            # Screen size
 436            self.height = self._DEFAULT_HEIGHT
 437            self.width = self._DEFAULT_FULL_WIDTH
 438
 439            # Location and coordinates (from primary subject)
 440            self.location = self.first_obj.city
 441            self.geolat = self.first_obj.lat
 442            self.geolon = self.first_obj.lng
 443
 444            # Circle radii
 445            self.first_circle_radius = 0
 446            self.second_circle_radius = 36
 447            self.third_circle_radius = 120
 448
 449        elif self.chart_type == "Return":
 450            # --- RETURN CHART SETUP ---
 451
 452            # Validate Subjects
 453            if not second_obj:
 454                raise KerykeionException("Second object is required for Return charts.")
 455            if not isinstance(self.first_obj, AstrologicalSubjectModel):
 456                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 457            if not isinstance(second_obj, PlanetReturnModel):
 458                raise KerykeionException("Second object must be a PlanetReturnModel instance.")
 459
 460            # Secondary subject setup
 461            self.second_obj = second_obj
 462
 463            # Calculate aspects (natal to return)
 464            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
 465                self.first_obj,
 466                self.second_obj,
 467                active_points=self.active_points,
 468                active_aspects=active_aspects,
 469            )
 470            self.aspects_list = synastry_aspects_instance.relevant_aspects
 471
 472            # Secondary subject available points
 473            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
 474
 475            # Screen size
 476            self.height = self._DEFAULT_HEIGHT
 477            self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
 478
 479            # Location and coordinates (from natal subject)
 480            self.location = self.first_obj.city
 481            self.geolat = self.first_obj.lat
 482            self.geolon = self.first_obj.lng
 483
 484            # Circle radii
 485            self.first_circle_radius = 0
 486            self.second_circle_radius = 36
 487            self.third_circle_radius = 120
 488
 489        elif self.chart_type == "SingleWheelReturn":
 490            # --- NATAL / EXTERNAL NATAL CHART SETUP ---
 491
 492            # Validate Subject
 493            if not isinstance(self.first_obj, PlanetReturnModel):
 494                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
 495
 496            # Calculate aspects
 497            aspects_instance = AspectsFactory.single_chart_aspects(
 498                self.first_obj,
 499                active_points=self.active_points,
 500                active_aspects=active_aspects,
 501            )
 502            self.aspects_list = aspects_instance.relevant_aspects
 503
 504            # Screen size
 505            self.height = self._DEFAULT_HEIGHT
 506            self.width = self._DEFAULT_NATAL_WIDTH
 507
 508            # Location and coordinates
 509            self.location = self.first_obj.city
 510            self.geolat = self.first_obj.lat
 511            self.geolon = self.first_obj.lng
 512
 513            # Circle radii
 514            if self.chart_type == "ExternalNatal":
 515                self.first_circle_radius = 56
 516                self.second_circle_radius = 92
 517                self.third_circle_radius = 112
 518            else:
 519                self.first_circle_radius = 0
 520                self.second_circle_radius = 36
 521                self.third_circle_radius = 120
 522
 523        # --------------------
 524        # FINAL COMMON SETUP
 525        # --------------------
 526
 527        # Calculate element points
 528        celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
 529        if self.chart_type == "Synastry":
 530            element_totals = calculate_synastry_element_points(
 531                self.available_planets_setting,
 532                celestial_points_names,
 533                self.first_obj,
 534                self.second_obj,
 535            )
 536        else:
 537            element_totals = calculate_element_points(
 538                self.available_planets_setting,
 539                celestial_points_names,
 540                self.first_obj,
 541            )
 542
 543        self.fire = element_totals["fire"]
 544        self.earth = element_totals["earth"]
 545        self.air = element_totals["air"]
 546        self.water = element_totals["water"]
 547
 548        # Calculate qualities points
 549        if self.chart_type == "Synastry":
 550            qualities_totals = calculate_synastry_quality_points(
 551                self.available_planets_setting,
 552                celestial_points_names,
 553                self.first_obj,
 554                self.second_obj,
 555            )
 556        else:
 557            qualities_totals = calculate_quality_points(
 558                self.available_planets_setting,
 559                celestial_points_names,
 560                self.first_obj,
 561            )
 562
 563        self.cardinal = qualities_totals["cardinal"]
 564        self.fixed = qualities_totals["fixed"]
 565        self.mutable = qualities_totals["mutable"]
 566
 567        # Set up theme
 568        if theme not in get_args(KerykeionChartTheme) and theme is not None:
 569            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
 570
 571        self.set_up_theme(theme)
 572
 573    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
 574        """
 575        Load and apply a CSS theme for the chart visualization.
 576
 577        Args:
 578            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
 579        """
 580        if theme is None:
 581            self.color_style_tag = ""
 582            return
 583
 584        theme_dir = Path(__file__).parent / "themes"
 585
 586        with open(theme_dir / f"{theme}.css", "r") as f:
 587            self.color_style_tag = f.read()
 588
 589    def set_output_directory(self, dir_path: Path) -> None:
 590        """
 591        Set the directory where generated SVG files will be saved.
 592
 593        Args:
 594            dir_path (Path): Target directory for SVG output.
 595        """
 596        self.output_directory = dir_path
 597        logging.info(f"Output directory set to: {self.output_directory}")
 598
 599    def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
 600        """
 601        Load and parse chart configuration settings.
 602
 603        Args:
 604            settings_file_or_dict (Path, dict, or KerykeionSettingsModel):
 605                Source for custom chart settings.
 606        """
 607        settings = get_settings(settings_file_or_dict)
 608
 609        self.language_settings = settings["language_settings"][self.chart_language]
 610
 611    def _draw_zodiac_circle_slices(self, r):
 612        """
 613        Draw zodiac circle slices for each sign.
 614
 615        Args:
 616            r (float): Outer radius of the zodiac ring.
 617
 618        Returns:
 619            str: Concatenated SVG elements for zodiac slices.
 620        """
 621        sings = get_args(Sign)
 622        output = ""
 623        for i, sing in enumerate(sings):
 624            output += draw_zodiac_slice(
 625                c1=self.first_circle_radius,
 626                chart_type=self.chart_type,
 627                seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
 628                num=i,
 629                r=r,
 630                style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;',
 631                type=sing,
 632            )
 633
 634        return output
 635
 636    def _draw_all_aspects_lines(self, r, ar):
 637        """
 638        Render SVG lines for all aspects in the chart.
 639
 640        Args:
 641            r (float): Radius at which aspect lines originate.
 642            ar (float): Radius at which aspect lines terminate.
 643
 644        Returns:
 645            str: SVG markup for all aspect lines.
 646        """
 647        out = ""
 648        for aspect in self.aspects_list:
 649            aspect_name = aspect["aspect"]
 650            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
 651            if aspect_color:
 652                out += draw_aspect_line(
 653                    r=r,
 654                    ar=ar,
 655                    aspect=aspect,
 656                    color=aspect_color,
 657                    seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
 658                )
 659        return out
 660
 661    def _draw_all_transit_aspects_lines(self, r, ar):
 662        """
 663        Render SVG lines for all transit aspects in the chart.
 664
 665        Args:
 666            r (float): Radius at which transit aspect lines originate.
 667            ar (float): Radius at which transit aspect lines terminate.
 668
 669        Returns:
 670            str: SVG markup for all transit aspect lines.
 671        """
 672        out = ""
 673        for aspect in self.aspects_list:
 674            aspect_name = aspect["aspect"]
 675            aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None)
 676            if aspect_color:
 677                out += draw_aspect_line(
 678                    r=r,
 679                    ar=ar,
 680                    aspect=aspect,
 681                    color=aspect_color,
 682                    seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
 683                )
 684        return out
 685
 686    def _create_template_dictionary(self) -> ChartTemplateDictionary:
 687        """
 688        Assemble chart data and rendering instructions into a template dictionary.
 689
 690        Gathers styling, dimensions, and SVG fragments for chart components based on
 691        chart type and subjects.
 692
 693        Returns:
 694            ChartTemplateDictionary: Populated structure of template variables.
 695        """
 696        # Initialize template dictionary
 697        template_dict: dict = {}
 698
 699        # -------------------------------------#
 700        #  COMMON SETTINGS FOR ALL CHART TYPES #
 701        # -------------------------------------#
 702
 703        # Set the color style tag and basic dimensions
 704        template_dict["color_style_tag"] = self.color_style_tag
 705        template_dict["chart_height"] = self.height
 706        template_dict["chart_width"] = self.width
 707
 708        # Set paper colors
 709        template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"]
 710        template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"]
 711
 712        # Set background color based on transparent_background setting
 713        if self.transparent_background:
 714            template_dict["background_color"] = "transparent"
 715        else:
 716            template_dict["background_color"] = self.chart_colors_settings["paper_1"]
 717
 718        # Set planet colors
 719        for planet in self.planets_settings:
 720            planet_id = planet["id"]
 721            template_dict[f"planets_color_{planet_id}"] = planet["color"]
 722
 723        # Set zodiac colors
 724        for i in range(12):
 725            template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"]
 726
 727        # Set orb colors
 728        for aspect in self.aspects_settings:
 729            template_dict[f"orb_color_{aspect['degree']}"] = aspect["color"]
 730
 731        # Draw zodiac circle slices
 732        template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius)
 733
 734        # Calculate element percentages
 735        total_elements = self.fire + self.water + self.earth + self.air
 736        fire_percentage = int(round(100 * self.fire / total_elements))
 737        earth_percentage = int(round(100 * self.earth / total_elements))
 738        air_percentage = int(round(100 * self.air / total_elements))
 739        water_percentage = int(round(100 * self.water / total_elements))
 740
 741        # Element Percentages
 742        template_dict["elements_string"] = f"{self.language_settings.get('elements', 'Elements')}:"
 743        template_dict["fire_string"] = f"{self.language_settings['fire']} {fire_percentage}%"
 744        template_dict["earth_string"] = f"{self.language_settings['earth']} {earth_percentage}%"
 745        template_dict["air_string"] = f"{self.language_settings['air']} {air_percentage}%"
 746        template_dict["water_string"] = f"{self.language_settings['water']} {water_percentage}%"
 747
 748
 749        # Qualities Percentages
 750        total_qualities = self.cardinal + self.fixed + self.mutable
 751        cardinal_percentage = int(round(100 * self.cardinal / total_qualities))
 752        fixed_percentage = int(round(100 * self.fixed / total_qualities))
 753        mutable_percentage = int(round(100 * self.mutable / total_qualities))
 754
 755        template_dict["qualities_string"] = f"{self.language_settings.get('qualities', 'Qualities')}:"
 756        template_dict["cardinal_string"] = f"{self.language_settings.get('cardinal', 'Cardinal')} {cardinal_percentage}%"
 757        template_dict["fixed_string"] = f"{self.language_settings.get('fixed', 'Fixed')} {fixed_percentage}%"
 758        template_dict["mutable_string"] = f"{self.language_settings.get('mutable', 'Mutable')} {mutable_percentage}%"
 759
 760        # Get houses list for main subject
 761        first_subject_houses_list = get_houses_list(self.first_obj)
 762
 763        # ------------------------------- #
 764        #  CHART TYPE SPECIFIC SETTINGS   #
 765        # ------------------------------- #
 766
 767        if self.chart_type in ["Natal", "ExternalNatal"]:
 768            # Set viewbox
 769            template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
 770
 771            # Rings and circles
 772            template_dict["transitRing"] = ""
 773            template_dict["degreeRing"] = draw_degree_ring(
 774                self.main_radius,
 775                self.first_circle_radius,
 776                self.first_obj.seventh_house.abs_pos,
 777                self.chart_colors_settings["paper_0"],
 778            )
 779            template_dict["background_circle"] = draw_background_circle(
 780                self.main_radius,
 781                self.chart_colors_settings["paper_1"],
 782                self.chart_colors_settings["paper_1"],
 783            )
 784            template_dict["first_circle"] = draw_first_circle(
 785                self.main_radius,
 786                self.chart_colors_settings["zodiac_radix_ring_2"],
 787                self.chart_type,
 788                self.first_circle_radius,
 789            )
 790            template_dict["second_circle"] = draw_second_circle(
 791                self.main_radius,
 792                self.chart_colors_settings["zodiac_radix_ring_1"],
 793                self.chart_colors_settings["paper_1"],
 794                self.chart_type,
 795                self.second_circle_radius,
 796            )
 797            template_dict["third_circle"] = draw_third_circle(
 798                self.main_radius,
 799                self.chart_colors_settings["zodiac_radix_ring_0"],
 800                self.chart_colors_settings["paper_1"],
 801                self.chart_type,
 802                self.third_circle_radius,
 803            )
 804
 805            # Aspects
 806            template_dict["makeDoubleChartAspectList"] = ""
 807            template_dict["makeAspectGrid"] = draw_aspect_grid(
 808                self.chart_colors_settings["paper_0"],
 809                self.available_planets_setting,
 810                self.aspects_list,
 811            )
 812            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
 813
 814            # Chart title
 815            template_dict["stringTitle"] = f'{self.first_obj.name} - {self.language_settings.get("birth_chart", "Birth Chart")}'
 816
 817            # Top left section
 818            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
 819            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
 820
 821            template_dict["top_left_0"] = f'{self.language_settings.get("location", "Location")}:'
 822            template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}"
 823            template_dict["top_left_2"] = f"{self.language_settings['latitude']}: {latitude_string}"
 824            template_dict["top_left_3"] = f"{self.language_settings['longitude']}: {longitude_string}"
 825            template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
 826            template_dict["top_left_5"] = f"{self.language_settings.get('day_of_week', 'Day of Week')}: {self.first_obj.day_of_week}" # type: ignore
 827
 828            # Bottom left section
 829            if self.first_obj.zodiac_type == "Tropic":
 830                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
 831            else:
 832                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
 833                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
 834                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
 835
 836            template_dict["bottom_left_0"] = zodiac_info
 837            template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
 838
 839            # Lunar phase information (optional)
 840            if self.first_obj.lunar_phase is not None:
 841                template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
 842                template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
 843            else:
 844                template_dict["bottom_left_2"] = ""
 845                template_dict["bottom_left_3"] = ""
 846
 847            template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
 848
 849            # Moon phase section calculations
 850            if self.first_obj.lunar_phase is not None:
 851                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
 852            else:
 853                template_dict["makeLunarPhase"] = ""
 854
 855            # Houses and planet drawing
 856            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
 857                main_subject_houses_list=first_subject_houses_list,
 858                text_color=self.chart_colors_settings["paper_0"],
 859                house_cusp_generale_name_label=self.language_settings["cusp"],
 860            )
 861            template_dict["makeSecondaryHousesGrid"] = ""
 862
 863            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
 864                r=self.main_radius,
 865                first_subject_houses_list=first_subject_houses_list,
 866                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
 867                first_house_color=self.planets_settings[12]["color"],
 868                tenth_house_color=self.planets_settings[13]["color"],
 869                seventh_house_color=self.planets_settings[14]["color"],
 870                fourth_house_color=self.planets_settings[15]["color"],
 871                c1=self.first_circle_radius,
 872                c3=self.third_circle_radius,
 873                chart_type=self.chart_type,
 874            )
 875
 876            template_dict["makePlanets"] = draw_planets(
 877                available_planets_setting=self.available_planets_setting,
 878                chart_type=self.chart_type,
 879                radius=self.main_radius,
 880                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 881                third_circle_radius=self.third_circle_radius,
 882                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
 883                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
 884            )
 885
 886            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
 887                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
 888                subject_name=self.first_obj.name,
 889                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
 890                chart_type=self.chart_type,
 891                text_color=self.chart_colors_settings["paper_0"],
 892                celestial_point_language=self.language_settings["celestial_points"],
 893            )
 894            template_dict["makeSecondaryPlanetGrid"] = ""
 895            template_dict["makeHouseComparisonGrid"] = ""
 896
 897        elif self.chart_type == "Composite":
 898            # Set viewbox
 899            template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
 900
 901            # Rings and circles
 902            template_dict["transitRing"] = ""
 903            template_dict["degreeRing"] = draw_degree_ring(
 904                self.main_radius,
 905                self.first_circle_radius,
 906                self.first_obj.seventh_house.abs_pos,
 907                self.chart_colors_settings["paper_0"],
 908            )
 909            template_dict["background_circle"] = draw_background_circle(
 910                self.main_radius,
 911                self.chart_colors_settings["paper_1"],
 912                self.chart_colors_settings["paper_1"],
 913            )
 914            template_dict["first_circle"] = draw_first_circle(
 915                self.main_radius,
 916                self.chart_colors_settings["zodiac_radix_ring_2"],
 917                self.chart_type,
 918                self.first_circle_radius,
 919            )
 920            template_dict["second_circle"] = draw_second_circle(
 921                self.main_radius,
 922                self.chart_colors_settings["zodiac_radix_ring_1"],
 923                self.chart_colors_settings["paper_1"],
 924                self.chart_type,
 925                self.second_circle_radius,
 926            )
 927            template_dict["third_circle"] = draw_third_circle(
 928                self.main_radius,
 929                self.chart_colors_settings["zodiac_radix_ring_0"],
 930                self.chart_colors_settings["paper_1"],
 931                self.chart_type,
 932                self.third_circle_radius,
 933            )
 934
 935            # Aspects
 936            template_dict["makeDoubleChartAspectList"] = ""
 937            template_dict["makeAspectGrid"] = draw_aspect_grid(
 938                self.chart_colors_settings["paper_0"],
 939                self.available_planets_setting,
 940                self.aspects_list,
 941            )
 942            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
 943
 944            # Chart title
 945            template_dict["stringTitle"] = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
 946
 947            # Top left section
 948            # First subject
 949            latitude = convert_latitude_coordinate_to_string(
 950                self.first_obj.first_subject.lat, # type: ignore
 951                self.language_settings["north_letter"],
 952                self.language_settings["south_letter"],
 953            )
 954            longitude = convert_longitude_coordinate_to_string(
 955                self.first_obj.first_subject.lng, # type: ignore
 956                self.language_settings["east_letter"],
 957                self.language_settings["west_letter"],
 958            )
 959
 960            # Second subject
 961            latitude_string = convert_latitude_coordinate_to_string(
 962                self.first_obj.second_subject.lat, # type: ignore
 963                self.language_settings["north_letter"],
 964                self.language_settings["south_letter"],
 965            )
 966            longitude_string = convert_longitude_coordinate_to_string(
 967                self.first_obj.second_subject.lng, # type: ignore
 968                self.language_settings["east_letter"],
 969                self.language_settings["west_letter"],
 970            )
 971
 972            template_dict["top_left_0"] = f"{self.first_obj.first_subject.name}" # type: ignore
 973            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
 974            template_dict["top_left_2"] = f"{latitude} {longitude}"
 975            template_dict["top_left_3"] = self.first_obj.second_subject.name # type: ignore
 976            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
 977            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
 978
 979            # Bottom left section
 980            if self.first_obj.zodiac_type == "Tropic":
 981                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
 982            else:
 983                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
 984                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
 985                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
 986
 987            template_dict["bottom_left_0"] = zodiac_info
 988            template_dict["bottom_left_1"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}"
 989            template_dict["bottom_left_2"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.first_obj.first_subject.perspective_type}' # type: ignore
 990            template_dict["bottom_left_3"] = f'{self.language_settings.get("composite_chart", "Composite Chart")} - {self.language_settings.get("midpoints", "Midpoints")}'
 991            template_dict["bottom_left_4"] = ""
 992
 993            # Moon phase section calculations
 994            if self.first_obj.lunar_phase is not None:
 995                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
 996            else:
 997                template_dict["makeLunarPhase"] = ""
 998
 999            # Houses and planet drawing
1000            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1001                main_subject_houses_list=first_subject_houses_list,
1002                text_color=self.chart_colors_settings["paper_0"],
1003                house_cusp_generale_name_label=self.language_settings["cusp"],
1004            )
1005            template_dict["makeSecondaryHousesGrid"] = ""
1006
1007            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1008                r=self.main_radius,
1009                first_subject_houses_list=first_subject_houses_list,
1010                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1011                first_house_color=self.planets_settings[12]["color"],
1012                tenth_house_color=self.planets_settings[13]["color"],
1013                seventh_house_color=self.planets_settings[14]["color"],
1014                fourth_house_color=self.planets_settings[15]["color"],
1015                c1=self.first_circle_radius,
1016                c3=self.third_circle_radius,
1017                chart_type=self.chart_type,
1018            )
1019
1020            template_dict["makePlanets"] = draw_planets(
1021                available_planets_setting=self.available_planets_setting,
1022                chart_type=self.chart_type,
1023                radius=self.main_radius,
1024                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1025                third_circle_radius=self.third_circle_radius,
1026                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1027                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1028            )
1029
1030            subject_name = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore
1031
1032            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1033                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
1034                subject_name=subject_name,
1035                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1036                chart_type=self.chart_type,
1037                text_color=self.chart_colors_settings["paper_0"],
1038                celestial_point_language=self.language_settings["celestial_points"],
1039            )
1040            template_dict["makeSecondaryPlanetGrid"] = ""
1041            template_dict["makeHouseComparisonGrid"] = ""
1042
1043        elif self.chart_type == "Transit":
1044
1045            # Transit has no Element Percentages
1046            template_dict["elements_string"] = ""
1047            template_dict["fire_string"] = ""
1048            template_dict["earth_string"] = ""
1049            template_dict["air_string"] = ""
1050            template_dict["water_string"] = ""
1051
1052            # Transit has no Qualities Percentages
1053            template_dict["qualities_string"] = ""
1054            template_dict["cardinal_string"] = ""
1055            template_dict["fixed_string"] = ""
1056            template_dict["mutable_string"] = ""
1057
1058            # Set viewbox
1059            if self.double_chart_aspect_grid_type == "table":
1060                template_dict["viewbox"] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX
1061            else:
1062                template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1063
1064            # Get houses list for secondary subject
1065            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1066
1067            # Rings and circles
1068            template_dict["transitRing"] = draw_transit_ring(
1069                self.main_radius,
1070                self.chart_colors_settings["paper_1"],
1071                self.chart_colors_settings["zodiac_transit_ring_3"],
1072            )
1073            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1074            template_dict["background_circle"] = draw_background_circle(
1075                self.main_radius,
1076                self.chart_colors_settings["paper_1"],
1077                self.chart_colors_settings["paper_1"],
1078            )
1079            template_dict["first_circle"] = draw_first_circle(
1080                self.main_radius,
1081                self.chart_colors_settings["zodiac_transit_ring_2"],
1082                self.chart_type,
1083            )
1084            template_dict["second_circle"] = draw_second_circle(
1085                self.main_radius,
1086                self.chart_colors_settings["zodiac_transit_ring_1"],
1087                self.chart_colors_settings["paper_1"],
1088                self.chart_type,
1089            )
1090            template_dict["third_circle"] = draw_third_circle(
1091                self.main_radius,
1092                self.chart_colors_settings["zodiac_transit_ring_0"],
1093                self.chart_colors_settings["paper_1"],
1094                self.chart_type,
1095                self.third_circle_radius,
1096            )
1097
1098            # Aspects
1099            if self.double_chart_aspect_grid_type == "list":
1100                title = f'{self.first_obj.name} - {self.language_settings.get("transit_aspects", "Transit Aspects")}'
1101                template_dict["makeAspectGrid"] = ""
1102                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings)
1103            else:
1104                template_dict["makeAspectGrid"] = ""
1105                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1106                    self.chart_colors_settings["paper_0"],
1107                    self.available_planets_setting,
1108                    self.aspects_list,
1109                    550,
1110                    450,
1111                )
1112
1113            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1114
1115            # Chart title
1116            template_dict["stringTitle"] = f"{self.language_settings['transits']} {format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime)}" # type: ignore
1117
1118            # Top left section
1119            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1120            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
1121
1122            template_dict["top_left_0"] = template_dict["top_left_0"] = f'{self.first_obj.name}'
1123            template_dict["top_left_1"] = f"{format_location_string(self.first_obj.city)}, {self.first_obj.nation}" # type: ignore
1124            template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1125            template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}"
1126            template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}"
1127            template_dict["top_left_5"] = ""#f"{self.language_settings['type']}: {self.language_settings.get(self.chart_type, self.chart_type)}"
1128
1129            # Bottom left section
1130            if self.first_obj.zodiac_type == "Tropic":
1131                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
1132            else:
1133                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1134                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1135                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
1136
1137            template_dict["bottom_left_0"] = zodiac_info
1138            template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1139
1140            # Lunar phase information from second object (Transit) (optional)
1141            if self.second_obj is not None and hasattr(self.second_obj, 'lunar_phase') and self.second_obj.lunar_phase is not None:
1142                template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.second_obj.lunar_phase.get("moon_phase", "")}' # type: ignore
1143                template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.second_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.second_obj.lunar_phase.moon_phase_name)}'
1144            else:
1145                template_dict["bottom_left_2"] = ""
1146                template_dict["bottom_left_3"] = ""
1147
1148            template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.second_obj.perspective_type.lower().replace(" ", "_"), self.second_obj.perspective_type)}' # type: ignore
1149
1150            # Moon phase section calculations - use first_obj for visualization
1151            if self.first_obj.lunar_phase is not None:
1152                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1153            else:
1154                template_dict["makeLunarPhase"] = ""
1155
1156            # Houses and planet drawing
1157            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1158                main_subject_houses_list=first_subject_houses_list,
1159                text_color=self.chart_colors_settings["paper_0"],
1160                house_cusp_generale_name_label=self.language_settings["cusp"],
1161            )
1162            # template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1163            #     secondary_subject_houses_list=second_subject_houses_list,
1164            #     text_color=self.chart_colors_settings["paper_0"],
1165            #     house_cusp_generale_name_label=self.language_settings["cusp"],
1166            # )
1167            template_dict["makeSecondaryHousesGrid"] = ""
1168
1169            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1170                r=self.main_radius,
1171                first_subject_houses_list=first_subject_houses_list,
1172                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1173                first_house_color=self.planets_settings[12]["color"],
1174                tenth_house_color=self.planets_settings[13]["color"],
1175                seventh_house_color=self.planets_settings[14]["color"],
1176                fourth_house_color=self.planets_settings[15]["color"],
1177                c1=self.first_circle_radius,
1178                c3=self.third_circle_radius,
1179                chart_type=self.chart_type,
1180                second_subject_houses_list=second_subject_houses_list,
1181                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1182            )
1183
1184            template_dict["makePlanets"] = draw_planets(
1185                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1186                available_planets_setting=self.available_planets_setting,
1187                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1188                radius=self.main_radius,
1189                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1190                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1191                chart_type=self.chart_type,
1192                third_circle_radius=self.third_circle_radius,
1193            )
1194
1195            # Planet grids
1196            first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})"
1197            second_return_grid_title = f"{self.language_settings.get('Transit', 'Transit')} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})"
1198            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1199                planets_and_houses_grid_title="",
1200                subject_name=first_return_grid_title,
1201                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1202                chart_type=self.chart_type,
1203                text_color=self.chart_colors_settings["paper_0"],
1204                celestial_point_language=self.language_settings["celestial_points"],
1205            )
1206
1207            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1208                planets_and_houses_grid_title="",
1209                second_subject_name=second_return_grid_title,
1210                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1211                chart_type=self.chart_type,
1212                text_color=self.chart_colors_settings["paper_0"],
1213                celestial_point_language=self.language_settings["celestial_points"],
1214            )
1215
1216            # House comparison grid
1217            house_comparison_factory = HouseComparisonFactory(
1218                first_subject=self.first_obj,
1219                second_subject=self.second_obj,
1220                active_points=self.active_points,
1221            )
1222            house_comparison = house_comparison_factory.get_house_comparison()
1223
1224            template_dict["makeHouseComparisonGrid"] = draw_single_house_comparison_grid(
1225                house_comparison,
1226                celestial_point_language=self.language_settings.get("celestial_points", "Celestial Points"),
1227                active_points=self.active_points,
1228                points_owner_subject_number=2, # The second subject is the Transit
1229                house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1230                return_point_label=self.language_settings.get("transit_point", "Transit Point"),
1231                natal_house_label=self.language_settings.get("house_position", "Natal House"),
1232                x_position=930,
1233            )
1234
1235        elif self.chart_type == "Synastry":
1236            # Set viewbox
1237            template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX
1238
1239            # Get houses list for secondary subject
1240            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1241
1242            # Rings and circles
1243            template_dict["transitRing"] = draw_transit_ring(
1244                self.main_radius,
1245                self.chart_colors_settings["paper_1"],
1246                self.chart_colors_settings["zodiac_transit_ring_3"],
1247            )
1248            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1249            template_dict["background_circle"] = draw_background_circle(
1250                self.main_radius,
1251                self.chart_colors_settings["paper_1"],
1252                self.chart_colors_settings["paper_1"],
1253            )
1254            template_dict["first_circle"] = draw_first_circle(
1255                self.main_radius,
1256                self.chart_colors_settings["zodiac_transit_ring_2"],
1257                self.chart_type,
1258            )
1259            template_dict["second_circle"] = draw_second_circle(
1260                self.main_radius,
1261                self.chart_colors_settings["zodiac_transit_ring_1"],
1262                self.chart_colors_settings["paper_1"],
1263                self.chart_type,
1264            )
1265            template_dict["third_circle"] = draw_third_circle(
1266                self.main_radius,
1267                self.chart_colors_settings["zodiac_transit_ring_0"],
1268                self.chart_colors_settings["paper_1"],
1269                self.chart_type,
1270                self.third_circle_radius,
1271            )
1272
1273            # Aspects
1274            if self.double_chart_aspect_grid_type == "list":
1275                template_dict["makeAspectGrid"] = ""
1276                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(
1277                    f"{self.first_obj.name} - {self.second_obj.name} {self.language_settings.get('synastry_aspects', 'Synastry Aspects')}", # type: ignore
1278                    self.aspects_list,
1279                    self.planets_settings,
1280                    self.aspects_settings
1281                )
1282            else:
1283                template_dict["makeAspectGrid"] = ""
1284                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1285                    self.chart_colors_settings["paper_0"],
1286                    self.available_planets_setting,
1287                    self.aspects_list,
1288                    550,
1289                    450,
1290                )
1291
1292            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1293
1294            # Chart title
1295            template_dict["stringTitle"] = f"{self.first_obj.name} {self.language_settings['and_word']} {self.second_obj.name}" # type: ignore
1296
1297            # Top left section
1298            template_dict["top_left_0"] = f"{self.first_obj.name}:"
1299            template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore
1300            template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1301            template_dict["top_left_3"] = f"{self.second_obj.name}: " # type: ignore
1302            template_dict["top_left_4"] = f"{self.second_obj.city}, {self.second_obj.nation}" # type: ignore
1303            template_dict["top_left_5"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
1304
1305            # Bottom left section
1306            if self.first_obj.zodiac_type == "Tropic":
1307                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
1308            else:
1309                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1310                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1311                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
1312
1313            template_dict["bottom_left_0"] = ""
1314            # FIXME!
1315            template_dict["bottom_left_1"] = "" # f"Compatibility Score: {16}/44" # type: ignore
1316            template_dict["bottom_left_2"] = zodiac_info
1317            template_dict["bottom_left_3"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}"
1318            template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1319
1320            # Moon phase section calculations
1321            template_dict["makeLunarPhase"] = ""
1322
1323            # Houses and planet drawing
1324            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1325                main_subject_houses_list=first_subject_houses_list,
1326                text_color=self.chart_colors_settings["paper_0"],
1327                house_cusp_generale_name_label=self.language_settings["cusp"],
1328            )
1329
1330            template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1331                secondary_subject_houses_list=second_subject_houses_list,
1332                text_color=self.chart_colors_settings["paper_0"],
1333                house_cusp_generale_name_label=self.language_settings["cusp"],
1334            )
1335
1336            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1337                r=self.main_radius,
1338                first_subject_houses_list=first_subject_houses_list,
1339                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1340                first_house_color=self.planets_settings[12]["color"],
1341                tenth_house_color=self.planets_settings[13]["color"],
1342                seventh_house_color=self.planets_settings[14]["color"],
1343                fourth_house_color=self.planets_settings[15]["color"],
1344                c1=self.first_circle_radius,
1345                c3=self.third_circle_radius,
1346                chart_type=self.chart_type,
1347                second_subject_houses_list=second_subject_houses_list,
1348                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1349            )
1350
1351            template_dict["makePlanets"] = draw_planets(
1352                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1353                available_planets_setting=self.available_planets_setting,
1354                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1355                radius=self.main_radius,
1356                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1357                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1358                chart_type=self.chart_type,
1359                third_circle_radius=self.third_circle_radius,
1360            )
1361
1362            # Planet grid
1363            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1364                planets_and_houses_grid_title="",
1365                subject_name=f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})",
1366                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1367                chart_type=self.chart_type,
1368                text_color=self.chart_colors_settings["paper_0"],
1369                celestial_point_language=self.language_settings["celestial_points"],
1370            )
1371            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1372                planets_and_houses_grid_title="",
1373                second_subject_name= f"{self.second_obj.name} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})", # type: ignore
1374                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1375                chart_type=self.chart_type,
1376                text_color=self.chart_colors_settings["paper_0"],
1377                celestial_point_language=self.language_settings["celestial_points"],
1378            )
1379            template_dict["makeHouseComparisonGrid"] = ""
1380
1381        elif self.chart_type == "Return":
1382            # Set viewbox
1383            template_dict["viewbox"] = self._ULTRA_WIDE_CHART_VIEWBOX
1384
1385            # Get houses list for secondary subject
1386            second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore
1387
1388            # Rings and circles
1389            template_dict["transitRing"] = draw_transit_ring(
1390                self.main_radius,
1391                self.chart_colors_settings["paper_1"],
1392                self.chart_colors_settings["zodiac_transit_ring_3"],
1393            )
1394            template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos)
1395            template_dict["background_circle"] = draw_background_circle(
1396                self.main_radius,
1397                self.chart_colors_settings["paper_1"],
1398                self.chart_colors_settings["paper_1"],
1399            )
1400            template_dict["first_circle"] = draw_first_circle(
1401                self.main_radius,
1402                self.chart_colors_settings["zodiac_transit_ring_2"],
1403                self.chart_type,
1404            )
1405            template_dict["second_circle"] = draw_second_circle(
1406                self.main_radius,
1407                self.chart_colors_settings["zodiac_transit_ring_1"],
1408                self.chart_colors_settings["paper_1"],
1409                self.chart_type,
1410            )
1411            template_dict["third_circle"] = draw_third_circle(
1412                self.main_radius,
1413                self.chart_colors_settings["zodiac_transit_ring_0"],
1414                self.chart_colors_settings["paper_1"],
1415                self.chart_type,
1416                self.third_circle_radius,
1417            )
1418
1419            # Aspects
1420            if self.double_chart_aspect_grid_type == "list":
1421                title = self.language_settings.get("return_aspects", "Natal to Return Aspects")
1422                template_dict["makeAspectGrid"] = ""
1423                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings, max_columns=7)
1424            else:
1425                template_dict["makeAspectGrid"] = ""
1426                template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid(
1427                    self.chart_colors_settings["paper_0"],
1428                    self.available_planets_setting,
1429                    self.aspects_list,
1430                    550,
1431                    450,
1432                )
1433
1434            template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160)
1435
1436            # Chart title
1437            if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1438                template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('solar_return', 'Solar Return')}"
1439            else:
1440                template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('lunar_return', 'Lunar Return')}"
1441
1442
1443            # Top left section
1444            # Subject
1445            latitude_string = convert_latitude_coordinate_to_string(self.first_obj.lat, self.language_settings["north"], self.language_settings["south"]) # type: ignore
1446            longitude_string = convert_longitude_coordinate_to_string(self.first_obj.lng, self.language_settings["east"], self.language_settings["west"]) # type: ignore
1447
1448            # Return
1449            return_latitude_string = convert_latitude_coordinate_to_string(self.second_obj.lat, self.language_settings["north"], self.language_settings["south"]) # type: ignore
1450            return_longitude_string = convert_longitude_coordinate_to_string(self.second_obj.lng, self.language_settings["east"], self.language_settings["west"]) # type: ignore
1451
1452            if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1453                template_dict["top_left_0"] = f"{self.language_settings.get('solar_return', 'Solar Return')}:"
1454            else:
1455                template_dict["top_left_0"] = f"{self.language_settings.get('lunar_return', 'Lunar Return')}:"
1456            template_dict["top_left_1"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore
1457            template_dict["top_left_2"] = f"{return_latitude_string} / {return_longitude_string}"
1458            template_dict["top_left_3"] = f"{self.first_obj.name}"
1459            template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1460            template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}"
1461
1462            # Bottom left section
1463            if self.first_obj.zodiac_type == "Tropic":
1464                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
1465            else:
1466                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1467                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1468                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
1469
1470            template_dict["bottom_left_0"] = zodiac_info
1471            template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}"
1472
1473            # Lunar phase information (optional)
1474            if self.first_obj.lunar_phase is not None:
1475                template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
1476                template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
1477            else:
1478                template_dict["bottom_left_2"] = ""
1479                template_dict["bottom_left_3"] = ""
1480
1481            template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1482
1483            # Moon phase section calculations
1484            if self.first_obj.lunar_phase is not None:
1485                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1486            else:
1487                template_dict["makeLunarPhase"] = ""
1488
1489            # Houses and planet drawing
1490            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1491                main_subject_houses_list=first_subject_houses_list,
1492                text_color=self.chart_colors_settings["paper_0"],
1493                house_cusp_generale_name_label=self.language_settings["cusp"],
1494            )
1495
1496            template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid(
1497                secondary_subject_houses_list=second_subject_houses_list,
1498                text_color=self.chart_colors_settings["paper_0"],
1499                house_cusp_generale_name_label=self.language_settings["cusp"],
1500            )
1501
1502            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1503                r=self.main_radius,
1504                first_subject_houses_list=first_subject_houses_list,
1505                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1506                first_house_color=self.planets_settings[12]["color"],
1507                tenth_house_color=self.planets_settings[13]["color"],
1508                seventh_house_color=self.planets_settings[14]["color"],
1509                fourth_house_color=self.planets_settings[15]["color"],
1510                c1=self.first_circle_radius,
1511                c3=self.third_circle_radius,
1512                chart_type=self.chart_type,
1513                second_subject_houses_list=second_subject_houses_list,
1514                transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"],
1515            )
1516
1517            template_dict["makePlanets"] = draw_planets(
1518                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1519                available_planets_setting=self.available_planets_setting,
1520                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1521                radius=self.main_radius,
1522                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1523                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1524                chart_type=self.chart_type,
1525                third_circle_radius=self.third_circle_radius,
1526            )
1527
1528            # Planet grid
1529            if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1530                first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})"
1531                second_return_grid_title = f"{self.language_settings.get('solar_return', 'Solar Return')} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})"
1532            else:
1533                first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})"
1534                second_return_grid_title = f'{self.language_settings.get("lunar_return", "Lunar Return")} ({self.language_settings.get("outer_wheel", "Outer Wheel")})'
1535            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1536                planets_and_houses_grid_title="",
1537                subject_name=first_return_grid_title,
1538                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1539                chart_type=self.chart_type,
1540                text_color=self.chart_colors_settings["paper_0"],
1541                celestial_point_language=self.language_settings["celestial_points"],
1542            )
1543            template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid(
1544                planets_and_houses_grid_title="",
1545                second_subject_name=second_return_grid_title,
1546                second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points,
1547                chart_type=self.chart_type,
1548                text_color=self.chart_colors_settings["paper_0"],
1549                celestial_point_language=self.language_settings["celestial_points"],
1550            )
1551
1552            house_comparison_factory = HouseComparisonFactory(
1553                first_subject=self.first_obj,
1554                second_subject=self.second_obj,
1555                active_points=self.active_points,
1556            )
1557            house_comparison = house_comparison_factory.get_house_comparison()
1558
1559            template_dict["makeHouseComparisonGrid"] = draw_house_comparison_grid(
1560                house_comparison,
1561                celestial_point_language=self.language_settings["celestial_points"],
1562                active_points=self.active_points,
1563                points_owner_subject_number=2, # The second subject is the Solar Return
1564                house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"),
1565                return_point_label=self.language_settings.get("return_point", "Return Point"),
1566                return_label=self.language_settings.get("Return", "Return"),
1567                radix_label=self.language_settings.get("Natal", "Natal"),
1568            )
1569
1570        elif self.chart_type == "SingleWheelReturn":
1571            # Set viewbox
1572            template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX
1573
1574            # Rings and circles
1575            template_dict["transitRing"] = ""
1576            template_dict["degreeRing"] = draw_degree_ring(
1577                self.main_radius,
1578                self.first_circle_radius,
1579                self.first_obj.seventh_house.abs_pos,
1580                self.chart_colors_settings["paper_0"],
1581            )
1582            template_dict["background_circle"] = draw_background_circle(
1583                self.main_radius,
1584                self.chart_colors_settings["paper_1"],
1585                self.chart_colors_settings["paper_1"],
1586            )
1587            template_dict["first_circle"] = draw_first_circle(
1588                self.main_radius,
1589                self.chart_colors_settings["zodiac_radix_ring_2"],
1590                self.chart_type,
1591                self.first_circle_radius,
1592            )
1593            template_dict["second_circle"] = draw_second_circle(
1594                self.main_radius,
1595                self.chart_colors_settings["zodiac_radix_ring_1"],
1596                self.chart_colors_settings["paper_1"],
1597                self.chart_type,
1598                self.second_circle_radius,
1599            )
1600            template_dict["third_circle"] = draw_third_circle(
1601                self.main_radius,
1602                self.chart_colors_settings["zodiac_radix_ring_0"],
1603                self.chart_colors_settings["paper_1"],
1604                self.chart_type,
1605                self.third_circle_radius,
1606            )
1607
1608            # Aspects
1609            template_dict["makeDoubleChartAspectList"] = ""
1610            template_dict["makeAspectGrid"] = draw_aspect_grid(
1611                self.chart_colors_settings["paper_0"],
1612                self.available_planets_setting,
1613                self.aspects_list,
1614            )
1615            template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius)
1616
1617            # Chart title
1618            template_dict["stringTitle"] = self.first_obj.name
1619
1620            # Top left section
1621            latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"])
1622            longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"])
1623
1624            template_dict["top_left_0"] = f'{self.language_settings["info"]}:'
1625            template_dict["top_left_1"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore
1626            template_dict["top_left_2"] = f"{self.first_obj.city}, {self.first_obj.nation}"
1627            template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}"
1628            template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}"
1629
1630            if hasattr(self.first_obj, 'return_type') and self.first_obj.return_type == "Solar":
1631                template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get('solar_return', 'Solar Return')}"
1632            else:
1633                template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get('lunar_return', 'Lunar Return')}"
1634
1635            # Bottom left section
1636            if self.first_obj.zodiac_type == "Tropic":
1637                zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}"
1638            else:
1639                mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore
1640                mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const))
1641                zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}"
1642
1643            template_dict["bottom_left_0"] = zodiac_info
1644            template_dict["bottom_left_1"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}"
1645
1646            # Lunar phase information (optional)
1647            if self.first_obj.lunar_phase is not None:
1648                template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}'
1649                template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}'
1650            else:
1651                template_dict["bottom_left_2"] = ""
1652                template_dict["bottom_left_3"] = ""
1653
1654            template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}'
1655
1656            # Moon phase section calculations
1657            if self.first_obj.lunar_phase is not None:
1658                template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat)
1659            else:
1660                template_dict["makeLunarPhase"] = ""
1661
1662            # Houses and planet drawing
1663            template_dict["makeMainHousesGrid"] = draw_main_house_grid(
1664                main_subject_houses_list=first_subject_houses_list,
1665                text_color=self.chart_colors_settings["paper_0"],
1666                house_cusp_generale_name_label=self.language_settings["cusp"],
1667            )
1668            template_dict["makeSecondaryHousesGrid"] = ""
1669
1670            template_dict["makeHouses"] = draw_houses_cusps_and_text_number(
1671                r=self.main_radius,
1672                first_subject_houses_list=first_subject_houses_list,
1673                standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"],
1674                first_house_color=self.planets_settings[12]["color"],
1675                tenth_house_color=self.planets_settings[13]["color"],
1676                seventh_house_color=self.planets_settings[14]["color"],
1677                fourth_house_color=self.planets_settings[15]["color"],
1678                c1=self.first_circle_radius,
1679                c3=self.third_circle_radius,
1680                chart_type=self.chart_type,
1681            )
1682
1683            template_dict["makePlanets"] = draw_planets(
1684                available_planets_setting=self.available_planets_setting,
1685                chart_type=self.chart_type,
1686                radius=self.main_radius,
1687                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1688                third_circle_radius=self.third_circle_radius,
1689                main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos,
1690                main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos,
1691            )
1692
1693            template_dict["makeMainPlanetGrid"] = draw_main_planet_grid(
1694                planets_and_houses_grid_title=self.language_settings["planets_and_house"],
1695                subject_name=self.first_obj.name,
1696                available_kerykeion_celestial_points=self.available_kerykeion_celestial_points,
1697                chart_type=self.chart_type,
1698                text_color=self.chart_colors_settings["paper_0"],
1699                celestial_point_language=self.language_settings["celestial_points"],
1700            )
1701            template_dict["makeSecondaryPlanetGrid"] = ""
1702            template_dict["makeHouseComparisonGrid"] = ""
1703
1704        return ChartTemplateDictionary(**template_dict)
1705
1706    def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
1707        """
1708        Render the full chart SVG as a string.
1709
1710        Reads the XML template, substitutes variables, and optionally inlines CSS
1711        variables and minifies the output.
1712
1713        Args:
1714            minify (bool): Remove whitespace and quotes for compactness.
1715            remove_css_variables (bool): Embed CSS variable definitions.
1716
1717        Returns:
1718            str: SVG markup as a string.
1719        """
1720        td = self._create_template_dictionary()
1721
1722        DATA_DIR = Path(__file__).parent
1723        xml_svg = DATA_DIR / "templates" / "chart.xml"
1724
1725        # read template
1726        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
1727            template = Template(f.read()).substitute(td)
1728
1729        # return filename
1730
1731        logging.debug(f"Template dictionary keys: {td.keys()}")
1732
1733        self._create_template_dictionary()
1734
1735        if remove_css_variables:
1736            template = inline_css_variables_in_svg(template)
1737
1738        if minify:
1739            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1740
1741        else:
1742            template = template.replace('"', "'")
1743
1744        return template
1745
1746    def makeSVG(self, minify: bool = False, remove_css_variables=False):
1747        """
1748        Generate and save the full chart SVG to disk.
1749
1750        Calls makeTemplate to render the SVG, then writes a file named
1751        "{subject.name} - {chart_type} Chart.svg" in the output directory.
1752
1753        Args:
1754            minify (bool): Pass-through to makeTemplate for compact output.
1755            remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
1756
1757        Returns:
1758            None
1759        """
1760
1761        self.template = self.makeTemplate(minify, remove_css_variables)
1762
1763        if self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
1764            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Lunar Return.svg"
1765        elif self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1766            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Solar Return.svg"
1767        else:
1768            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart.svg"
1769
1770        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1771            output_file.write(self.template)
1772
1773        print(f"SVG Generated Correctly in: {chartname}")
1774
1775    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1776        """
1777        Render the wheel-only chart SVG as a string.
1778
1779        Reads the wheel-only XML template, substitutes chart data, and applies optional
1780        CSS inlining and minification.
1781
1782        Args:
1783            minify (bool): Remove whitespace and quotes for compactness.
1784            remove_css_variables (bool): Embed CSS variable definitions.
1785
1786        Returns:
1787            str: SVG markup for the chart wheel only.
1788        """
1789
1790        with open(
1791            Path(__file__).parent / "templates" / "wheel_only.xml",
1792            "r",
1793            encoding="utf-8",
1794            errors="ignore",
1795        ) as f:
1796            template = f.read()
1797
1798        template_dict = self._create_template_dictionary()
1799        template = Template(template).substitute(template_dict)
1800
1801        if remove_css_variables:
1802            template = inline_css_variables_in_svg(template)
1803
1804        if minify:
1805            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1806
1807        else:
1808            template = template.replace('"', "'")
1809
1810        return template
1811
1812    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
1813        """
1814        Generate and save wheel-only chart SVG to disk.
1815
1816        Calls makeWheelOnlyTemplate and writes a file named
1817        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
1818
1819        Args:
1820            minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
1821            remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
1822
1823        Returns:
1824            None
1825        """
1826
1827        template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
1828        chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Wheel Only.svg"
1829
1830        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1831            output_file.write(template)
1832
1833        print(f"SVG Generated Correctly in: {chartname}")
1834
1835    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1836        """
1837        Render the aspect-grid-only chart SVG as a string.
1838
1839        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
1840        and applies optional CSS inlining and minification.
1841
1842        Args:
1843            minify (bool): Remove whitespace and quotes for compactness.
1844            remove_css_variables (bool): Embed CSS variable definitions.
1845
1846        Returns:
1847            str: SVG markup for the aspect grid only.
1848        """
1849
1850        with open(
1851            Path(__file__).parent / "templates" / "aspect_grid_only.xml",
1852            "r",
1853            encoding="utf-8",
1854            errors="ignore",
1855        ) as f:
1856            template = f.read()
1857
1858        template_dict = self._create_template_dictionary()
1859
1860        if self.chart_type in ["Transit", "Synastry", "Return"]:
1861            aspects_grid = draw_transit_aspect_grid(
1862                self.chart_colors_settings["paper_0"],
1863                self.available_planets_setting,
1864                self.aspects_list,
1865            )
1866        else:
1867            aspects_grid = draw_aspect_grid(
1868                self.chart_colors_settings["paper_0"],
1869                self.available_planets_setting,
1870                self.aspects_list,
1871                x_start=50,
1872                y_start=250,
1873            )
1874
1875        template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
1876
1877        if remove_css_variables:
1878            template = inline_css_variables_in_svg(template)
1879
1880        if minify:
1881            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1882
1883        else:
1884            template = template.replace('"', "'")
1885
1886        return template
1887
1888    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
1889        """
1890        Generate and save aspect-grid-only chart SVG to disk.
1891
1892        Calls makeAspectGridOnlyTemplate and writes a file named
1893        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
1894
1895        Args:
1896            minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
1897            remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
1898
1899        Returns:
1900            None
1901        """
1902
1903        template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
1904        chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
1905
1906        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1907            output_file.write(template)
1908
1909        print(f"SVG Generated Correctly in: {chartname}")

ChartDrawer generates astrological chart visualizations as SVG files.

This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite. Charts are rendered using XML templates and drawing utilities, with customizable themes, language, active points, and aspects. The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.

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: first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel): The primary astrological subject for the chart. chart_type (ChartType, optional): The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite'). Defaults to 'Natal'. second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional): The secondary subject for Transit or Synastry charts. Not required for Natal or Composite. new_output_directory (str | Path, optional): Directory to write generated SVG files. Defaults to the user's home directory. new_settings_file (Path | dict | KerykeionSettingsModel, optional): Path or settings object to override default chart configuration (colors, fonts, aspects). 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'. active_points (list[AstrologicalPoint], optional): List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS. Example: ["Sun", "Moon", "Mercury", "Venus"]

active_aspects (list[ActiveAspect], optional):
    List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
    Example:
    [
        {"name": "conjunction", "orb": 10},
        {"name": "opposition", "orb": 10},
        {"name": "trine", "orb": 8},
        {"name": "sextile", "orb": 6},
        {"name": "square", "orb": 5},
        {"name": "quintile", "orb": 1},
    ]

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.

makeSVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the full chart SVG file to the output directory.
    Filenames follow the 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.

makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the wheel-only SVG file:
    '{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.

makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
    Generate and write the aspect-grid-only SVG file:
    '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
ChartDrawer( first_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel], chart_type: Literal['Natal', 'ExternalNatal', 'Synastry', 'Transit', 'Composite', 'Return', 'SingleWheelReturn'] = 'Natal', second_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel, NoneType] = None, new_output_directory: Optional[str] = None, new_settings_file: Union[pathlib._local.Path, NoneType, KerykeionSettingsModel, dict] = None, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry']] = 'classic', double_chart_aspect_grid_type: Literal['list', 'table'] = 'list', chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI'] = 'EN', active_points: Optional[list[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]] = None, active_aspects: list[kerykeion.schemas.kr_models.ActiveAspect] = [{'name': 'conjunction', 'orb': 10}, {'name': 'opposition', 'orb': 10}, {'name': 'trine', 'orb': 8}, {'name': 'sextile', 'orb': 6}, {'name': 'square', 'orb': 5}, {'name': 'quintile', 'orb': 1}], *, transparent_background: bool = False, colors_settings: dict = {'paper_0': 'var(--kerykeion-chart-color-paper-0)', 'paper_1': 'var(--kerykeion-chart-color-paper-1)', 'zodiac_bg_0': 'var(--kerykeion-chart-color-zodiac-bg-0)', 'zodiac_bg_1': 'var(--kerykeion-chart-color-zodiac-bg-1)', 'zodiac_bg_2': 'var(--kerykeion-chart-color-zodiac-bg-2)', 'zodiac_bg_3': 'var(--kerykeion-chart-color-zodiac-bg-3)', 'zodiac_bg_4': 'var(--kerykeion-chart-color-zodiac-bg-4)', 'zodiac_bg_5': 'var(--kerykeion-chart-color-zodiac-bg-5)', 'zodiac_bg_6': 'var(--kerykeion-chart-color-zodiac-bg-6)', 'zodiac_bg_7': 'var(--kerykeion-chart-color-zodiac-bg-7)', 'zodiac_bg_8': 'var(--kerykeion-chart-color-zodiac-bg-8)', 'zodiac_bg_9': 'var(--kerykeion-chart-color-zodiac-bg-9)', 'zodiac_bg_10': 'var(--kerykeion-chart-color-zodiac-bg-10)', 'zodiac_bg_11': 'var(--kerykeion-chart-color-zodiac-bg-11)', 'zodiac_icon_0': 'var(--kerykeion-chart-color-zodiac-icon-0)', 'zodiac_icon_1': 'var(--kerykeion-chart-color-zodiac-icon-1)', 'zodiac_icon_2': 'var(--kerykeion-chart-color-zodiac-icon-2)', 'zodiac_icon_3': 'var(--kerykeion-chart-color-zodiac-icon-3)', 'zodiac_icon_4': 'var(--kerykeion-chart-color-zodiac-icon-4)', 'zodiac_icon_5': 'var(--kerykeion-chart-color-zodiac-icon-5)', 'zodiac_icon_6': 'var(--kerykeion-chart-color-zodiac-icon-6)', 'zodiac_icon_7': 'var(--kerykeion-chart-color-zodiac-icon-7)', 'zodiac_icon_8': 'var(--kerykeion-chart-color-zodiac-icon-8)', 'zodiac_icon_9': 'var(--kerykeion-chart-color-zodiac-icon-9)', 'zodiac_icon_10': 'var(--kerykeion-chart-color-zodiac-icon-10)', 'zodiac_icon_11': 'var(--kerykeion-chart-color-zodiac-icon-11)', 'zodiac_radix_ring_0': 'var(--kerykeion-chart-color-zodiac-radix-ring-0)', 'zodiac_radix_ring_1': 'var(--kerykeion-chart-color-zodiac-radix-ring-1)', 'zodiac_radix_ring_2': 'var(--kerykeion-chart-color-zodiac-radix-ring-2)', 'zodiac_transit_ring_0': 'var(--kerykeion-chart-color-zodiac-transit-ring-0)', 'zodiac_transit_ring_1': 'var(--kerykeion-chart-color-zodiac-transit-ring-1)', 'zodiac_transit_ring_2': 'var(--kerykeion-chart-color-zodiac-transit-ring-2)', 'zodiac_transit_ring_3': 'var(--kerykeion-chart-color-zodiac-transit-ring-3)', 'houses_radix_line': 'var(--kerykeion-chart-color-houses-radix-line)', 'houses_transit_line': 'var(--kerykeion-chart-color-houses-transit-line)', 'lunar_phase_0': 'var(--kerykeion-chart-color-lunar-phase-0)', 'lunar_phase_1': 'var(--kerykeion-chart-color-lunar-phase-1)'}, celestial_points_settings: list[dict] = [{'id': 0, 'name': 'Sun', 'color': 'var(--kerykeion-chart-color-sun)', 'element_points': 40, 'label': 'Sun'}, {'id': 1, 'name': 'Moon', 'color': 'var(--kerykeion-chart-color-moon)', 'element_points': 40, 'label': 'Moon'}, {'id': 2, 'name': 'Mercury', 'color': 'var(--kerykeion-chart-color-mercury)', 'element_points': 15, 'label': 'Mercury'}, {'id': 3, 'name': 'Venus', 'color': 'var(--kerykeion-chart-color-venus)', 'element_points': 15, 'label': 'Venus'}, {'id': 4, 'name': 'Mars', 'color': 'var(--kerykeion-chart-color-mars)', 'element_points': 15, 'label': 'Mars'}, {'id': 5, 'name': 'Jupiter', 'color': 'var(--kerykeion-chart-color-jupiter)', 'element_points': 10, 'label': 'Jupiter'}, {'id': 6, 'name': 'Saturn', 'color': 'var(--kerykeion-chart-color-saturn)', 'element_points': 10, 'label': 'Saturn'}, {'id': 7, 'name': 'Uranus', 'color': 'var(--kerykeion-chart-color-uranus)', 'element_points': 10, 'label': 'Uranus'}, {'id': 8, 'name': 'Neptune', 'color': 'var(--kerykeion-chart-color-neptune)', 'element_points': 10, 'label': 'Neptune'}, {'id': 9, 'name': 'Pluto', 'color': 'var(--kerykeion-chart-color-pluto)', 'element_points': 10, 'label': 'Pluto'}, {'id': 10, 'name': 'Mean_Node', 'color': 'var(--kerykeion-chart-color-mean-node)', 'element_points': 0, 'label': 'Mean_Node'}, {'id': 11, 'name': 'True_Node', 'color': 'var(--kerykeion-chart-color-true-node)', 'element_points': 0, 'label': 'True_Node'}, {'id': 12, 'name': 'Chiron', 'color': 'var(--kerykeion-chart-color-chiron)', 'element_points': 0, 'label': 'Chiron'}, {'id': 13, 'name': 'Ascendant', 'color': 'var(--kerykeion-chart-color-first-house)', 'element_points': 40, 'label': 'Asc'}, {'id': 14, 'name': 'Medium_Coeli', 'color': 'var(--kerykeion-chart-color-tenth-house)', 'element_points': 20, 'label': 'Mc'}, {'id': 15, 'name': 'Descendant', 'color': 'var(--kerykeion-chart-color-seventh-house)', 'element_points': 0, 'label': 'Dsc'}, {'id': 16, 'name': 'Imum_Coeli', 'color': 'var(--kerykeion-chart-color-fourth-house)', 'element_points': 0, 'label': 'Ic'}, {'id': 17, 'name': 'Mean_Lilith', 'color': 'var(--kerykeion-chart-color-mean-lilith)', 'element_points': 0, 'label': 'Mean_Lilith'}, {'id': 18, 'name': 'Mean_South_Node', 'color': 'var(--kerykeion-chart-color-mean-node)', 'element_points': 0, 'label': 'Mean_South_Node'}, {'id': 19, 'name': 'True_South_Node', 'color': 'var(--kerykeion-chart-color-true-node)', 'element_points': 0, 'label': 'True_South_Node'}, {'id': 20, 'name': 'True_Lilith', 'color': 'var(--kerykeion-chart-color-mean-lilith)', 'element_points': 0, 'label': 'True_Lilith'}, {'id': 21, 'name': 'Earth', 'color': 'var(--kerykeion-chart-color-earth)', 'element_points': 0, 'label': 'Earth'}, {'id': 22, 'name': 'Pholus', 'color': 'var(--kerykeion-chart-color-pholus)', 'element_points': 0, 'label': 'Pholus'}, {'id': 23, 'name': 'Ceres', 'color': 'var(--kerykeion-chart-color-ceres)', 'element_points': 0, 'label': 'Ceres'}, {'id': 24, 'name': 'Pallas', 'color': 'var(--kerykeion-chart-color-pallas)', 'element_points': 0, 'label': 'Pallas'}, {'id': 25, 'name': 'Juno', 'color': 'var(--kerykeion-chart-color-juno)', 'element_points': 0, 'label': 'Juno'}, {'id': 26, 'name': 'Vesta', 'color': 'var(--kerykeion-chart-color-vesta)', 'element_points': 0, 'label': 'Vesta'}, {'id': 27, 'name': 'Eris', 'color': 'var(--kerykeion-chart-color-eris)', 'element_points': 0, 'label': 'Eris'}, {'id': 28, 'name': 'Sedna', 'color': 'var(--kerykeion-chart-color-sedna)', 'element_points': 0, 'label': 'Sedna'}, {'id': 29, 'name': 'Haumea', 'color': 'var(--kerykeion-chart-color-haumea)', 'element_points': 0, 'label': 'Haumea'}, {'id': 30, 'name': 'Makemake', 'color': 'var(--kerykeion-chart-color-makemake)', 'element_points': 0, 'label': 'Makemake'}, {'id': 31, 'name': 'Ixion', 'color': 'var(--kerykeion-chart-color-ixion)', 'element_points': 0, 'label': 'Ixion'}, {'id': 32, 'name': 'Orcus', 'color': 'var(--kerykeion-chart-color-orcus)', 'element_points': 0, 'label': 'Orcus'}, {'id': 33, 'name': 'Quaoar', 'color': 'var(--kerykeion-chart-color-quaoar)', 'element_points': 0, 'label': 'Quaoar'}, {'id': 34, 'name': 'Regulus', 'color': 'var(--kerykeion-chart-color-regulus)', 'element_points': 0, 'label': 'Regulus'}, {'id': 35, 'name': 'Spica', 'color': 'var(--kerykeion-chart-color-spica)', 'element_points': 0, 'label': 'Spica'}, {'id': 36, 'name': 'Pars_Fortunae', 'color': 'var(--kerykeion-chart-color-pars-fortunae)', 'element_points': 5, 'label': 'Fortune'}, {'id': 37, 'name': 'Pars_Spiritus', 'color': 'var(--kerykeion-chart-color-pars-spiritus)', 'element_points': 0, 'label': 'Spirit'}, {'id': 38, 'name': 'Pars_Amoris', 'color': 'var(--kerykeion-chart-color-pars-amoris)', 'element_points': 0, 'label': 'Love'}, {'id': 39, 'name': 'Pars_Fidei', 'color': 'var(--kerykeion-chart-color-pars-fidei)', 'element_points': 0, 'label': 'Faith'}, {'id': 40, 'name': 'Vertex', 'color': 'var(--kerykeion-chart-color-vertex)', 'element_points': 0, 'label': 'Vertex'}, {'id': 41, 'name': 'Anti_Vertex', 'color': 'var(--kerykeion-chart-color-anti-vertex)', 'element_points': 0, 'label': 'Anti_Vertex'}], aspects_settings: list[dict] = [{'degree': 0, 'name': 'conjunction', 'is_major': True, 'color': 'var(--kerykeion-chart-color-conjunction)'}, {'degree': 30, 'name': 'semi-sextile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-semi-sextile)'}, {'degree': 45, 'name': 'semi-square', 'is_major': False, 'color': 'var(--kerykeion-chart-color-semi-square)'}, {'degree': 60, 'name': 'sextile', 'is_major': True, 'color': 'var(--kerykeion-chart-color-sextile)'}, {'degree': 72, 'name': 'quintile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-quintile)'}, {'degree': 90, 'name': 'square', 'is_major': True, 'color': 'var(--kerykeion-chart-color-square)'}, {'degree': 120, 'name': 'trine', 'is_major': True, 'color': 'var(--kerykeion-chart-color-trine)'}, {'degree': 135, 'name': 'sesquiquadrate', 'is_major': False, 'color': 'var(--kerykeion-chart-color-sesquiquadrate)'}, {'degree': 144, 'name': 'biquintile', 'is_major': False, 'color': 'var(--kerykeion-chart-color-biquintile)'}, {'degree': 150, 'name': 'quincunx', 'is_major': False, 'color': 'var(--kerykeion-chart-color-quincunx)'}, {'degree': 180, 'name': 'opposition', 'is_major': True, 'color': 'var(--kerykeion-chart-color-opposition)'}])
202    def __init__(
203        self,
204        first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
205        chart_type: ChartType = "Natal",
206        second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] = None,
207        new_output_directory: Union[str, None] = None,
208        new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None,
209        theme: Union[KerykeionChartTheme, None] = "classic",
210        double_chart_aspect_grid_type: Literal["list", "table"] = "list",
211        chart_language: KerykeionChartLanguage = "EN",
212        active_points: Optional[list[AstrologicalPoint]] = None,
213        active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS,
214        *,
215        transparent_background: bool = False,
216        colors_settings: dict = DEFAULT_CHART_COLORS,
217        celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS,
218        aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS,
219    ):
220        """
221        Initialize the chart generator with subject data and configuration options.
222
223        Args:
224            first_obj (AstrologicalSubjectModel, or CompositeSubjectModel):
225                Primary astrological subject instance.
226            chart_type (ChartType, optional):
227                Type of chart to generate (e.g., 'Natal', 'Transit').
228            second_obj (AstrologicalSubject, optional):
229                Secondary subject for Transit or Synastry charts.
230            new_output_directory (str or Path, optional):
231                Base directory to save generated SVG files.
232            new_settings_file (Path, dict, or KerykeionSettingsModel, optional):
233                Custom settings source for chart colors, fonts, and aspects.
234            theme (KerykeionChartTheme or None, optional):
235                CSS theme to apply; None for default styling.
236            double_chart_aspect_grid_type (Literal['list','table'], optional):
237                Layout style for double-chart aspect grids ('list' or 'table').
238            chart_language (KerykeionChartLanguage, optional):
239                Language code for chart labels (e.g., 'EN', 'IT').
240            active_points (List[AstrologicalPoint], optional):
241                Celestial points to include in the chart visualization.
242            active_aspects (List[ActiveAspect], optional):
243                Aspects to calculate, each defined by name and orb.
244            transparent_background (bool, optional):
245                Whether to use a transparent background instead of the theme color. Defaults to False.
246        """
247        # --------------------
248        # COMMON INITIALIZATION
249        # --------------------
250        home_directory = Path.home()
251        self.new_settings_file = new_settings_file
252        self.chart_language = chart_language
253        self.active_aspects = active_aspects
254        self.chart_type = chart_type
255        self.double_chart_aspect_grid_type = double_chart_aspect_grid_type
256        self.transparent_background = transparent_background
257        self.chart_colors_settings = colors_settings
258        self.planets_settings = celestial_points_settings
259        self.aspects_settings = aspects_settings
260
261        if not active_points:
262            self.active_points = first_obj.active_points
263        else:
264            self.active_points = find_common_active_points(
265                active_points,
266                first_obj.active_points
267            )
268
269        if second_obj:
270            self.active_points = find_common_active_points(
271                self.active_points,
272                second_obj.active_points
273            )
274
275        # Set output directory
276        if new_output_directory:
277            self.output_directory = Path(new_output_directory)
278        else:
279            self.output_directory = home_directory
280
281        # Load settings
282        self.parse_json_settings(new_settings_file)
283
284        # Primary subject
285        self.first_obj = first_obj
286
287        # Default radius for all charts
288        self.main_radius = 240
289
290        # Configure available planets
291        self.available_planets_setting = []
292        for body in self.planets_settings:
293            if body["name"] in self.active_points:
294                body["is_active"] = True
295                self.available_planets_setting.append(body)
296
297        # Set available celestial points
298        available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
299        self.available_kerykeion_celestial_points = []
300        for body in available_celestial_points_names:
301            self.available_kerykeion_celestial_points.append(self.first_obj.get(body))
302
303        # ------------------------
304        # CHART TYPE SPECIFIC SETUP
305        # ------------------------
306
307        if self.chart_type in ["Natal", "ExternalNatal"]:
308            # --- NATAL / EXTERNAL NATAL CHART SETUP ---
309
310            # Validate Subject
311            if not isinstance(self.first_obj, AstrologicalSubjectModel):
312                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
313
314            # Calculate aspects
315            aspects_instance = AspectsFactory.single_chart_aspects(
316                self.first_obj,
317                active_points=self.active_points,
318                active_aspects=active_aspects,
319            )
320            self.aspects_list = aspects_instance.relevant_aspects
321
322            # Screen size
323            self.height = self._DEFAULT_HEIGHT
324            self.width = self._DEFAULT_NATAL_WIDTH
325
326            # Location and coordinates
327            self.location = self.first_obj.city
328            self.geolat = self.first_obj.lat
329            self.geolon = self.first_obj.lng
330
331            # Circle radii
332            if self.chart_type == "ExternalNatal":
333                self.first_circle_radius = 56
334                self.second_circle_radius = 92
335                self.third_circle_radius = 112
336            else:
337                self.first_circle_radius = 0
338                self.second_circle_radius = 36
339                self.third_circle_radius = 120
340
341        elif self.chart_type == "Composite":
342            # --- COMPOSITE CHART SETUP ---
343
344            # Validate Subject
345            if not isinstance(self.first_obj, CompositeSubjectModel):
346                raise KerykeionException("First object must be a CompositeSubjectModel instance.")
347
348            # Calculate aspects
349            self.aspects_list = AspectsFactory.single_chart_aspects(self.first_obj, active_points=self.active_points).relevant_aspects
350
351            # Screen size
352            self.height = self._DEFAULT_HEIGHT
353            self.width = self._DEFAULT_NATAL_WIDTH
354
355            # Location and coordinates (average of both subjects)
356            self.location = ""
357            self.geolat = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2
358            self.geolon = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2
359
360            # Circle radii
361            self.first_circle_radius = 0
362            self.second_circle_radius = 36
363            self.third_circle_radius = 120
364
365        elif self.chart_type == "Transit":
366            # --- TRANSIT CHART SETUP ---
367
368            # Validate Subjects
369            if not second_obj:
370                raise KerykeionException("Second object is required for Transit charts.")
371            if not isinstance(self.first_obj, AstrologicalSubjectModel):
372                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
373            if not isinstance(second_obj, AstrologicalSubjectModel):
374                raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
375
376            # Secondary subject setup
377            self.second_obj = second_obj
378
379            # Calculate aspects (transit to natal)
380            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
381                self.first_obj,
382                self.second_obj,
383                active_points=self.active_points,
384                active_aspects=active_aspects,
385            )
386            self.aspects_list = synastry_aspects_instance.relevant_aspects
387
388            # Secondary subject available points
389            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
390
391            # Screen size
392            self.height = self._DEFAULT_HEIGHT
393            if self.double_chart_aspect_grid_type == "table":
394                self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE
395            else:
396                self.width = self._DEFAULT_FULL_WIDTH
397
398            # Location and coordinates (from transit subject)
399            self.location = self.second_obj.city
400            self.geolat = self.second_obj.lat
401            self.geolon = self.second_obj.lng
402            self.t_name = self.language_settings["transit_name"]
403
404            # Circle radii
405            self.first_circle_radius = 0
406            self.second_circle_radius = 36
407            self.third_circle_radius = 120
408
409        elif self.chart_type == "Synastry":
410            # --- SYNASTRY CHART SETUP ---
411
412            # Validate Subjects
413            if not second_obj:
414                raise KerykeionException("Second object is required for Synastry charts.")
415            if not isinstance(self.first_obj, AstrologicalSubjectModel):
416                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
417            if not isinstance(second_obj, AstrologicalSubjectModel):
418                raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
419
420            # Secondary subject setup
421            self.second_obj = second_obj
422
423            # Calculate aspects (natal to partner)
424            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
425                self.first_obj,
426                self.second_obj,
427                active_points=self.active_points,
428                active_aspects=active_aspects,
429            )
430            self.aspects_list = synastry_aspects_instance.relevant_aspects
431
432            # Secondary subject available points
433            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
434
435            # Screen size
436            self.height = self._DEFAULT_HEIGHT
437            self.width = self._DEFAULT_FULL_WIDTH
438
439            # Location and coordinates (from primary subject)
440            self.location = self.first_obj.city
441            self.geolat = self.first_obj.lat
442            self.geolon = self.first_obj.lng
443
444            # Circle radii
445            self.first_circle_radius = 0
446            self.second_circle_radius = 36
447            self.third_circle_radius = 120
448
449        elif self.chart_type == "Return":
450            # --- RETURN CHART SETUP ---
451
452            # Validate Subjects
453            if not second_obj:
454                raise KerykeionException("Second object is required for Return charts.")
455            if not isinstance(self.first_obj, AstrologicalSubjectModel):
456                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
457            if not isinstance(second_obj, PlanetReturnModel):
458                raise KerykeionException("Second object must be a PlanetReturnModel instance.")
459
460            # Secondary subject setup
461            self.second_obj = second_obj
462
463            # Calculate aspects (natal to return)
464            synastry_aspects_instance = AspectsFactory.dual_chart_aspects(
465                self.first_obj,
466                self.second_obj,
467                active_points=self.active_points,
468                active_aspects=active_aspects,
469            )
470            self.aspects_list = synastry_aspects_instance.relevant_aspects
471
472            # Secondary subject available points
473            self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points
474
475            # Screen size
476            self.height = self._DEFAULT_HEIGHT
477            self.width = self._DEFAULT_ULTRA_WIDE_WIDTH
478
479            # Location and coordinates (from natal subject)
480            self.location = self.first_obj.city
481            self.geolat = self.first_obj.lat
482            self.geolon = self.first_obj.lng
483
484            # Circle radii
485            self.first_circle_radius = 0
486            self.second_circle_radius = 36
487            self.third_circle_radius = 120
488
489        elif self.chart_type == "SingleWheelReturn":
490            # --- NATAL / EXTERNAL NATAL CHART SETUP ---
491
492            # Validate Subject
493            if not isinstance(self.first_obj, PlanetReturnModel):
494                raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.")
495
496            # Calculate aspects
497            aspects_instance = AspectsFactory.single_chart_aspects(
498                self.first_obj,
499                active_points=self.active_points,
500                active_aspects=active_aspects,
501            )
502            self.aspects_list = aspects_instance.relevant_aspects
503
504            # Screen size
505            self.height = self._DEFAULT_HEIGHT
506            self.width = self._DEFAULT_NATAL_WIDTH
507
508            # Location and coordinates
509            self.location = self.first_obj.city
510            self.geolat = self.first_obj.lat
511            self.geolon = self.first_obj.lng
512
513            # Circle radii
514            if self.chart_type == "ExternalNatal":
515                self.first_circle_radius = 56
516                self.second_circle_radius = 92
517                self.third_circle_radius = 112
518            else:
519                self.first_circle_radius = 0
520                self.second_circle_radius = 36
521                self.third_circle_radius = 120
522
523        # --------------------
524        # FINAL COMMON SETUP
525        # --------------------
526
527        # Calculate element points
528        celestial_points_names = [body["name"].lower() for body in self.available_planets_setting]
529        if self.chart_type == "Synastry":
530            element_totals = calculate_synastry_element_points(
531                self.available_planets_setting,
532                celestial_points_names,
533                self.first_obj,
534                self.second_obj,
535            )
536        else:
537            element_totals = calculate_element_points(
538                self.available_planets_setting,
539                celestial_points_names,
540                self.first_obj,
541            )
542
543        self.fire = element_totals["fire"]
544        self.earth = element_totals["earth"]
545        self.air = element_totals["air"]
546        self.water = element_totals["water"]
547
548        # Calculate qualities points
549        if self.chart_type == "Synastry":
550            qualities_totals = calculate_synastry_quality_points(
551                self.available_planets_setting,
552                celestial_points_names,
553                self.first_obj,
554                self.second_obj,
555            )
556        else:
557            qualities_totals = calculate_quality_points(
558                self.available_planets_setting,
559                celestial_points_names,
560                self.first_obj,
561            )
562
563        self.cardinal = qualities_totals["cardinal"]
564        self.fixed = qualities_totals["fixed"]
565        self.mutable = qualities_totals["mutable"]
566
567        # Set up theme
568        if theme not in get_args(KerykeionChartTheme) and theme is not None:
569            raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.")
570
571        self.set_up_theme(theme)

Initialize the chart generator with subject data and configuration options.

Args: first_obj (AstrologicalSubjectModel, or CompositeSubjectModel): Primary astrological subject instance. chart_type (ChartType, optional): Type of chart to generate (e.g., 'Natal', 'Transit'). second_obj (AstrologicalSubject, optional): Secondary subject for Transit or Synastry charts. new_output_directory (str or Path, optional): Base directory to save generated SVG files. new_settings_file (Path, dict, or KerykeionSettingsModel, optional): Custom settings source for chart colors, fonts, and aspects. 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'). active_points (List[AstrologicalPoint], optional): Celestial points to include in the chart visualization. active_aspects (List[ActiveAspect], optional): Aspects to calculate, each defined by name and orb. transparent_background (bool, optional): Whether to use a transparent background instead of the theme color. Defaults to False.

first_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, kerykeion.schemas.kr_models.CompositeSubjectModel, PlanetReturnModel]
second_obj: Union[kerykeion.schemas.kr_models.AstrologicalSubjectModel, PlanetReturnModel, NoneType]
chart_type: Literal['Natal', 'ExternalNatal', 'Synastry', 'Transit', 'Composite', 'Return', 'SingleWheelReturn']
new_output_directory: Optional[pathlib._local.Path]
new_settings_file: Union[pathlib._local.Path, NoneType, KerykeionSettingsModel, dict]
output_directory: pathlib._local.Path
theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry']]
double_chart_aspect_grid_type: Literal['list', 'table']
chart_language: Literal['EN', 'FR', 'PT', 'IT', 'CN', 'ES', 'RU', 'TR', 'DE', 'HI']
active_points: List[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']]
active_aspects: List[kerykeion.schemas.kr_models.ActiveAspect]
transparent_background: bool
fire: float
earth: float
air: float
water: float
first_circle_radius: float
second_circle_radius: float
third_circle_radius: float
width: Union[float, int]
language_settings: dict
chart_colors_settings: dict
planets_settings: dict
aspects_settings: dict
available_planets_setting: List[kerykeion.schemas.settings_models.KerykeionSettingsCelestialPointModel]
height: float
location: str
geolat: float
geolon: float
template: str
main_radius
available_kerykeion_celestial_points
cardinal
fixed
mutable
def set_up_theme( self, theme: Optional[Literal['light', 'dark', 'dark-high-contrast', 'classic', 'strawberry']] = None) -> None:
573    def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None:
574        """
575        Load and apply a CSS theme for the chart visualization.
576
577        Args:
578            theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied.
579        """
580        if theme is None:
581            self.color_style_tag = ""
582            return
583
584        theme_dir = Path(__file__).parent / "themes"
585
586        with open(theme_dir / f"{theme}.css", "r") as f:
587            self.color_style_tag = f.read()

Load and apply a CSS theme for the chart visualization.

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

def set_output_directory(self, dir_path: pathlib._local.Path) -> None:
589    def set_output_directory(self, dir_path: Path) -> None:
590        """
591        Set the directory where generated SVG files will be saved.
592
593        Args:
594            dir_path (Path): Target directory for SVG output.
595        """
596        self.output_directory = dir_path
597        logging.info(f"Output directory set to: {self.output_directory}")

Set the directory where generated SVG files will be saved.

Args: dir_path (Path): Target directory for SVG output.

def parse_json_settings( self, settings_file_or_dict: Union[pathlib._local.Path, dict, KerykeionSettingsModel, NoneType]) -> None:
599    def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None:
600        """
601        Load and parse chart configuration settings.
602
603        Args:
604            settings_file_or_dict (Path, dict, or KerykeionSettingsModel):
605                Source for custom chart settings.
606        """
607        settings = get_settings(settings_file_or_dict)
608
609        self.language_settings = settings["language_settings"][self.chart_language]

Load and parse chart configuration settings.

Args: settings_file_or_dict (Path, dict, or KerykeionSettingsModel): Source for custom chart settings.

def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
1706    def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str:
1707        """
1708        Render the full chart SVG as a string.
1709
1710        Reads the XML template, substitutes variables, and optionally inlines CSS
1711        variables and minifies the output.
1712
1713        Args:
1714            minify (bool): Remove whitespace and quotes for compactness.
1715            remove_css_variables (bool): Embed CSS variable definitions.
1716
1717        Returns:
1718            str: SVG markup as a string.
1719        """
1720        td = self._create_template_dictionary()
1721
1722        DATA_DIR = Path(__file__).parent
1723        xml_svg = DATA_DIR / "templates" / "chart.xml"
1724
1725        # read template
1726        with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f:
1727            template = Template(f.read()).substitute(td)
1728
1729        # return filename
1730
1731        logging.debug(f"Template dictionary keys: {td.keys()}")
1732
1733        self._create_template_dictionary()
1734
1735        if remove_css_variables:
1736            template = inline_css_variables_in_svg(template)
1737
1738        if minify:
1739            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1740
1741        else:
1742            template = template.replace('"', "'")
1743
1744        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.

Returns: str: SVG markup as a string.

def makeSVG(self, minify: bool = False, remove_css_variables=False):
1746    def makeSVG(self, minify: bool = False, remove_css_variables=False):
1747        """
1748        Generate and save the full chart SVG to disk.
1749
1750        Calls makeTemplate to render the SVG, then writes a file named
1751        "{subject.name} - {chart_type} Chart.svg" in the output directory.
1752
1753        Args:
1754            minify (bool): Pass-through to makeTemplate for compact output.
1755            remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.
1756
1757        Returns:
1758            None
1759        """
1760
1761        self.template = self.makeTemplate(minify, remove_css_variables)
1762
1763        if self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar":
1764            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Lunar Return.svg"
1765        elif self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar":
1766            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Solar Return.svg"
1767        else:
1768            chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart.svg"
1769
1770        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1771            output_file.write(self.template)
1772
1773        print(f"SVG Generated Correctly in: {chartname}")

Generate and save the full chart SVG to disk.

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

Args: minify (bool): Pass-through to makeTemplate for compact output. remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables.

Returns: None

def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1775    def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1776        """
1777        Render the wheel-only chart SVG as a string.
1778
1779        Reads the wheel-only XML template, substitutes chart data, and applies optional
1780        CSS inlining and minification.
1781
1782        Args:
1783            minify (bool): Remove whitespace and quotes for compactness.
1784            remove_css_variables (bool): Embed CSS variable definitions.
1785
1786        Returns:
1787            str: SVG markup for the chart wheel only.
1788        """
1789
1790        with open(
1791            Path(__file__).parent / "templates" / "wheel_only.xml",
1792            "r",
1793            encoding="utf-8",
1794            errors="ignore",
1795        ) as f:
1796            template = f.read()
1797
1798        template_dict = self._create_template_dictionary()
1799        template = Template(template).substitute(template_dict)
1800
1801        if remove_css_variables:
1802            template = inline_css_variables_in_svg(template)
1803
1804        if minify:
1805            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1806
1807        else:
1808            template = template.replace('"', "'")
1809
1810        return template

Render the wheel-only chart SVG as a string.

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

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

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

def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
1812    def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False):
1813        """
1814        Generate and save wheel-only chart SVG to disk.
1815
1816        Calls makeWheelOnlyTemplate and writes a file named
1817        "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory.
1818
1819        Args:
1820            minify (bool): Pass-through to makeWheelOnlyTemplate for compact output.
1821            remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.
1822
1823        Returns:
1824            None
1825        """
1826
1827        template = self.makeWheelOnlyTemplate(minify, remove_css_variables)
1828        chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Wheel Only.svg"
1829
1830        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1831            output_file.write(template)
1832
1833        print(f"SVG Generated Correctly in: {chartname}")

Generate and save wheel-only chart SVG to disk.

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

Args: minify (bool): Pass-through to makeWheelOnlyTemplate for compact output. remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables.

Returns: None

def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1835    def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False):
1836        """
1837        Render the aspect-grid-only chart SVG as a string.
1838
1839        Reads the aspect-grid XML template, generates the aspect grid based on chart type,
1840        and applies optional CSS inlining and minification.
1841
1842        Args:
1843            minify (bool): Remove whitespace and quotes for compactness.
1844            remove_css_variables (bool): Embed CSS variable definitions.
1845
1846        Returns:
1847            str: SVG markup for the aspect grid only.
1848        """
1849
1850        with open(
1851            Path(__file__).parent / "templates" / "aspect_grid_only.xml",
1852            "r",
1853            encoding="utf-8",
1854            errors="ignore",
1855        ) as f:
1856            template = f.read()
1857
1858        template_dict = self._create_template_dictionary()
1859
1860        if self.chart_type in ["Transit", "Synastry", "Return"]:
1861            aspects_grid = draw_transit_aspect_grid(
1862                self.chart_colors_settings["paper_0"],
1863                self.available_planets_setting,
1864                self.aspects_list,
1865            )
1866        else:
1867            aspects_grid = draw_aspect_grid(
1868                self.chart_colors_settings["paper_0"],
1869                self.available_planets_setting,
1870                self.aspects_list,
1871                x_start=50,
1872                y_start=250,
1873            )
1874
1875        template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid})
1876
1877        if remove_css_variables:
1878            template = inline_css_variables_in_svg(template)
1879
1880        if minify:
1881            template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace("    ", "").replace("  ", "")
1882
1883        else:
1884            template = template.replace('"', "'")
1885
1886        return template

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

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

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

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

def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
1888    def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False):
1889        """
1890        Generate and save aspect-grid-only chart SVG to disk.
1891
1892        Calls makeAspectGridOnlyTemplate and writes a file named
1893        "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory.
1894
1895        Args:
1896            minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output.
1897            remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.
1898
1899        Returns:
1900            None
1901        """
1902
1903        template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables)
1904        chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Aspect Grid Only.svg"
1905
1906        with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file:
1907            output_file.write(template)
1908
1909        print(f"SVG Generated Correctly in: {chartname}")

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

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

Args: minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output. remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables.

Returns: None

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

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

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

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

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

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

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

Example:

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

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

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

Initialize the composite subject factory with two astrological subjects.

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

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

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

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

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

Generate the complete composite chart model using the midpoint technique.

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

The process includes:

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

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

Example:

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

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

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

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

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

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

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

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

Examples: Create daily ephemeris data for a month:

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

Create hourly data for a specific location:

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

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

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

Generate ephemeris data for the specified date range.

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

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

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

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

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

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

Examples: Basic usage with dictionary output:

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

Using model instances for type safety:

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

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

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

Generate ephemeris data as complete AstrologicalSubject instances.

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

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

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

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

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

Examples: Basic usage for accessing individual chart features:

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

Using model instances for serialization:

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

Batch processing for analysis:

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

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

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

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

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

Factory for creating house comparison analyses between two astrological subjects.

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

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

Example:

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

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

Initialize the house comparison factory.

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

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

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

Generate bidirectional house comparison analysis between the two subjects.

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

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

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

class HouseComparisonModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
56class HouseComparisonModel(SubscriptableBaseModel):
57    """
58    Bidirectional house comparison analysis between two astrological subjects.
59
60    Contains results of how astrological points from each subject interact with
61    the house system of the other subject.
62
63    Attributes:
64        first_subject_name: Name of the first subject
65        second_subject_name: Name of the second subject
66        first_points_in_second_houses: First subject's points in second subject's houses
67        second_points_in_first_houses: Second subject's points in first subject's houses
68    """
69
70    first_subject_name: str
71    """Name of the first subject"""
72    second_subject_name: str
73    """Name of the second subject"""
74    first_points_in_second_houses: list[PointInHouseModel]
75    """First subject's points positioned in second subject's houses"""
76    second_points_in_first_houses: list[PointInHouseModel]
77    """Second subject's points positioned in first subject's houses"""

Bidirectional house comparison analysis between two astrological subjects.

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

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

first_subject_name: str

Name of the first subject

second_subject_name: str

Name of the second subject

first_points_in_second_houses: list[kerykeion.house_comparison.house_comparison_models.PointInHouseModel]

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

second_points_in_first_houses: list[kerykeion.house_comparison.house_comparison_models.PointInHouseModel]

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

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

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

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

A factory class for calculating and generating planetary return charts.

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

Planetary returns are fundamental concepts in predictive astrology:

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

The factory handles complex astronomical calculations automatically, including:

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

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

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

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

Examples: Online mode with automatic location lookup:

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

Offline mode with manual coordinates:

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

Different location for return chart:

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

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

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

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

Initialize a PlanetaryReturnFactory instance with location and configuration settings.

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

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

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

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

Examples: Initialize with online geocoding:

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

Initialize with manual coordinates:

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

Initialize with mixed parameters (coordinates override online lookup):

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

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

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

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

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

The calculation process:

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

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

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

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

Examples: Calculate next Solar Return after a specific date:

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

Calculate next Lunar Return with timezone:

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

Access complete chart data from return:

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

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

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

See Also: next_return_from_year(): Simplified interface for yearly calculations next_return_from_month_and_year(): Monthly calculation interface

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

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_month_and_year(): For more specific monthly searches next_return_from_iso_formatted_time(): For custom starting dates

def next_return_from_month_and_year( self, year: int, month: int, return_type: Literal['Lunar', 'Solar']) -> PlanetReturnModel:
620    def next_return_from_month_and_year(
621        self,
622        year: int,
623        month: int,
624        return_type: ReturnType
625    ) -> PlanetReturnModel:
626        """
627        Calculate the first planetary return occurring in or after a specified month and year.
628
629        This method provides precise timing control for planetary return calculations by
630        searching from the first day of a specific month and year. It's particularly
631        useful for finding Lunar Returns in specific months or for Solar Return timing
632        when you need to focus on a particular time period within a year.
633
634        The method searches from the first moment (00:00:00 UTC) of the specified month
635        and year, finding the next return that occurs from that point forward. This is
636        especially valuable for Lunar Return work, where multiple returns occur per year
637        and you need to isolate specific monthly periods.
638
639        Args:
640            year (int): The calendar year to search within. Must be a valid year
641                within the ephemeris data range (typically 1800-2200).
642                Examples: 2024, 2025, 1990.
643            month (int): The month to start the search from. Must be between 1 and 12,
644                where 1=January, 2=February, ..., 12=December.
645            return_type (ReturnType): The type of planetary return to calculate.
646                Must be either "Solar" for Sun returns or "Lunar" for Moon returns.
647
648        Returns:
649            PlanetReturnModel: Comprehensive return chart data for the first return
650                found on or after the first day of the specified month and year.
651                Contains complete astrological chart information including:
652                - Precise return datetime in UTC and local timezone
653                - All planetary positions at the return moment
654                - House cusps for the factory's configured location
655                - Complete astrological subject data with all calculated features
656                - Return type identifier and naming information
657
658        Raises:
659            KerykeionException: If month is not between 1 and 12.
660            KerykeionException: If return_type is not "Solar" or "Lunar".
661            ValueError: If year is outside valid ephemeris calculation range.
662            SwissEphException: If astronomical calculations fail.
663
664        Examples:
665            Find Solar Return in birth month:
666
667            >>> factory = PlanetaryReturnFactory(subject, ...)
668            >>> # Subject born in June, find 2024 Solar Return in June
669            >>> solar_return = factory.next_return_from_month_and_year(
670            ...     2024, 6, "Solar"
671            ... )
672            >>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")
673
674            Find specific Lunar Return:
675
676            >>> # Find first Lunar Return in March 2024
677            >>> lunar_return = factory.next_return_from_month_and_year(
678            ...     2024, 3, "Lunar"
679            ... )
680            >>> print(f"March 2024 Lunar Return: {lunar_return.iso_formatted_local_datetime}")
681
682            Monthly Lunar Return tracking:
683
684            >>> lunar_returns_2024 = []
685            >>> for month in range(1, 13):
686            ...     lunar_return = factory.next_return_from_month_and_year(
687            ...         2024, month, "Lunar"
688            ...     )
689            ...     lunar_returns_2024.append(lunar_return)
690            ...     print(f"Month {month}: {lunar_return.iso_formatted_local_datetime}")
691
692            Seasonal analysis:
693
694            >>> # Spring Solar Return (if birthday is in spring)
695            >>> spring_return = factory.next_return_from_month_and_year(
696            ...     2024, 3, "Solar"
697            ... )
698            >>> # Compare with autumn energy
699            >>> autumn_lunar = factory.next_return_from_month_and_year(
700            ...     2024, 9, "Lunar"
701            ... )
702
703        Practical Applications:
704            - Monthly Lunar Return consultation scheduling
705            - Seasonal astrological analysis and timing
706            - Comparative study of returns across different months
707            - Precise timing for astrological interventions
708            - Educational demonstrations of monthly astrological cycles
709            - Research into seasonal patterns in planetary returns
710
711        Technical Notes:
712            - Search begins at 00:00:00 UTC on the 1st day of the specified month
713            - For Solar Returns, may find the return in a subsequent month if
714              the birthday falls late in the specified month of the previous year
715            - Lunar Returns typically occur within the specified month due to
716              their ~27-day cycle
717            - Month validation prevents common input errors
718            - All calculations maintain second-level precision
719
720        Timing Considerations:
721            - Solar Returns: Usually occur within 1-2 days of the natal birthday
722            - Lunar Returns: Occur approximately every 27.3 days
723            - The method finds the chronologically first return from the start date
724            - Timezone differences can affect which calendar day the return occurs
725
726        Use Cases:
727            - Monthly return chart consultations
728            - Timing specific astrological work or rituals
729            - Research into monthly astrological patterns
730            - Educational calendar planning for astrological courses
731            - Comparative return chart analysis
732
733        See Also:
734            next_return_from_year(): For annual return calculations
735            next_return_from_iso_formatted_time(): For custom date searches
736        """
737        # Validate month input
738        if month < 1 or month > 12:
739            raise KerykeionException(f"Invalid month {month}. Month must be between 1 and 12.")
740
741        # Create datetime for the first day of the specified month and year (UTC)
742        start_date = datetime(year, month, 1, 0, 0, tzinfo=timezone.utc)
743
744        # Get the return using the existing method
745        return self.next_return_from_iso_formatted_time(
746            start_date.isoformat(),
747            return_type
748        )

Calculate the first planetary return occurring in or after a specified month and year.

This method provides precise timing control for planetary return calculations by searching from the first day of a specific month and year. It's particularly useful for finding Lunar Returns in specific months or for Solar Return timing when you need to focus on a particular time period within a year.

The method searches from the first moment (00:00:00 UTC) of the specified month and year, finding the next return that occurs from that point forward. This is especially valuable for Lunar Return work, where multiple returns occur per year and you need to isolate specific monthly periods.

Args: year (int): The calendar year to search within. Must be a valid year within the ephemeris data range (typically 1800-2200). Examples: 2024, 2025, 1990. month (int): The month to start the search from. Must be between 1 and 12, where 1=January, 2=February, ..., 12=December. 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 first day of the specified month and year. Contains complete astrological chart information including: - Precise return datetime in UTC and local timezone - All planetary positions at the return moment - House cusps for the factory's configured location - Complete astrological subject data with all calculated features - Return type identifier and naming information

Raises: KerykeionException: If month is not between 1 and 12. KerykeionException: If return_type is not "Solar" or "Lunar". ValueError: If year is outside valid ephemeris calculation range. SwissEphException: If astronomical calculations fail.

Examples: Find Solar Return in birth month:

>>> factory = PlanetaryReturnFactory(subject, ...)
>>> # Subject born in June, find 2024 Solar Return in June
>>> solar_return = factory.next_return_from_month_and_year(
...     2024, 6, "Solar"
... )
>>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")

Find specific Lunar Return:

>>> # Find first Lunar Return in March 2024
>>> lunar_return = factory.next_return_from_month_and_year(
...     2024, 3, "Lunar"
... )
>>> print(f"March 2024 Lunar Return: {lunar_return.iso_formatted_local_datetime}")

Monthly Lunar Return tracking:

>>> lunar_returns_2024 = []
>>> for month in range(1, 13):
...     lunar_return = factory.next_return_from_month_and_year(
...         2024, month, "Lunar"
...     )
...     lunar_returns_2024.append(lunar_return)
...     print(f"Month {month}: {lunar_return.iso_formatted_local_datetime}")

Seasonal analysis:

>>> # Spring Solar Return (if birthday is in spring)
>>> spring_return = factory.next_return_from_month_and_year(
...     2024, 3, "Solar"
... )
>>> # Compare with autumn energy
>>> autumn_lunar = factory.next_return_from_month_and_year(
...     2024, 9, "Lunar"
... )

Practical Applications: - Monthly Lunar Return consultation scheduling - Seasonal astrological analysis and timing - Comparative study of returns across different months - Precise timing for astrological interventions - Educational demonstrations of monthly astrological cycles - Research into seasonal patterns in planetary returns

Technical Notes: - Search begins at 00:00:00 UTC on the 1st day of the specified month - For Solar Returns, may find the return in a subsequent month if the birthday falls late in the specified month of the previous year - Lunar Returns typically occur within the specified month due to their ~27-day cycle - Month validation prevents common input errors - All calculations maintain second-level precision

Timing Considerations: - Solar Returns: Usually occur within 1-2 days of the natal birthday - Lunar Returns: Occur approximately every 27.3 days - The method finds the chronologically first return from the start date - Timezone differences can affect which calendar day the return occurs

Use Cases: - Monthly return chart consultations - Timing specific astrological work or rituals - Research into monthly astrological patterns - Educational calendar planning for astrological courses - Comparative return chart analysis

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

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

Pydantic Model for Planet Return

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

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

class RelationshipScoreFactory:
 62class RelationshipScoreFactory:
 63    """
 64    Calculates relationship scores between two subjects using the Ciro Discepolo method.
 65
 66    The scoring system evaluates synastry aspects between planetary positions to generate
 67    numerical compatibility scores with categorical descriptions.
 68
 69    Score Ranges:
 70        - 0-5: Minimal relationship
 71        - 5-10: Medium relationship
 72        - 10-15: Important relationship
 73        - 15-20: Very important relationship
 74        - 20-30: Exceptional relationship
 75        - 30+: Rare exceptional relationship
 76
 77    Args:
 78        first_subject (AstrologicalSubjectModel): First astrological subject
 79        second_subject (AstrologicalSubjectModel): Second astrological subject
 80        use_only_major_aspects (bool, optional): Filter to major aspects only. Defaults to True.
 81
 82    Reference:
 83        http://www.cirodiscepolo.it/Articoli/Discepoloele.htm
 84    """
 85
 86    SCORE_MAPPING = [
 87        ("Minimal", 5),
 88        ("Medium", 10),
 89        ("Important", 15),
 90        ("Very Important", 20),
 91        ("Exceptional", 30),
 92        ("Rare Exceptional", float("inf")),
 93    ]
 94
 95    MAJOR_ASPECTS = {"conjunction", "opposition", "square", "trine", "sextile"}
 96
 97    def __init__(
 98        self,
 99        first_subject: AstrologicalSubjectModel,
100        second_subject: AstrologicalSubjectModel,
101        use_only_major_aspects: bool = True,
102    ):
103        self.use_only_major_aspects = use_only_major_aspects
104        self.first_subject: AstrologicalSubjectModel = first_subject
105        self.second_subject: AstrologicalSubjectModel = second_subject
106
107        self.score_value = 0
108        self.relationship_score_description: RelationshipScoreDescription = "Minimal"
109        self.is_destiny_sign = True
110        self.relationship_score_aspects: list[RelationshipScoreAspectModel] = []
111        self._synastry_aspects = AspectsFactory.dual_chart_aspects(self.first_subject, self.second_subject).all_aspects
112
113    def _evaluate_destiny_sign(self):
114        """
115        Checks if subjects share the same sun sign quality and adds points.
116
117        Adds 5 points if both subjects have sun signs with matching quality
118        (cardinal, fixed, or mutable).
119        """
120        if self.first_subject.sun["quality"] == self.second_subject.sun["quality"]: # type: ignore
121            self.is_destiny_sign = True
122            self.score_value += DESTINY_SIGN_POINTS
123            logging.debug(f"Destiny sign found, adding {DESTINY_SIGN_POINTS} points, total score: {self.score_value}")
124
125    def _evaluate_aspect(self, aspect, points):
126        """
127        Processes an aspect and adds points to the total score.
128
129        Args:
130            aspect (dict): Aspect data containing planetary positions and geometry
131            points (int): Points to add to the total score
132        """
133        if self.use_only_major_aspects and aspect["aspect"] not in self.MAJOR_ASPECTS:
134            return
135
136        self.score_value += points
137        self.relationship_score_aspects.append(
138            RelationshipScoreAspectModel(
139                p1_name=aspect["p1_name"],
140                p2_name=aspect["p2_name"],
141                aspect=aspect["aspect"],
142                orbit=aspect["orbit"],
143            )
144        )
145        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)}")
146
147    def _evaluate_sun_sun_main_aspect(self, aspect):
148        """
149        Evaluates Sun-Sun conjunction, opposition, or square aspects.
150
151        Adds 8 points for standard orbs, 11 points for tight orbs (≤2°).
152
153        Args:
154            aspect (dict): Aspect data
155        """
156        if aspect["p1_name"] == "Sun" and aspect["p2_name"] == "Sun" and aspect["aspect"] in {"conjunction", "opposition", "square"}:
157            points = MAJOR_ASPECT_POINTS_HIGH_PRECISION if aspect["orbit"] <= HIGH_PRECISION_ORBIT_THRESHOLD else MAJOR_ASPECT_POINTS_STANDARD
158            self._evaluate_aspect(aspect, points)
159
160    def _evaluate_sun_moon_conjunction(self, aspect):
161        """
162        Evaluates Sun-Moon conjunction aspects.
163
164        Adds 8 points for standard orbs, 11 points for tight orbs (≤2°).
165
166        Args:
167            aspect (dict): Aspect data
168        """
169        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Sun"} and aspect["aspect"] == "conjunction":
170            points = MAJOR_ASPECT_POINTS_HIGH_PRECISION if aspect["orbit"] <= HIGH_PRECISION_ORBIT_THRESHOLD else MAJOR_ASPECT_POINTS_STANDARD
171            self._evaluate_aspect(aspect, points)
172
173    def _evaluate_sun_sun_other_aspects(self, aspect):
174        """
175        Evaluates Sun-Sun aspects other than conjunction, opposition, or square.
176
177        Adds 4 points for any qualifying aspect.
178
179        Args:
180            aspect (dict): Aspect data
181        """
182        if aspect["p1_name"] == "Sun" and aspect["p2_name"] == "Sun" and aspect["aspect"] not in {"conjunction", "opposition", "square"}:
183            points = MINOR_ASPECT_POINTS
184            self._evaluate_aspect(aspect, points)
185
186    def _evaluate_sun_moon_other_aspects(self, aspect):
187        """
188        Evaluates Sun-Moon aspects other than conjunctions.
189
190        Adds 4 points for any qualifying aspect.
191
192        Args:
193            aspect (dict): Aspect data
194        """
195        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Sun"} and aspect["aspect"] != "conjunction":
196            points = MINOR_ASPECT_POINTS
197            self._evaluate_aspect(aspect, points)
198
199    def _evaluate_sun_ascendant_aspect(self, aspect):
200        """
201        Evaluates Sun-Ascendant aspects.
202
203        Adds 4 points for any aspect between Sun and Ascendant.
204
205        Args:
206            aspect (dict): Aspect data
207        """
208        if {aspect["p1_name"], aspect["p2_name"]} == {"Sun", "Ascendant"}:
209            points = SUN_ASCENDANT_ASPECT_POINTS
210            self._evaluate_aspect(aspect, points)
211
212    def _evaluate_moon_ascendant_aspect(self, aspect):
213        """
214        Evaluates Moon-Ascendant aspects.
215
216        Adds 4 points for any aspect between Moon and Ascendant.
217
218        Args:
219            aspect (dict): Aspect data
220        """
221        if {aspect["p1_name"], aspect["p2_name"]} == {"Moon", "Ascendant"}:
222            points = MOON_ASCENDANT_ASPECT_POINTS
223            self._evaluate_aspect(aspect, points)
224
225    def _evaluate_venus_mars_aspect(self, aspect):
226        """
227        Evaluates Venus-Mars aspects.
228
229        Adds 4 points for any aspect between Venus and Mars.
230
231        Args:
232            aspect (dict): Aspect data
233        """
234        if {aspect["p1_name"], aspect["p2_name"]} == {"Venus", "Mars"}:
235            points = VENUS_MARS_ASPECT_POINTS
236            self._evaluate_aspect(aspect, points)
237
238    def _evaluate_relationship_score_description(self):
239        """
240        Determines the categorical description based on the numerical score.
241
242        Maps the total score to predefined description ranges.
243        """
244        for description, threshold in self.SCORE_MAPPING:
245            if self.score_value < threshold:
246                self.relationship_score_description = description
247                break
248
249    def get_relationship_score(self):
250        """
251        Calculates the complete relationship score using all evaluation methods.
252
253        Returns:
254            RelationshipScoreModel: Score object containing numerical value, description,
255                destiny sign status, contributing aspects, and subject data.
256        """
257        self._evaluate_destiny_sign()
258
259        for aspect in self._synastry_aspects:
260            self._evaluate_sun_sun_main_aspect(aspect)
261            self._evaluate_sun_moon_conjunction(aspect)
262            self._evaluate_sun_moon_other_aspects(aspect)
263            self._evaluate_sun_sun_other_aspects(aspect)
264            self._evaluate_sun_ascendant_aspect(aspect)
265            self._evaluate_moon_ascendant_aspect(aspect)
266            self._evaluate_venus_mars_aspect(aspect)
267
268        self._evaluate_relationship_score_description()
269
270        return RelationshipScoreModel(
271            score_value=self.score_value,
272            score_description=self.relationship_score_description,
273            is_destiny_sign=self.is_destiny_sign,
274            aspects=self.relationship_score_aspects,
275            subjects=[self.first_subject, self.second_subject],
276        )

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.

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

RelationshipScoreFactory( first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel, use_only_major_aspects: bool = True)
 97    def __init__(
 98        self,
 99        first_subject: AstrologicalSubjectModel,
100        second_subject: AstrologicalSubjectModel,
101        use_only_major_aspects: bool = True,
102    ):
103        self.use_only_major_aspects = use_only_major_aspects
104        self.first_subject: AstrologicalSubjectModel = first_subject
105        self.second_subject: AstrologicalSubjectModel = second_subject
106
107        self.score_value = 0
108        self.relationship_score_description: RelationshipScoreDescription = "Minimal"
109        self.is_destiny_sign = True
110        self.relationship_score_aspects: list[RelationshipScoreAspectModel] = []
111        self._synastry_aspects = AspectsFactory.dual_chart_aspects(self.first_subject, self.second_subject).all_aspects
SCORE_MAPPING = [('Minimal', 5), ('Medium', 10), ('Important', 15), ('Very Important', 20), ('Exceptional', 30), ('Rare Exceptional', inf)]
MAJOR_ASPECTS = {'square', 'trine', 'opposition', 'conjunction', 'sextile'}
use_only_major_aspects
first_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
second_subject: kerykeion.schemas.kr_models.AstrologicalSubjectModel
score_value
relationship_score_description: Literal['Minimal', 'Medium', 'Important', 'Very Important', 'Exceptional', 'Rare Exceptional']
is_destiny_sign
relationship_score_aspects: list[kerykeion.schemas.kr_models.RelationshipScoreAspectModel]
def get_relationship_score(self):
249    def get_relationship_score(self):
250        """
251        Calculates the complete relationship score using all evaluation methods.
252
253        Returns:
254            RelationshipScoreModel: Score object containing numerical value, description,
255                destiny sign status, contributing aspects, and subject data.
256        """
257        self._evaluate_destiny_sign()
258
259        for aspect in self._synastry_aspects:
260            self._evaluate_sun_sun_main_aspect(aspect)
261            self._evaluate_sun_moon_conjunction(aspect)
262            self._evaluate_sun_moon_other_aspects(aspect)
263            self._evaluate_sun_sun_other_aspects(aspect)
264            self._evaluate_sun_ascendant_aspect(aspect)
265            self._evaluate_moon_ascendant_aspect(aspect)
266            self._evaluate_venus_mars_aspect(aspect)
267
268        self._evaluate_relationship_score_description()
269
270        return RelationshipScoreModel(
271            score_value=self.score_value,
272            score_description=self.relationship_score_description,
273            is_destiny_sign=self.is_destiny_sign,
274            aspects=self.relationship_score_aspects,
275            subjects=[self.first_subject, self.second_subject],
276        )

Calculates the complete relationship score using all evaluation methods.

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

class ReportGenerator:
 7class ReportGenerator:
 8    """
 9    Create a report for a Kerykeion instance.
10    """
11
12    report_title: str
13    data_table: str
14    planets_table: str
15    houses_table: str
16
17    def __init__(self, instance: AstrologicalSubjectModel):
18        """
19        Initialize a new ReportGenerator instance.
20
21        Args:
22            instance: The astrological subject model to create a report for.
23        """
24        self.instance = instance
25
26        self.get_report_title()
27        self.get_data_table()
28        self.get_planets_table()
29        self.get_houses_table()
30
31    def get_report_title(self) -> None:
32        """Generate the report title based on the subject's name."""
33        self.report_title = f"\n+- Kerykeion report for {self.instance.name} -+"
34
35    def get_data_table(self) -> None:
36        """
37        Creates the data table of the report.
38        """
39
40        main_data = [["Date", "Time", "Location", "Longitude", "Latitude"]] + [
41            [
42                f"{self.instance.day}/{self.instance.month}/{self.instance.year}",
43                f"{self.instance.hour}:{self.instance.minute}",
44                f"{self.instance.city}, {self.instance.nation}",
45                self.instance.lng,
46                self.instance.lat,
47            ]
48        ]
49        self.data_table = AsciiTable(main_data).table
50
51    def get_planets_table(self) -> None:
52        """
53        Creates the planets table.
54        """
55
56        planets_data = [["AstrologicalPoint", "Sign", "Pos.", "Ret.", "House"]] + [
57            [
58                planet.name,
59                planet.sign,
60                round(float(planet.position), 2),
61                ("R" if planet.retrograde else "-"),
62                planet.house,
63            ]
64            for planet in get_available_astrological_points_list(self.instance)
65        ]
66
67        self.planets_table = AsciiTable(planets_data).table
68
69    def get_houses_table(self) -> None:
70        """
71        Creates the houses table.
72        """
73
74        houses_data = [["House", "Sign", "Position"]] + [
75            [house.name, house.sign, round(float(house.position), 2)] for house in get_houses_list(self.instance)
76        ]
77
78        self.houses_table = AsciiTable(houses_data).table
79
80    def get_full_report(self) -> str:
81        """
82        Returns the full report.
83        """
84
85        return f"{self.report_title}\n{self.data_table}\n{self.planets_table}\n{self.houses_table}"
86
87    def print_report(self) -> None:
88        """
89        Print the report.
90        """
91
92        print(self.get_full_report())

Create a report for a Kerykeion instance.

ReportGenerator(instance: kerykeion.schemas.kr_models.AstrologicalSubjectModel)
17    def __init__(self, instance: AstrologicalSubjectModel):
18        """
19        Initialize a new ReportGenerator instance.
20
21        Args:
22            instance: The astrological subject model to create a report for.
23        """
24        self.instance = instance
25
26        self.get_report_title()
27        self.get_data_table()
28        self.get_planets_table()
29        self.get_houses_table()

Initialize a new ReportGenerator instance.

Args: instance: The astrological subject model to create a report for.

report_title: str
data_table: str
planets_table: str
houses_table: str
instance
def get_report_title(self) -> None:
31    def get_report_title(self) -> None:
32        """Generate the report title based on the subject's name."""
33        self.report_title = f"\n+- Kerykeion report for {self.instance.name} -+"

Generate the report title based on the subject's name.

def get_data_table(self) -> None:
35    def get_data_table(self) -> None:
36        """
37        Creates the data table of the report.
38        """
39
40        main_data = [["Date", "Time", "Location", "Longitude", "Latitude"]] + [
41            [
42                f"{self.instance.day}/{self.instance.month}/{self.instance.year}",
43                f"{self.instance.hour}:{self.instance.minute}",
44                f"{self.instance.city}, {self.instance.nation}",
45                self.instance.lng,
46                self.instance.lat,
47            ]
48        ]
49        self.data_table = AsciiTable(main_data).table

Creates the data table of the report.

def get_planets_table(self) -> None:
51    def get_planets_table(self) -> None:
52        """
53        Creates the planets table.
54        """
55
56        planets_data = [["AstrologicalPoint", "Sign", "Pos.", "Ret.", "House"]] + [
57            [
58                planet.name,
59                planet.sign,
60                round(float(planet.position), 2),
61                ("R" if planet.retrograde else "-"),
62                planet.house,
63            ]
64            for planet in get_available_astrological_points_list(self.instance)
65        ]
66
67        self.planets_table = AsciiTable(planets_data).table

Creates the planets table.

def get_houses_table(self) -> None:
69    def get_houses_table(self) -> None:
70        """
71        Creates the houses table.
72        """
73
74        houses_data = [["House", "Sign", "Position"]] + [
75            [house.name, house.sign, round(float(house.position), 2)] for house in get_houses_list(self.instance)
76        ]
77
78        self.houses_table = AsciiTable(houses_data).table

Creates the houses table.

def get_full_report(self) -> str:
80    def get_full_report(self) -> str:
81        """
82        Returns the full report.
83        """
84
85        return f"{self.report_title}\n{self.data_table}\n{self.planets_table}\n{self.houses_table}"

Returns the full report.

def print_report(self) -> None:
87    def print_report(self) -> None:
88        """
89        Print the report.
90        """
91
92        print(self.get_full_report())

Print the report.

class KerykeionSettingsModel(kerykeion.schemas.kr_models.SubscriptableBaseModel):
177class KerykeionSettingsModel(SubscriptableBaseModel):
178    """
179    This class is used to define the global settings for the Kerykeion.
180    """
181    language_settings: dict[str, KerykeionLanguageModel] = Field(title="Language Settings", description="The language settings of the chart")

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

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

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

def get_settings( new_settings_file: Union[pathlib._local.Path, NoneType, KerykeionSettingsModel, dict] = None) -> KerykeionSettingsModel:
16def get_settings(new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None) -> KerykeionSettingsModel:
17    """
18    This function is used to get the settings dict from the settings file.
19    If no settings file is passed as argument, or the file is not found, it will fallback to:
20    - The system wide config file, located in ~/.config/kerykeion/kr.config.json
21    - The default config file, located in the package folder
22
23    Args:
24        new_settings_file (Union[Path, None], optional): The path of the settings file. Defaults to None.
25
26    Returns:
27        Dict: The settings dict
28    """
29
30    if isinstance(new_settings_file, dict):
31        return KerykeionSettingsModel(**new_settings_file)
32    elif isinstance(new_settings_file, KerykeionSettingsModel):
33        return new_settings_file
34
35    # Config path we passed as argument
36    if new_settings_file is not None:
37        settings_file = new_settings_file
38
39        if not settings_file.exists():
40            raise FileNotFoundError(f"File {settings_file} does not exist")
41
42    # System wide config path
43    else:
44        home_folder = Path.home()
45        settings_file = home_folder / ".config" / "kerykeion" / "kr.config.json"
46
47    # Fallback to the default config in the package
48    if not settings_file.exists():
49        settings_file = Path(__file__).parent / "kr.config.json"
50
51    logging.debug(f"Kerykeion config file path: {settings_file}")
52    settings_dict = load_settings_file(settings_file)
53
54    return KerykeionSettingsModel(**settings_dict)

This function is used to get the settings dict from the settings file. If no settings file is passed as argument, or the file is not found, it will fallback to:

  • The system wide config file, located in ~/.config/kerykeion/kr.config.json
  • The default config file, located in the package folder

Args: new_settings_file (Union[Path, None], optional): The path of the settings file. Defaults to None.

Returns: Dict: The settings dict

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

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.

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.

Examples: Basic transit calculation:

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

Custom configuration:

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

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

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

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).

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

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

Calculate and generate transit data for all configured time points.

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

The calculation process:

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

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

Examples: Basic usage:

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

Processing results:

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

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

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