# kerykeion

This is part of Kerykeion (C) 2025 Giacomo Battaglia

# Kerykeion

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

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

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

Kerykeion also integrates seamlessly with LLM and AI applications.

Here is an example of a birthchart:

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

## Donate

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

## ⚠️ 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

- Web API
- Donate
- ⚠️ Development Branch Notice
- Table of Contents
- Installation
- Basic Usage
- Generate a SVG Chart
  - Birth Chart
  - External Birth Chart
  - Synastry Chart
  - Transit Chart
  - Composite Chart
- Wheel Only Charts
  - Birth Chart
  - Wheel Only Birth Chart (External)
  - Synastry Chart
  - Change the Output Directory
  - Change Language
  - Minified SVG
  - SVG without CSS Variables
  - Grid Only SVG
- Report
- Example: Retrieving Aspects
- Ayanamsa (Sidereal Modes)
- House Systems
- Perspective Type
- Themes
- Alternative Initialization
- Lunar Nodes (Rahu \& Ketu)
- JSON Support
- Auto Generated Documentation
- Development
- Integrating Kerykeion into Your Project
- License
- Contributing
- Citations

## 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 `KerykeionChartSVG` 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, KerykeionChartSVG

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

The SVG file will be saved in the home directory.

### External Birth Chart

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

### Synastry Chart

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG

    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 = KerykeionChartSVG(first, "Synastry", second)
    synastry_chart.makeSVG()

### Transit Chart

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG

    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 = KerykeionChartSVG(subject, "Transit", transit)
    transit_chart.makeSVG()

### Composite Chart

    from kerykeion import CompositeSubjectFactory, AstrologicalSubjectFactory, KerykeionChartSVG

    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 = KerykeionChartSVG(composite_model, "Composite")
    composite_chart.makeSVG()

## 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, KerykeionChartSVG

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

### Wheel Only Birth Chart (External)

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

### Synastry Chart

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG
    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 = KerykeionChartSVG(
        first, "Synastry", second
    )
    synastry_chart.makeWheelOnlySVG()

### Change the Output Directory

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

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG

    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 = KerykeionChartSVG(
        first, "Synastry", second,
        new_output_directory="."
    )
    synastry_chart.makeSVG()

### Change Language

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

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG

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

    birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
    birth_chart_svg = KerykeionChartSVG(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, KerykeionChartSVG
    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 = KerykeionChartSVG(birth_chart, "Synastry", second, theme="dark")
    aspect_grid_chart.makeAspectGridOnlySVG()

## Report

    from kerykeion import Report, 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 = Report(john)
    report.print_report()

Report 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, KerykeionChartSVG

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

## 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 `KerykeionChartSVG` class.

Example:

    from kerykeion import AstrologicalSubjectFactory, KerykeionChartSVG

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

    chart = KerykeionChartSVG(
        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

View Source

     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.kerykeion_chart_svg import KerykeionChartSVG
    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 .kr_types import *
    17from .planetary_return_factory import PlanetaryReturnFactory, PlanetReturnModel
    18from .relationship_score_factory import RelationshipScoreFactory
    19from .report import Report
    20from .settings import KerykeionSettingsModel, get_settings
    21from .transits_time_range_factory import TransitsTimeRangeFactory
    22
    23__all__ = [
    24    "AspectsFactory",
    25    "AstrologicalSubjectFactory",
    26    "KerykeionChartSVG",
    27    "CompositeSubjectFactory",
    28    "EphemerisDataFactory",
    29    "HouseComparisonFactory",
    30    "HouseComparisonModel",
    31    "PlanetaryReturnFactory",
    32    "PlanetReturnModel",
    33    "RelationshipScoreFactory",
    34    "Report",
    35    "KerykeionSettingsModel",
    36    "get_settings",
    37    "TransitsTimeRangeFactory",
    38]

class AspectsFactory: View Source

     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.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.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.kr_types.kr_models.ActiveAspect]] = None) -> kerykeion.kr_types.kr_models.SingleChartAspectsModel: View Source

     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.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.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.kr_types.kr_models.ActiveAspect]] = None) -> kerykeion.kr_types.kr_models.DualChartAspectsModel: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.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.kr_types.kr_models.ActiveAspect]] = None) -> kerykeion.kr_types.kr_models.SingleChartAspectsModel: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.kr_models.CompositeSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.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.kr_types.kr_models.ActiveAspect]] = None) -> kerykeion.kr_types.kr_models.DualChartAspectsModel: View Source

    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: View Source

     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 = 0, minute: int = 57, 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.kr_types.kr_models.AstrologicalSubjectModel: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel: View Source

    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 KerykeionChartSVG: View Source

      80class KerykeionChartSVG:
      81    """
      82    KerykeionChartSVG 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}")

KerykeionChartSVG 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'.

KerykeionChartSVG( first_obj: Union[kerykeion.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.kr_models.CompositeSubjectModel, PlanetReturnModel], chart_type: Literal['Natal', 'ExternalNatal', 'Synastry', 'Transit', 'Composite', 'Return', 'SingleWheelReturn'] = 'Natal', second_obj: Union[kerykeion.kr_types.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.kr_types.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)'}]) View Source

    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.kr_types.kr_models.AstrologicalSubjectModel, kerykeion.kr_types.kr_models.CompositeSubjectModel, PlanetReturnModel]
second_obj: Union[kerykeion.kr_types.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.kr_types.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.kr_types.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: View Source

    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: View Source

    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: View Source

    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: View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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: View Source

     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.kr_types.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.kr_types.kr_models.AstrologicalSubjectModel, chart_name: Optional[str] = None) View Source

    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.kr_types.kr_models.CompositeSubjectModel]
first_subject: kerykeion.kr_types.kr_models.AstrologicalSubjectModel
second_subject: kerykeion.kr_types.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): View Source

    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: View Source

     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) View Source

    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: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel]: View Source

    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: View Source

    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.kr_types.kr_models.AstrologicalSubjectModel, PlanetReturnModel], second_subject: Union[kerykeion.kr_types.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']) View Source

    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: View Source

    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.kr_types.kr_models.SubscriptableBaseModel): View Source

    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: View Source

     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.kr_types.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) View Source

    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: View Source

    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: View Source

    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: View Source

    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.kr_types.kr_models.AstrologicalBaseModel): View Source

    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: View Source

     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.kr_types.kr_models.AstrologicalSubjectModel, second_subject: kerykeion.kr_types.kr_models.AstrologicalSubjectModel, use_only_major_aspects: bool = True) View Source

     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 = {'sextile', 'trine', 'conjunction', 'opposition', 'square'}
use_only_major_aspects
first_subject: kerykeion.kr_types.kr_models.AstrologicalSubjectModel
second_subject: kerykeion.kr_types.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.kr_types.kr_models.RelationshipScoreAspectModel]
def get_relationship_score(self): View Source

    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 Report: View Source

     7class Report:
     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 Report 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.

Report(instance: kerykeion.kr_types.kr_models.AstrologicalSubjectModel) View Source

    17    def __init__(self, instance: AstrologicalSubjectModel):
    18        """
    19        Initialize a new Report 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 Report 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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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: View Source

    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.kr_types.kr_models.SubscriptableBaseModel): View Source

    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.kr_types.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: View Source

    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: View Source

     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.kr_types import AstrologicalPoint, ActiveAspect
    120        >>> custom_points = [AstrologicalPoint.SUN, AstrologicalPoint.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.kr_types import AstrologicalPoint, ActiveAspect
    >>> custom_points = [AstrologicalPoint.SUN, AstrologicalPoint.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.kr_types.kr_models.AstrologicalSubjectModel, ephemeris_data_points: List[kerykeion.kr_types.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.kr_types.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) View Source

    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.kr_types.kr_models.TransitsTimeRangeModel: View Source

    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