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:
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
- Wheel Only Charts
- ReportGenerator
- 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 ChartDrawer class. You can create various types of charts, including birth, synastry, transit, and composite charts.
Tip:
The optimized way to open the generated SVG files is with a web browser (e.g., Chrome, Firefox).
To improve compatibility across different applications, you can use the remove_css_variables parameter when generating the SVG. This will inline all styles and eliminate CSS variables, resulting in an SVG that is more broadly supported.
Birth Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
john = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(john)
birth_chart_svg.makeSVG()
The SVG file will be saved in the home directory.
External Birth Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart, chart_type="ExternalNatal")
birth_chart_svg.makeSVG()
Synastry Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
synastry_chart = ChartDrawer(first, "Synastry", second)
synastry_chart.makeSVG()
Transit Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
transit = AstrologicalSubjectFactory.from_birth_data("Transit", 2025, 6, 8, 8, 45, "Atlanta", "US")
subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
transit_chart = ChartDrawer(subject, "Transit", transit)
transit_chart.makeSVG()
Composite Chart
from kerykeion import CompositeSubjectFactory, AstrologicalSubjectFactory, ChartDrawer
angelina = AstrologicalSubjectFactory.from_birth_data("Angelina Jolie", 1975, 6, 4, 9, 9, "Los Angeles", "US", lng=-118.15, lat=34.03, tz_str="America/Los_Angeles")
brad = AstrologicalSubjectFactory.from_birth_data("Brad Pitt", 1963, 12, 18, 6, 31, "Shawnee", "US", lng=-96.56, lat=35.20, tz_str="America/Chicago")
factory = CompositeSubjectFactory(angelina, brad)
composite_model = factory.get_midpoint_composite_subject_model()
composite_chart = ChartDrawer(composite_model, "Composite")
composite_chart.makeSVG()
Wheel Only Charts
For all the charts, you can generate a wheel-only chart by using the method makeWheelOnlySVG():
Birth Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeWheelOnlySVG()
Wheel Only Birth Chart (External)
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart, chart_type="ExternalNatal")
birth_chart_svg.makeWheelOnlySVG(
wheel_only=True,
wheel_only_external=True
)
Synastry Chart
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
synastry_chart = ChartDrawer(
first, "Synastry", second
)
synastry_chart.makeWheelOnlySVG()
Change the Output Directory
To save the SVG file in a custom location, specify new_output_directory:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
first = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
synastry_chart = ChartDrawer(
first, "Synastry", second,
new_output_directory="."
)
synastry_chart.makeSVG()
Change Language
You can switch chart language by passing chart_language to the ChartDrawer class:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(
birth_chart,
chart_language="IT" # Change to Italian
)
birth_chart_svg.makeSVG()
More details here.
The available languages are:
- EN (English)
- FR (French)
- PT (Portuguese)
- ES (Spanish)
- TR (Turkish)
- RU (Russian)
- IT (Italian)
- CN (Chinese)
- DE (German)
Minified SVG
To generate a minified SVG, set minify_svg=True in the makeSVG() method:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeSVG(
minify=True
)
SVG without CSS Variables
To generate an SVG without CSS variables, set remove_css_variables=True in the makeSVG() method:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
birth_chart_svg = ChartDrawer(birth_chart)
birth_chart_svg.makeSVG(
remove_css_variables=True
)
This will inline all styles and eliminate CSS variables, resulting in an SVG that is more broadly supported.
Grid Only SVG
It's possible to generate a grid-only SVG, useful for creating a custom layout. To do this, use the makeAspectGridOnlySVG() method:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
birth_chart = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
second = AstrologicalSubjectFactory.from_birth_data("Paul McCartney", 1942, 6, 18, 15, 30, "Liverpool", "GB")
aspect_grid_chart = ChartDrawer(birth_chart, "Synastry", second, theme="dark")
aspect_grid_chart.makeAspectGridOnlySVG()
ReportGenerator
from kerykeion import ReportGenerator, AstrologicalSubjectFactory
john = AstrologicalSubjectFactory.from_birth_data(
"John Lennon", 1940, 10, 9, 18, 30,
lng=-2.9833, # Longitude for Liverpool
lat=53.4000, # Latitude for Liverpool
tz_str="Europe/London", # Timezone for Liverpool
city="Liverpool",
)
report = ReportGenerator(john)
report.print_report()
ReportGenerator output:
+- Kerykeion report for John Lennon -+
+-----------+-------+---------------+-----------+----------+
| Date | Time | Location | Longitude | Latitude |
+-----------+-------+---------------+-----------+----------+
| 9/10/1940 | 18:30 | Liverpool, GB | -2.9833 | 53.4 |
+-----------+-------+---------------+-----------+----------+
+-------------------+------+-------+------+----------------+
| AstrologicalPoint | Sign | Pos. | Ret. | House |
+-------------------+------+-------+------+----------------+
| Sun | Lib | 16.27 | - | Sixth_House |
| Moon | Aqu | 3.55 | - | Eleventh_House |
| Mercury | Sco | 8.56 | - | Seventh_House |
| Venus | Vir | 3.22 | - | Sixth_House |
| Mars | Lib | 2.66 | - | Sixth_House |
| Jupiter | Tau | 13.69 | R | First_House |
| Saturn | Tau | 13.22 | R | First_House |
| Uranus | Tau | 25.55 | R | First_House |
| Neptune | Vir | 26.03 | - | Sixth_House |
| Pluto | Leo | 4.19 | - | Fifth_House |
| Mean_Node | Lib | 10.58 | R | Sixth_House |
| Mean_South_Node | Ari | 10.58 | R | Twelfth_House |
| Mean_Lilith | Ari | 13.37 | - | Twelfth_House |
| Chiron | Leo | 0.57 | - | Fifth_House |
+-------------------+------+-------+------+----------------+
+----------------+------+----------+
| House | Sign | Position |
+----------------+------+----------+
| First_House | Ari | 19.72 |
| Second_House | Tau | 29.52 |
| Third_House | Gem | 20.23 |
| Fourth_House | Can | 7.07 |
| Fifth_House | Can | 25.31 |
| Sixth_House | Leo | 22.11 |
| Seventh_House | Lib | 19.72 |
| Eighth_House | Sco | 29.52 |
| Ninth_House | Sag | 20.23 |
| Tenth_House | Cap | 7.07 |
| Eleventh_House | Cap | 25.31 |
| Twelfth_House | Aqu | 22.11 |
+----------------+------+----------+
To export to a file:
python3 your_script_name.py > file.txt
Example: Retrieving Aspects
Kerykeion provides a unified AspectsFactory class for calculating astrological aspects within single charts or between two charts:
from kerykeion import AspectsFactory, AstrologicalSubjectFactory
# Create astrological subjects
jack = AstrologicalSubjectFactory.from_birth_data("Jack", 1990, 6, 15, 15, 15, "Roma", "IT")
jane = AstrologicalSubjectFactory.from_birth_data("Jane", 1991, 10, 25, 21, 0, "Roma", "IT")
# For single chart aspects (natal, return, composite, etc.)
single_chart_aspects = AspectsFactory.single_chart_aspects(jack)
print(f"Found {len(single_chart_aspects)} aspects in Jack's chart")
print(single_chart_aspects[0])
# Output: AspectModel with details like aspect type, orb, planets involved, etc.
# For dual chart aspects (synastry, transits, comparisons, etc.)
dual_chart_aspects = AspectsFactory.dual_chart_aspects(jack, jane)
print(f"Found {len(dual_chart_aspects)} aspects between Jack and Jane's charts")
print(dual_chart_aspects[0])
# Output: AspectModel with cross-chart aspect details
# The factory returns structured AspectModel objects with properties like:
# - p1_name, p2_name: Planet/point names
# - aspect: Aspect type (conjunction, trine, square, etc.)
# - orbit: Orb tolerance in degrees
# - aspect_degrees: Exact degrees for the aspect (0, 60, 90, 120, 180, etc.)
# - color: Hex color code for visualization
Advanced Usage with Custom Settings:
# You can also customize aspect calculations with custom orb settings
from kerykeion.settings.config_constants import DEFAULT_ACTIVE_ASPECTS
# Modify aspect settings if needed
custom_aspects = DEFAULT_ACTIVE_ASPECTS.copy()
# ... modify as needed
# The factory automatically uses the configured settings for orb calculations
# and filters aspects based on relevance and orb thresholds
Ayanamsa (Sidereal Modes)
By default, the zodiac type is Tropical. To use Sidereal, specify the sidereal mode:
johnny = AstrologicalSubjectFactory.from_birth_data(
"Johnny Depp", 1963, 6, 9, 0, 0,
"Owensboro", "US",
zodiac_type="Sidereal",
sidereal_mode="LAHIRI"
)
More examples here.
Full list of supported sidereal modes here.
House Systems
By default, houses are calculated using Placidus. Configure a different house system as follows:
johnny = AstrologicalSubjectFactory.from_birth_data(
"Johnny Depp", 1963, 6, 9, 0, 0,
"Owensboro", "US",
houses_system="M"
)
More examples here.
Full list of supported house systems here.
So far all the available houses system in the Swiss Ephemeris are supported but the Gauquelin Sectors.
Perspective Type
By default, Kerykeion uses the Apparent Geocentric perspective (the most standard in astrology). Other perspectives (e.g., Heliocentric) can be set this way:
johnny = AstrologicalSubjectFactory.from_birth_data(
"Johnny Depp", 1963, 6, 9, 0, 0,
"Owensboro", "US",
perspective_type="Heliocentric"
)
More examples here.
Full list of supported perspective types here.
Themes
Kerykeion provides several chart themes:
- Classic (default)
- Dark
- Dark High Contrast
- Light
Each theme offers a distinct visual style, allowing you to choose the one that best suits your preferences or presentation needs. If you prefer more control over the appearance, you can opt not to set any theme, making it easier to customize the chart by overriding the default CSS variables. For more detailed instructions on how to apply themes, check the documentation
Here's an example of how to set the theme:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
dark_theme_subject = AstrologicalSubjectFactory.from_birth_data("John Lennon - Dark Theme", 1940, 10, 9, 18, 30, "Liverpool", "GB")
dark_theme_natal_chart = ChartDrawer(dark_theme_subject, theme="dark_high_contrast")
dark_theme_natal_chart.makeSVG()
Alternative Initialization
Create an AstrologicalSubject from a UTC ISO 8601 string:
subject = AstrologicalSubject.get_from_iso_utc_time(
"Johnny Depp", "1963-06-09T05:00:00Z", "Owensboro", "US"
)
If you set online=True, provide a geonames_username to allow city-based geolocation:
from kerykeion.astrological_subject import AstrologicalSubjectFactory
subject = AstrologicalSubject.get_from_iso_utc_time(
"Johnny Depp", "1963-06-09T05:00:00Z", "Owensboro", "US", online=True
)
Lunar Nodes (Rahu & Ketu)
Kerykeion supports both True and Mean Lunar Nodes:
- True North Lunar Node:
"true_node"(name kept without "north" for backward compatibility). - True South Lunar Node:
"true_south_node". - Mean North Lunar Node:
"mean_node"(name kept without "north" for backward compatibility). - Mean South Lunar Node:
"mean_south_node".
In instances of the classes used to generate aspects and SVG charts, only the mean nodes are active. To activate the true nodes, you need to pass the active_points parameter to the ChartDrawer class.
Example:
from kerykeion import AstrologicalSubjectFactory, ChartDrawer
subject = AstrologicalSubjectFactory.from_birth_data("John Lennon", 1940, 10, 9, 18, 30, "Liverpool", "GB")
chart = ChartDrawer(
subject,
active_points=[
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
"Mean_Node",
"Mean_South_Node",
"True_Node", # Activates True North Node
"True_South_Node", # Activates True South Node
"Ascendant",
"Medium_Coeli",
"Descendant",
"Imum_Coeli"
]
)
chart.makeSVG()
JSON Support
You can serialize the astrological subject (the base data used throughout the library) to JSON:
from kerykeion import AstrologicalSubjectFactory
johnny = AstrologicalSubjectFactory.from_birth_data("Johnny Depp", 1963, 6, 9, 0, 0, "Owensboro", "US")
print(johnny.json(dump=False, indent=2))
Auto Generated Documentation
You can find auto-generated documentation here. Most classes and functions include docstrings.
Development
Clone the repository or download the ZIP via the GitHub interface.
Integrating Kerykeion into Your Project
If you would like to incorporate Kerykeion's astrological features into your application, please reach out via email. Whether you need custom features, support, or specialized consulting, I am happy to discuss potential collaborations.
License
This project is covered under the AGPL-3.0 License. For detailed information, please see the LICENSE file. If you have questions, feel free to contact me at kerykeion.astrology@gmail.com.
As a rule of thumb, if you use this library in a project, you should open-source that project under a compatible license. Alternatively, if you wish to keep your source closed, consider using the AstrologerAPI, which is AGPL-3.0 compliant and also helps support the project.
Since the AstrologerAPI is an external third-party service, using it does not require your code to be open-source.
Contributing
Contributions are welcome! Feel free to submit pull requests or report issues.
Citations
If using Kerykeion in published or academic work, please cite as follows:
Battaglia, G. (2025). Kerykeion: A Python Library for Astrological Calculations and Chart Generation.
https://github.com/g-battaglia/kerykeion
1# -*- coding: utf-8 -*- 2""" 3This is part of Kerykeion (C) 2025 Giacomo Battaglia 4 5.. include:: ../README.md 6""" 7 8# Local 9from .aspects import AspectsFactory 10from .astrological_subject_factory import AstrologicalSubjectFactory 11from .charts.chart_drawer import ChartDrawer 12from .composite_subject_factory import CompositeSubjectFactory 13from .ephemeris_data_factory import EphemerisDataFactory 14from .house_comparison.house_comparison_factory import HouseComparisonFactory 15from .house_comparison.house_comparison_models import HouseComparisonModel 16from .schemas import * 17from .planetary_return_factory import PlanetaryReturnFactory, PlanetReturnModel 18from .relationship_score_factory import RelationshipScoreFactory 19from .report import ReportGenerator 20from .settings import KerykeionSettingsModel, get_settings 21from .transits_time_range_factory import TransitsTimeRangeFactory 22 23__all__ = [ 24 "AspectsFactory", 25 "AstrologicalSubjectFactory", 26 "ChartDrawer", 27 "CompositeSubjectFactory", 28 "EphemerisDataFactory", 29 "HouseComparisonFactory", 30 "HouseComparisonModel", 31 "PlanetaryReturnFactory", 32 "PlanetReturnModel", 33 "RelationshipScoreFactory", 34 "ReportGenerator", 35 "KerykeionSettingsModel", 36 "get_settings", 37 "TransitsTimeRangeFactory", 38]
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)
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")
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")
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.
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.
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.
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
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
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
80class ChartDrawer: 81 """ 82 ChartDrawer generates astrological chart visualizations as SVG files. 83 84 This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs 85 for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite. 86 Charts are rendered using XML templates and drawing utilities, with customizable themes, 87 language, active points, and aspects. 88 The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory. 89 90 NOTE: 91 The generated SVG files are optimized for web use, opening in browsers. If you want to 92 use them in other applications, you might need to adjust the SVG settings or styles. 93 94 Args: 95 first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel): 96 The primary astrological subject for the chart. 97 chart_type (ChartType, optional): 98 The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite'). 99 Defaults to 'Natal'. 100 second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional): 101 The secondary subject for Transit or Synastry charts. Not required for Natal or Composite. 102 new_output_directory (str | Path, optional): 103 Directory to write generated SVG files. Defaults to the user's home directory. 104 new_settings_file (Path | dict | KerykeionSettingsModel, optional): 105 Path or settings object to override default chart configuration (colors, fonts, aspects). 106 theme (KerykeionChartTheme, optional): 107 CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'. 108 double_chart_aspect_grid_type (Literal['list', 'table'], optional): 109 Specifies rendering style for double-chart aspect grids. Defaults to 'list'. 110 chart_language (KerykeionChartLanguage, optional): 111 Language code for chart labels. Defaults to 'EN'. 112 active_points (list[AstrologicalPoint], optional): 113 List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS. 114 Example: 115 ["Sun", "Moon", "Mercury", "Venus"] 116 117 active_aspects (list[ActiveAspect], optional): 118 List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS. 119 Example: 120 [ 121 {"name": "conjunction", "orb": 10}, 122 {"name": "opposition", "orb": 10}, 123 {"name": "trine", "orb": 8}, 124 {"name": "sextile", "orb": 6}, 125 {"name": "square", "orb": 5}, 126 {"name": "quintile", "orb": 1}, 127 ] 128 129 Public Methods: 130 makeTemplate(minify=False, remove_css_variables=False) -> str: 131 Render the full chart SVG as a string without writing to disk. Use `minify=True` 132 to remove whitespace and quotes, and `remove_css_variables=True` to embed CSS vars. 133 134 makeSVG(minify=False, remove_css_variables=False) -> None: 135 Generate and write the full chart SVG file to the output directory. 136 Filenames follow the pattern: 137 '{subject.name} - {chart_type} Chart.svg'. 138 139 makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str: 140 Render only the chart wheel (no aspect grid) as an SVG string. 141 142 makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None: 143 Generate and write the wheel-only SVG file: 144 '{subject.name} - {chart_type} Chart - Wheel Only.svg'. 145 146 makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str: 147 Render only the aspect grid as an SVG string. 148 149 makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None: 150 Generate and write the aspect-grid-only SVG file: 151 '{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'. 152 """ 153 154 # Constants 155 156 _DEFAULT_HEIGHT = 550 157 _DEFAULT_FULL_WIDTH = 1200 158 _DEFAULT_NATAL_WIDTH = 870 159 _DEFAULT_FULL_WIDTH_WITH_TABLE = 1200 160 _DEFAULT_ULTRA_WIDE_WIDTH = 1270 161 162 _BASIC_CHART_VIEWBOX = f"0 0 {_DEFAULT_NATAL_WIDTH} {_DEFAULT_HEIGHT}" 163 _WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_FULL_WIDTH} 546.0" 164 _ULTRA_WIDE_CHART_VIEWBOX = f"0 0 {_DEFAULT_ULTRA_WIDE_WIDTH} 546.0" 165 _TRANSIT_CHART_WITH_TABLE_VIWBOX = f"0 0 {_DEFAULT_FULL_WIDTH_WITH_TABLE} 546.0" 166 167 # Set at init 168 first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel] 169 second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] 170 chart_type: ChartType 171 new_output_directory: Union[Path, None] 172 new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] 173 output_directory: Path 174 new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] 175 theme: Union[KerykeionChartTheme, None] 176 double_chart_aspect_grid_type: Literal["list", "table"] 177 chart_language: KerykeionChartLanguage 178 active_points: List[AstrologicalPoint] 179 active_aspects: List[ActiveAspect] 180 transparent_background: bool 181 182 # Internal properties 183 fire: float 184 earth: float 185 air: float 186 water: float 187 first_circle_radius: float 188 second_circle_radius: float 189 third_circle_radius: float 190 width: Union[float, int] 191 language_settings: dict 192 chart_colors_settings: dict 193 planets_settings: dict 194 aspects_settings: dict 195 available_planets_setting: List[KerykeionSettingsCelestialPointModel] 196 height: float 197 location: str 198 geolat: float 199 geolon: float 200 template: str 201 202 def __init__( 203 self, 204 first_obj: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel], 205 chart_type: ChartType = "Natal", 206 second_obj: Union[AstrologicalSubjectModel, PlanetReturnModel, None] = None, 207 new_output_directory: Union[str, None] = None, 208 new_settings_file: Union[Path, None, KerykeionSettingsModel, dict] = None, 209 theme: Union[KerykeionChartTheme, None] = "classic", 210 double_chart_aspect_grid_type: Literal["list", "table"] = "list", 211 chart_language: KerykeionChartLanguage = "EN", 212 active_points: Optional[list[AstrologicalPoint]] = None, 213 active_aspects: list[ActiveAspect]= DEFAULT_ACTIVE_ASPECTS, 214 *, 215 transparent_background: bool = False, 216 colors_settings: dict = DEFAULT_CHART_COLORS, 217 celestial_points_settings: list[dict] = DEFAULT_CELESTIAL_POINTS_SETTINGS, 218 aspects_settings: list[dict] = DEFAULT_CHART_ASPECTS_SETTINGS, 219 ): 220 """ 221 Initialize the chart generator with subject data and configuration options. 222 223 Args: 224 first_obj (AstrologicalSubjectModel, or CompositeSubjectModel): 225 Primary astrological subject instance. 226 chart_type (ChartType, optional): 227 Type of chart to generate (e.g., 'Natal', 'Transit'). 228 second_obj (AstrologicalSubject, optional): 229 Secondary subject for Transit or Synastry charts. 230 new_output_directory (str or Path, optional): 231 Base directory to save generated SVG files. 232 new_settings_file (Path, dict, or KerykeionSettingsModel, optional): 233 Custom settings source for chart colors, fonts, and aspects. 234 theme (KerykeionChartTheme or None, optional): 235 CSS theme to apply; None for default styling. 236 double_chart_aspect_grid_type (Literal['list','table'], optional): 237 Layout style for double-chart aspect grids ('list' or 'table'). 238 chart_language (KerykeionChartLanguage, optional): 239 Language code for chart labels (e.g., 'EN', 'IT'). 240 active_points (List[AstrologicalPoint], optional): 241 Celestial points to include in the chart visualization. 242 active_aspects (List[ActiveAspect], optional): 243 Aspects to calculate, each defined by name and orb. 244 transparent_background (bool, optional): 245 Whether to use a transparent background instead of the theme color. Defaults to False. 246 """ 247 # -------------------- 248 # COMMON INITIALIZATION 249 # -------------------- 250 home_directory = Path.home() 251 self.new_settings_file = new_settings_file 252 self.chart_language = chart_language 253 self.active_aspects = active_aspects 254 self.chart_type = chart_type 255 self.double_chart_aspect_grid_type = double_chart_aspect_grid_type 256 self.transparent_background = transparent_background 257 self.chart_colors_settings = colors_settings 258 self.planets_settings = celestial_points_settings 259 self.aspects_settings = aspects_settings 260 261 if not active_points: 262 self.active_points = first_obj.active_points 263 else: 264 self.active_points = find_common_active_points( 265 active_points, 266 first_obj.active_points 267 ) 268 269 if second_obj: 270 self.active_points = find_common_active_points( 271 self.active_points, 272 second_obj.active_points 273 ) 274 275 # Set output directory 276 if new_output_directory: 277 self.output_directory = Path(new_output_directory) 278 else: 279 self.output_directory = home_directory 280 281 # Load settings 282 self.parse_json_settings(new_settings_file) 283 284 # Primary subject 285 self.first_obj = first_obj 286 287 # Default radius for all charts 288 self.main_radius = 240 289 290 # Configure available planets 291 self.available_planets_setting = [] 292 for body in self.planets_settings: 293 if body["name"] in self.active_points: 294 body["is_active"] = True 295 self.available_planets_setting.append(body) 296 297 # Set available celestial points 298 available_celestial_points_names = [body["name"].lower() for body in self.available_planets_setting] 299 self.available_kerykeion_celestial_points = [] 300 for body in available_celestial_points_names: 301 self.available_kerykeion_celestial_points.append(self.first_obj.get(body)) 302 303 # ------------------------ 304 # CHART TYPE SPECIFIC SETUP 305 # ------------------------ 306 307 if self.chart_type in ["Natal", "ExternalNatal"]: 308 # --- NATAL / EXTERNAL NATAL CHART SETUP --- 309 310 # Validate Subject 311 if not isinstance(self.first_obj, AstrologicalSubjectModel): 312 raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 313 314 # Calculate aspects 315 aspects_instance = AspectsFactory.single_chart_aspects( 316 self.first_obj, 317 active_points=self.active_points, 318 active_aspects=active_aspects, 319 ) 320 self.aspects_list = aspects_instance.relevant_aspects 321 322 # Screen size 323 self.height = self._DEFAULT_HEIGHT 324 self.width = self._DEFAULT_NATAL_WIDTH 325 326 # Location and coordinates 327 self.location = self.first_obj.city 328 self.geolat = self.first_obj.lat 329 self.geolon = self.first_obj.lng 330 331 # Circle radii 332 if self.chart_type == "ExternalNatal": 333 self.first_circle_radius = 56 334 self.second_circle_radius = 92 335 self.third_circle_radius = 112 336 else: 337 self.first_circle_radius = 0 338 self.second_circle_radius = 36 339 self.third_circle_radius = 120 340 341 elif self.chart_type == "Composite": 342 # --- COMPOSITE CHART SETUP --- 343 344 # Validate Subject 345 if not isinstance(self.first_obj, CompositeSubjectModel): 346 raise KerykeionException("First object must be a CompositeSubjectModel instance.") 347 348 # Calculate aspects 349 self.aspects_list = AspectsFactory.single_chart_aspects(self.first_obj, active_points=self.active_points).relevant_aspects 350 351 # Screen size 352 self.height = self._DEFAULT_HEIGHT 353 self.width = self._DEFAULT_NATAL_WIDTH 354 355 # Location and coordinates (average of both subjects) 356 self.location = "" 357 self.geolat = (self.first_obj.first_subject.lat + self.first_obj.second_subject.lat) / 2 358 self.geolon = (self.first_obj.first_subject.lng + self.first_obj.second_subject.lng) / 2 359 360 # Circle radii 361 self.first_circle_radius = 0 362 self.second_circle_radius = 36 363 self.third_circle_radius = 120 364 365 elif self.chart_type == "Transit": 366 # --- TRANSIT CHART SETUP --- 367 368 # Validate Subjects 369 if not second_obj: 370 raise KerykeionException("Second object is required for Transit charts.") 371 if not isinstance(self.first_obj, AstrologicalSubjectModel): 372 raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 373 if not isinstance(second_obj, AstrologicalSubjectModel): 374 raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 375 376 # Secondary subject setup 377 self.second_obj = second_obj 378 379 # Calculate aspects (transit to natal) 380 synastry_aspects_instance = AspectsFactory.dual_chart_aspects( 381 self.first_obj, 382 self.second_obj, 383 active_points=self.active_points, 384 active_aspects=active_aspects, 385 ) 386 self.aspects_list = synastry_aspects_instance.relevant_aspects 387 388 # Secondary subject available points 389 self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points 390 391 # Screen size 392 self.height = self._DEFAULT_HEIGHT 393 if self.double_chart_aspect_grid_type == "table": 394 self.width = self._DEFAULT_FULL_WIDTH_WITH_TABLE 395 else: 396 self.width = self._DEFAULT_FULL_WIDTH 397 398 # Location and coordinates (from transit subject) 399 self.location = self.second_obj.city 400 self.geolat = self.second_obj.lat 401 self.geolon = self.second_obj.lng 402 self.t_name = self.language_settings["transit_name"] 403 404 # Circle radii 405 self.first_circle_radius = 0 406 self.second_circle_radius = 36 407 self.third_circle_radius = 120 408 409 elif self.chart_type == "Synastry": 410 # --- SYNASTRY CHART SETUP --- 411 412 # Validate Subjects 413 if not second_obj: 414 raise KerykeionException("Second object is required for Synastry charts.") 415 if not isinstance(self.first_obj, AstrologicalSubjectModel): 416 raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 417 if not isinstance(second_obj, AstrologicalSubjectModel): 418 raise KerykeionException("Second object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 419 420 # Secondary subject setup 421 self.second_obj = second_obj 422 423 # Calculate aspects (natal to partner) 424 synastry_aspects_instance = AspectsFactory.dual_chart_aspects( 425 self.first_obj, 426 self.second_obj, 427 active_points=self.active_points, 428 active_aspects=active_aspects, 429 ) 430 self.aspects_list = synastry_aspects_instance.relevant_aspects 431 432 # Secondary subject available points 433 self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points 434 435 # Screen size 436 self.height = self._DEFAULT_HEIGHT 437 self.width = self._DEFAULT_FULL_WIDTH 438 439 # Location and coordinates (from primary subject) 440 self.location = self.first_obj.city 441 self.geolat = self.first_obj.lat 442 self.geolon = self.first_obj.lng 443 444 # Circle radii 445 self.first_circle_radius = 0 446 self.second_circle_radius = 36 447 self.third_circle_radius = 120 448 449 elif self.chart_type == "Return": 450 # --- RETURN CHART SETUP --- 451 452 # Validate Subjects 453 if not second_obj: 454 raise KerykeionException("Second object is required for Return charts.") 455 if not isinstance(self.first_obj, AstrologicalSubjectModel): 456 raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 457 if not isinstance(second_obj, PlanetReturnModel): 458 raise KerykeionException("Second object must be a PlanetReturnModel instance.") 459 460 # Secondary subject setup 461 self.second_obj = second_obj 462 463 # Calculate aspects (natal to return) 464 synastry_aspects_instance = AspectsFactory.dual_chart_aspects( 465 self.first_obj, 466 self.second_obj, 467 active_points=self.active_points, 468 active_aspects=active_aspects, 469 ) 470 self.aspects_list = synastry_aspects_instance.relevant_aspects 471 472 # Secondary subject available points 473 self.t_available_kerykeion_celestial_points = self.available_kerykeion_celestial_points 474 475 # Screen size 476 self.height = self._DEFAULT_HEIGHT 477 self.width = self._DEFAULT_ULTRA_WIDE_WIDTH 478 479 # Location and coordinates (from natal subject) 480 self.location = self.first_obj.city 481 self.geolat = self.first_obj.lat 482 self.geolon = self.first_obj.lng 483 484 # Circle radii 485 self.first_circle_radius = 0 486 self.second_circle_radius = 36 487 self.third_circle_radius = 120 488 489 elif self.chart_type == "SingleWheelReturn": 490 # --- NATAL / EXTERNAL NATAL CHART SETUP --- 491 492 # Validate Subject 493 if not isinstance(self.first_obj, PlanetReturnModel): 494 raise KerykeionException("First object must be an AstrologicalSubjectModel or AstrologicalSubject instance.") 495 496 # Calculate aspects 497 aspects_instance = AspectsFactory.single_chart_aspects( 498 self.first_obj, 499 active_points=self.active_points, 500 active_aspects=active_aspects, 501 ) 502 self.aspects_list = aspects_instance.relevant_aspects 503 504 # Screen size 505 self.height = self._DEFAULT_HEIGHT 506 self.width = self._DEFAULT_NATAL_WIDTH 507 508 # Location and coordinates 509 self.location = self.first_obj.city 510 self.geolat = self.first_obj.lat 511 self.geolon = self.first_obj.lng 512 513 # Circle radii 514 if self.chart_type == "ExternalNatal": 515 self.first_circle_radius = 56 516 self.second_circle_radius = 92 517 self.third_circle_radius = 112 518 else: 519 self.first_circle_radius = 0 520 self.second_circle_radius = 36 521 self.third_circle_radius = 120 522 523 # -------------------- 524 # FINAL COMMON SETUP 525 # -------------------- 526 527 # Calculate element points 528 celestial_points_names = [body["name"].lower() for body in self.available_planets_setting] 529 if self.chart_type == "Synastry": 530 element_totals = calculate_synastry_element_points( 531 self.available_planets_setting, 532 celestial_points_names, 533 self.first_obj, 534 self.second_obj, 535 ) 536 else: 537 element_totals = calculate_element_points( 538 self.available_planets_setting, 539 celestial_points_names, 540 self.first_obj, 541 ) 542 543 self.fire = element_totals["fire"] 544 self.earth = element_totals["earth"] 545 self.air = element_totals["air"] 546 self.water = element_totals["water"] 547 548 # Calculate qualities points 549 if self.chart_type == "Synastry": 550 qualities_totals = calculate_synastry_quality_points( 551 self.available_planets_setting, 552 celestial_points_names, 553 self.first_obj, 554 self.second_obj, 555 ) 556 else: 557 qualities_totals = calculate_quality_points( 558 self.available_planets_setting, 559 celestial_points_names, 560 self.first_obj, 561 ) 562 563 self.cardinal = qualities_totals["cardinal"] 564 self.fixed = qualities_totals["fixed"] 565 self.mutable = qualities_totals["mutable"] 566 567 # Set up theme 568 if theme not in get_args(KerykeionChartTheme) and theme is not None: 569 raise KerykeionException(f"Theme {theme} is not available. Set None for default theme.") 570 571 self.set_up_theme(theme) 572 573 def set_up_theme(self, theme: Union[KerykeionChartTheme, None] = None) -> None: 574 """ 575 Load and apply a CSS theme for the chart visualization. 576 577 Args: 578 theme (KerykeionChartTheme or None): Name of the theme to apply. If None, no CSS is applied. 579 """ 580 if theme is None: 581 self.color_style_tag = "" 582 return 583 584 theme_dir = Path(__file__).parent / "themes" 585 586 with open(theme_dir / f"{theme}.css", "r") as f: 587 self.color_style_tag = f.read() 588 589 def set_output_directory(self, dir_path: Path) -> None: 590 """ 591 Set the directory where generated SVG files will be saved. 592 593 Args: 594 dir_path (Path): Target directory for SVG output. 595 """ 596 self.output_directory = dir_path 597 logging.info(f"Output directory set to: {self.output_directory}") 598 599 def parse_json_settings(self, settings_file_or_dict: Union[Path, dict, KerykeionSettingsModel, None]) -> None: 600 """ 601 Load and parse chart configuration settings. 602 603 Args: 604 settings_file_or_dict (Path, dict, or KerykeionSettingsModel): 605 Source for custom chart settings. 606 """ 607 settings = get_settings(settings_file_or_dict) 608 609 self.language_settings = settings["language_settings"][self.chart_language] 610 611 def _draw_zodiac_circle_slices(self, r): 612 """ 613 Draw zodiac circle slices for each sign. 614 615 Args: 616 r (float): Outer radius of the zodiac ring. 617 618 Returns: 619 str: Concatenated SVG elements for zodiac slices. 620 """ 621 sings = get_args(Sign) 622 output = "" 623 for i, sing in enumerate(sings): 624 output += draw_zodiac_slice( 625 c1=self.first_circle_radius, 626 chart_type=self.chart_type, 627 seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 628 num=i, 629 r=r, 630 style=f'fill:{self.chart_colors_settings[f"zodiac_bg_{i}"]}; fill-opacity: 0.5;', 631 type=sing, 632 ) 633 634 return output 635 636 def _draw_all_aspects_lines(self, r, ar): 637 """ 638 Render SVG lines for all aspects in the chart. 639 640 Args: 641 r (float): Radius at which aspect lines originate. 642 ar (float): Radius at which aspect lines terminate. 643 644 Returns: 645 str: SVG markup for all aspect lines. 646 """ 647 out = "" 648 for aspect in self.aspects_list: 649 aspect_name = aspect["aspect"] 650 aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None) 651 if aspect_color: 652 out += draw_aspect_line( 653 r=r, 654 ar=ar, 655 aspect=aspect, 656 color=aspect_color, 657 seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 658 ) 659 return out 660 661 def _draw_all_transit_aspects_lines(self, r, ar): 662 """ 663 Render SVG lines for all transit aspects in the chart. 664 665 Args: 666 r (float): Radius at which transit aspect lines originate. 667 ar (float): Radius at which transit aspect lines terminate. 668 669 Returns: 670 str: SVG markup for all transit aspect lines. 671 """ 672 out = "" 673 for aspect in self.aspects_list: 674 aspect_name = aspect["aspect"] 675 aspect_color = next((a["color"] for a in self.aspects_settings if a["name"] == aspect_name), None) 676 if aspect_color: 677 out += draw_aspect_line( 678 r=r, 679 ar=ar, 680 aspect=aspect, 681 color=aspect_color, 682 seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 683 ) 684 return out 685 686 def _create_template_dictionary(self) -> ChartTemplateDictionary: 687 """ 688 Assemble chart data and rendering instructions into a template dictionary. 689 690 Gathers styling, dimensions, and SVG fragments for chart components based on 691 chart type and subjects. 692 693 Returns: 694 ChartTemplateDictionary: Populated structure of template variables. 695 """ 696 # Initialize template dictionary 697 template_dict: dict = {} 698 699 # -------------------------------------# 700 # COMMON SETTINGS FOR ALL CHART TYPES # 701 # -------------------------------------# 702 703 # Set the color style tag and basic dimensions 704 template_dict["color_style_tag"] = self.color_style_tag 705 template_dict["chart_height"] = self.height 706 template_dict["chart_width"] = self.width 707 708 # Set paper colors 709 template_dict["paper_color_0"] = self.chart_colors_settings["paper_0"] 710 template_dict["paper_color_1"] = self.chart_colors_settings["paper_1"] 711 712 # Set background color based on transparent_background setting 713 if self.transparent_background: 714 template_dict["background_color"] = "transparent" 715 else: 716 template_dict["background_color"] = self.chart_colors_settings["paper_1"] 717 718 # Set planet colors 719 for planet in self.planets_settings: 720 planet_id = planet["id"] 721 template_dict[f"planets_color_{planet_id}"] = planet["color"] 722 723 # Set zodiac colors 724 for i in range(12): 725 template_dict[f"zodiac_color_{i}"] = self.chart_colors_settings[f"zodiac_icon_{i}"] 726 727 # Set orb colors 728 for aspect in self.aspects_settings: 729 template_dict[f"orb_color_{aspect['degree']}"] = aspect["color"] 730 731 # Draw zodiac circle slices 732 template_dict["makeZodiac"] = self._draw_zodiac_circle_slices(self.main_radius) 733 734 # Calculate element percentages 735 total_elements = self.fire + self.water + self.earth + self.air 736 fire_percentage = int(round(100 * self.fire / total_elements)) 737 earth_percentage = int(round(100 * self.earth / total_elements)) 738 air_percentage = int(round(100 * self.air / total_elements)) 739 water_percentage = int(round(100 * self.water / total_elements)) 740 741 # Element Percentages 742 template_dict["elements_string"] = f"{self.language_settings.get('elements', 'Elements')}:" 743 template_dict["fire_string"] = f"{self.language_settings['fire']} {fire_percentage}%" 744 template_dict["earth_string"] = f"{self.language_settings['earth']} {earth_percentage}%" 745 template_dict["air_string"] = f"{self.language_settings['air']} {air_percentage}%" 746 template_dict["water_string"] = f"{self.language_settings['water']} {water_percentage}%" 747 748 749 # Qualities Percentages 750 total_qualities = self.cardinal + self.fixed + self.mutable 751 cardinal_percentage = int(round(100 * self.cardinal / total_qualities)) 752 fixed_percentage = int(round(100 * self.fixed / total_qualities)) 753 mutable_percentage = int(round(100 * self.mutable / total_qualities)) 754 755 template_dict["qualities_string"] = f"{self.language_settings.get('qualities', 'Qualities')}:" 756 template_dict["cardinal_string"] = f"{self.language_settings.get('cardinal', 'Cardinal')} {cardinal_percentage}%" 757 template_dict["fixed_string"] = f"{self.language_settings.get('fixed', 'Fixed')} {fixed_percentage}%" 758 template_dict["mutable_string"] = f"{self.language_settings.get('mutable', 'Mutable')} {mutable_percentage}%" 759 760 # Get houses list for main subject 761 first_subject_houses_list = get_houses_list(self.first_obj) 762 763 # ------------------------------- # 764 # CHART TYPE SPECIFIC SETTINGS # 765 # ------------------------------- # 766 767 if self.chart_type in ["Natal", "ExternalNatal"]: 768 # Set viewbox 769 template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX 770 771 # Rings and circles 772 template_dict["transitRing"] = "" 773 template_dict["degreeRing"] = draw_degree_ring( 774 self.main_radius, 775 self.first_circle_radius, 776 self.first_obj.seventh_house.abs_pos, 777 self.chart_colors_settings["paper_0"], 778 ) 779 template_dict["background_circle"] = draw_background_circle( 780 self.main_radius, 781 self.chart_colors_settings["paper_1"], 782 self.chart_colors_settings["paper_1"], 783 ) 784 template_dict["first_circle"] = draw_first_circle( 785 self.main_radius, 786 self.chart_colors_settings["zodiac_radix_ring_2"], 787 self.chart_type, 788 self.first_circle_radius, 789 ) 790 template_dict["second_circle"] = draw_second_circle( 791 self.main_radius, 792 self.chart_colors_settings["zodiac_radix_ring_1"], 793 self.chart_colors_settings["paper_1"], 794 self.chart_type, 795 self.second_circle_radius, 796 ) 797 template_dict["third_circle"] = draw_third_circle( 798 self.main_radius, 799 self.chart_colors_settings["zodiac_radix_ring_0"], 800 self.chart_colors_settings["paper_1"], 801 self.chart_type, 802 self.third_circle_radius, 803 ) 804 805 # Aspects 806 template_dict["makeDoubleChartAspectList"] = "" 807 template_dict["makeAspectGrid"] = draw_aspect_grid( 808 self.chart_colors_settings["paper_0"], 809 self.available_planets_setting, 810 self.aspects_list, 811 ) 812 template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius) 813 814 # Chart title 815 template_dict["stringTitle"] = f'{self.first_obj.name} - {self.language_settings.get("birth_chart", "Birth Chart")}' 816 817 # Top left section 818 latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"]) 819 longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"]) 820 821 template_dict["top_left_0"] = f'{self.language_settings.get("location", "Location")}:' 822 template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" 823 template_dict["top_left_2"] = f"{self.language_settings['latitude']}: {latitude_string}" 824 template_dict["top_left_3"] = f"{self.language_settings['longitude']}: {longitude_string}" 825 template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore 826 template_dict["top_left_5"] = f"{self.language_settings.get('day_of_week', 'Day of Week')}: {self.first_obj.day_of_week}" # type: ignore 827 828 # Bottom left section 829 if self.first_obj.zodiac_type == "Tropic": 830 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 831 else: 832 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 833 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 834 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 835 836 template_dict["bottom_left_0"] = zodiac_info 837 template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}" 838 839 # Lunar phase information (optional) 840 if self.first_obj.lunar_phase is not None: 841 template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}' 842 template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}' 843 else: 844 template_dict["bottom_left_2"] = "" 845 template_dict["bottom_left_3"] = "" 846 847 template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}' 848 849 # Moon phase section calculations 850 if self.first_obj.lunar_phase is not None: 851 template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat) 852 else: 853 template_dict["makeLunarPhase"] = "" 854 855 # Houses and planet drawing 856 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 857 main_subject_houses_list=first_subject_houses_list, 858 text_color=self.chart_colors_settings["paper_0"], 859 house_cusp_generale_name_label=self.language_settings["cusp"], 860 ) 861 template_dict["makeSecondaryHousesGrid"] = "" 862 863 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 864 r=self.main_radius, 865 first_subject_houses_list=first_subject_houses_list, 866 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 867 first_house_color=self.planets_settings[12]["color"], 868 tenth_house_color=self.planets_settings[13]["color"], 869 seventh_house_color=self.planets_settings[14]["color"], 870 fourth_house_color=self.planets_settings[15]["color"], 871 c1=self.first_circle_radius, 872 c3=self.third_circle_radius, 873 chart_type=self.chart_type, 874 ) 875 876 template_dict["makePlanets"] = draw_planets( 877 available_planets_setting=self.available_planets_setting, 878 chart_type=self.chart_type, 879 radius=self.main_radius, 880 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 881 third_circle_radius=self.third_circle_radius, 882 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 883 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 884 ) 885 886 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 887 planets_and_houses_grid_title=self.language_settings["planets_and_house"], 888 subject_name=self.first_obj.name, 889 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 890 chart_type=self.chart_type, 891 text_color=self.chart_colors_settings["paper_0"], 892 celestial_point_language=self.language_settings["celestial_points"], 893 ) 894 template_dict["makeSecondaryPlanetGrid"] = "" 895 template_dict["makeHouseComparisonGrid"] = "" 896 897 elif self.chart_type == "Composite": 898 # Set viewbox 899 template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX 900 901 # Rings and circles 902 template_dict["transitRing"] = "" 903 template_dict["degreeRing"] = draw_degree_ring( 904 self.main_radius, 905 self.first_circle_radius, 906 self.first_obj.seventh_house.abs_pos, 907 self.chart_colors_settings["paper_0"], 908 ) 909 template_dict["background_circle"] = draw_background_circle( 910 self.main_radius, 911 self.chart_colors_settings["paper_1"], 912 self.chart_colors_settings["paper_1"], 913 ) 914 template_dict["first_circle"] = draw_first_circle( 915 self.main_radius, 916 self.chart_colors_settings["zodiac_radix_ring_2"], 917 self.chart_type, 918 self.first_circle_radius, 919 ) 920 template_dict["second_circle"] = draw_second_circle( 921 self.main_radius, 922 self.chart_colors_settings["zodiac_radix_ring_1"], 923 self.chart_colors_settings["paper_1"], 924 self.chart_type, 925 self.second_circle_radius, 926 ) 927 template_dict["third_circle"] = draw_third_circle( 928 self.main_radius, 929 self.chart_colors_settings["zodiac_radix_ring_0"], 930 self.chart_colors_settings["paper_1"], 931 self.chart_type, 932 self.third_circle_radius, 933 ) 934 935 # Aspects 936 template_dict["makeDoubleChartAspectList"] = "" 937 template_dict["makeAspectGrid"] = draw_aspect_grid( 938 self.chart_colors_settings["paper_0"], 939 self.available_planets_setting, 940 self.aspects_list, 941 ) 942 template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius) 943 944 # Chart title 945 template_dict["stringTitle"] = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore 946 947 # Top left section 948 # First subject 949 latitude = convert_latitude_coordinate_to_string( 950 self.first_obj.first_subject.lat, # type: ignore 951 self.language_settings["north_letter"], 952 self.language_settings["south_letter"], 953 ) 954 longitude = convert_longitude_coordinate_to_string( 955 self.first_obj.first_subject.lng, # type: ignore 956 self.language_settings["east_letter"], 957 self.language_settings["west_letter"], 958 ) 959 960 # Second subject 961 latitude_string = convert_latitude_coordinate_to_string( 962 self.first_obj.second_subject.lat, # type: ignore 963 self.language_settings["north_letter"], 964 self.language_settings["south_letter"], 965 ) 966 longitude_string = convert_longitude_coordinate_to_string( 967 self.first_obj.second_subject.lng, # type: ignore 968 self.language_settings["east_letter"], 969 self.language_settings["west_letter"], 970 ) 971 972 template_dict["top_left_0"] = f"{self.first_obj.first_subject.name}" # type: ignore 973 template_dict["top_left_1"] = f"{datetime.fromisoformat(self.first_obj.first_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore 974 template_dict["top_left_2"] = f"{latitude} {longitude}" 975 template_dict["top_left_3"] = self.first_obj.second_subject.name # type: ignore 976 template_dict["top_left_4"] = f"{datetime.fromisoformat(self.first_obj.second_subject.iso_formatted_local_datetime).strftime('%Y-%m-%d %H:%M')}" # type: ignore 977 template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}" 978 979 # Bottom left section 980 if self.first_obj.zodiac_type == "Tropic": 981 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 982 else: 983 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 984 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 985 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 986 987 template_dict["bottom_left_0"] = zodiac_info 988 template_dict["bottom_left_1"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}" 989 template_dict["bottom_left_2"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.first_obj.first_subject.perspective_type}' # type: ignore 990 template_dict["bottom_left_3"] = f'{self.language_settings.get("composite_chart", "Composite Chart")} - {self.language_settings.get("midpoints", "Midpoints")}' 991 template_dict["bottom_left_4"] = "" 992 993 # Moon phase section calculations 994 if self.first_obj.lunar_phase is not None: 995 template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat) 996 else: 997 template_dict["makeLunarPhase"] = "" 998 999 # Houses and planet drawing 1000 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 1001 main_subject_houses_list=first_subject_houses_list, 1002 text_color=self.chart_colors_settings["paper_0"], 1003 house_cusp_generale_name_label=self.language_settings["cusp"], 1004 ) 1005 template_dict["makeSecondaryHousesGrid"] = "" 1006 1007 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 1008 r=self.main_radius, 1009 first_subject_houses_list=first_subject_houses_list, 1010 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 1011 first_house_color=self.planets_settings[12]["color"], 1012 tenth_house_color=self.planets_settings[13]["color"], 1013 seventh_house_color=self.planets_settings[14]["color"], 1014 fourth_house_color=self.planets_settings[15]["color"], 1015 c1=self.first_circle_radius, 1016 c3=self.third_circle_radius, 1017 chart_type=self.chart_type, 1018 ) 1019 1020 template_dict["makePlanets"] = draw_planets( 1021 available_planets_setting=self.available_planets_setting, 1022 chart_type=self.chart_type, 1023 radius=self.main_radius, 1024 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1025 third_circle_radius=self.third_circle_radius, 1026 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 1027 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 1028 ) 1029 1030 subject_name = f"{self.first_obj.first_subject.name} {self.language_settings['and_word']} {self.first_obj.second_subject.name}" # type: ignore 1031 1032 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 1033 planets_and_houses_grid_title=self.language_settings["planets_and_house"], 1034 subject_name=subject_name, 1035 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1036 chart_type=self.chart_type, 1037 text_color=self.chart_colors_settings["paper_0"], 1038 celestial_point_language=self.language_settings["celestial_points"], 1039 ) 1040 template_dict["makeSecondaryPlanetGrid"] = "" 1041 template_dict["makeHouseComparisonGrid"] = "" 1042 1043 elif self.chart_type == "Transit": 1044 1045 # Transit has no Element Percentages 1046 template_dict["elements_string"] = "" 1047 template_dict["fire_string"] = "" 1048 template_dict["earth_string"] = "" 1049 template_dict["air_string"] = "" 1050 template_dict["water_string"] = "" 1051 1052 # Transit has no Qualities Percentages 1053 template_dict["qualities_string"] = "" 1054 template_dict["cardinal_string"] = "" 1055 template_dict["fixed_string"] = "" 1056 template_dict["mutable_string"] = "" 1057 1058 # Set viewbox 1059 if self.double_chart_aspect_grid_type == "table": 1060 template_dict["viewbox"] = self._TRANSIT_CHART_WITH_TABLE_VIWBOX 1061 else: 1062 template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX 1063 1064 # Get houses list for secondary subject 1065 second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore 1066 1067 # Rings and circles 1068 template_dict["transitRing"] = draw_transit_ring( 1069 self.main_radius, 1070 self.chart_colors_settings["paper_1"], 1071 self.chart_colors_settings["zodiac_transit_ring_3"], 1072 ) 1073 template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos) 1074 template_dict["background_circle"] = draw_background_circle( 1075 self.main_radius, 1076 self.chart_colors_settings["paper_1"], 1077 self.chart_colors_settings["paper_1"], 1078 ) 1079 template_dict["first_circle"] = draw_first_circle( 1080 self.main_radius, 1081 self.chart_colors_settings["zodiac_transit_ring_2"], 1082 self.chart_type, 1083 ) 1084 template_dict["second_circle"] = draw_second_circle( 1085 self.main_radius, 1086 self.chart_colors_settings["zodiac_transit_ring_1"], 1087 self.chart_colors_settings["paper_1"], 1088 self.chart_type, 1089 ) 1090 template_dict["third_circle"] = draw_third_circle( 1091 self.main_radius, 1092 self.chart_colors_settings["zodiac_transit_ring_0"], 1093 self.chart_colors_settings["paper_1"], 1094 self.chart_type, 1095 self.third_circle_radius, 1096 ) 1097 1098 # Aspects 1099 if self.double_chart_aspect_grid_type == "list": 1100 title = f'{self.first_obj.name} - {self.language_settings.get("transit_aspects", "Transit Aspects")}' 1101 template_dict["makeAspectGrid"] = "" 1102 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings) 1103 else: 1104 template_dict["makeAspectGrid"] = "" 1105 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid( 1106 self.chart_colors_settings["paper_0"], 1107 self.available_planets_setting, 1108 self.aspects_list, 1109 550, 1110 450, 1111 ) 1112 1113 template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160) 1114 1115 # Chart title 1116 template_dict["stringTitle"] = f"{self.language_settings['transits']} {format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime)}" # type: ignore 1117 1118 # Top left section 1119 latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"]) 1120 longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"]) 1121 1122 template_dict["top_left_0"] = template_dict["top_left_0"] = f'{self.first_obj.name}' 1123 template_dict["top_left_1"] = f"{format_location_string(self.first_obj.city)}, {self.first_obj.nation}" # type: ignore 1124 template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore 1125 template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}" 1126 template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}" 1127 template_dict["top_left_5"] = ""#f"{self.language_settings['type']}: {self.language_settings.get(self.chart_type, self.chart_type)}" 1128 1129 # Bottom left section 1130 if self.first_obj.zodiac_type == "Tropic": 1131 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 1132 else: 1133 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 1134 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 1135 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 1136 1137 template_dict["bottom_left_0"] = zodiac_info 1138 template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}" 1139 1140 # Lunar phase information from second object (Transit) (optional) 1141 if self.second_obj is not None and hasattr(self.second_obj, 'lunar_phase') and self.second_obj.lunar_phase is not None: 1142 template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.second_obj.lunar_phase.get("moon_phase", "")}' # type: ignore 1143 template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.second_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.second_obj.lunar_phase.moon_phase_name)}' 1144 else: 1145 template_dict["bottom_left_2"] = "" 1146 template_dict["bottom_left_3"] = "" 1147 1148 template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.second_obj.perspective_type.lower().replace(" ", "_"), self.second_obj.perspective_type)}' # type: ignore 1149 1150 # Moon phase section calculations - use first_obj for visualization 1151 if self.first_obj.lunar_phase is not None: 1152 template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat) 1153 else: 1154 template_dict["makeLunarPhase"] = "" 1155 1156 # Houses and planet drawing 1157 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 1158 main_subject_houses_list=first_subject_houses_list, 1159 text_color=self.chart_colors_settings["paper_0"], 1160 house_cusp_generale_name_label=self.language_settings["cusp"], 1161 ) 1162 # template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid( 1163 # secondary_subject_houses_list=second_subject_houses_list, 1164 # text_color=self.chart_colors_settings["paper_0"], 1165 # house_cusp_generale_name_label=self.language_settings["cusp"], 1166 # ) 1167 template_dict["makeSecondaryHousesGrid"] = "" 1168 1169 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 1170 r=self.main_radius, 1171 first_subject_houses_list=first_subject_houses_list, 1172 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 1173 first_house_color=self.planets_settings[12]["color"], 1174 tenth_house_color=self.planets_settings[13]["color"], 1175 seventh_house_color=self.planets_settings[14]["color"], 1176 fourth_house_color=self.planets_settings[15]["color"], 1177 c1=self.first_circle_radius, 1178 c3=self.third_circle_radius, 1179 chart_type=self.chart_type, 1180 second_subject_houses_list=second_subject_houses_list, 1181 transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"], 1182 ) 1183 1184 template_dict["makePlanets"] = draw_planets( 1185 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1186 available_planets_setting=self.available_planets_setting, 1187 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1188 radius=self.main_radius, 1189 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 1190 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 1191 chart_type=self.chart_type, 1192 third_circle_radius=self.third_circle_radius, 1193 ) 1194 1195 # Planet grids 1196 first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})" 1197 second_return_grid_title = f"{self.language_settings.get('Transit', 'Transit')} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})" 1198 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 1199 planets_and_houses_grid_title="", 1200 subject_name=first_return_grid_title, 1201 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1202 chart_type=self.chart_type, 1203 text_color=self.chart_colors_settings["paper_0"], 1204 celestial_point_language=self.language_settings["celestial_points"], 1205 ) 1206 1207 template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid( 1208 planets_and_houses_grid_title="", 1209 second_subject_name=second_return_grid_title, 1210 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1211 chart_type=self.chart_type, 1212 text_color=self.chart_colors_settings["paper_0"], 1213 celestial_point_language=self.language_settings["celestial_points"], 1214 ) 1215 1216 # House comparison grid 1217 house_comparison_factory = HouseComparisonFactory( 1218 first_subject=self.first_obj, 1219 second_subject=self.second_obj, 1220 active_points=self.active_points, 1221 ) 1222 house_comparison = house_comparison_factory.get_house_comparison() 1223 1224 template_dict["makeHouseComparisonGrid"] = draw_single_house_comparison_grid( 1225 house_comparison, 1226 celestial_point_language=self.language_settings.get("celestial_points", "Celestial Points"), 1227 active_points=self.active_points, 1228 points_owner_subject_number=2, # The second subject is the Transit 1229 house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"), 1230 return_point_label=self.language_settings.get("transit_point", "Transit Point"), 1231 natal_house_label=self.language_settings.get("house_position", "Natal House"), 1232 x_position=930, 1233 ) 1234 1235 elif self.chart_type == "Synastry": 1236 # Set viewbox 1237 template_dict["viewbox"] = self._WIDE_CHART_VIEWBOX 1238 1239 # Get houses list for secondary subject 1240 second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore 1241 1242 # Rings and circles 1243 template_dict["transitRing"] = draw_transit_ring( 1244 self.main_radius, 1245 self.chart_colors_settings["paper_1"], 1246 self.chart_colors_settings["zodiac_transit_ring_3"], 1247 ) 1248 template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos) 1249 template_dict["background_circle"] = draw_background_circle( 1250 self.main_radius, 1251 self.chart_colors_settings["paper_1"], 1252 self.chart_colors_settings["paper_1"], 1253 ) 1254 template_dict["first_circle"] = draw_first_circle( 1255 self.main_radius, 1256 self.chart_colors_settings["zodiac_transit_ring_2"], 1257 self.chart_type, 1258 ) 1259 template_dict["second_circle"] = draw_second_circle( 1260 self.main_radius, 1261 self.chart_colors_settings["zodiac_transit_ring_1"], 1262 self.chart_colors_settings["paper_1"], 1263 self.chart_type, 1264 ) 1265 template_dict["third_circle"] = draw_third_circle( 1266 self.main_radius, 1267 self.chart_colors_settings["zodiac_transit_ring_0"], 1268 self.chart_colors_settings["paper_1"], 1269 self.chart_type, 1270 self.third_circle_radius, 1271 ) 1272 1273 # Aspects 1274 if self.double_chart_aspect_grid_type == "list": 1275 template_dict["makeAspectGrid"] = "" 1276 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list( 1277 f"{self.first_obj.name} - {self.second_obj.name} {self.language_settings.get('synastry_aspects', 'Synastry Aspects')}", # type: ignore 1278 self.aspects_list, 1279 self.planets_settings, 1280 self.aspects_settings 1281 ) 1282 else: 1283 template_dict["makeAspectGrid"] = "" 1284 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid( 1285 self.chart_colors_settings["paper_0"], 1286 self.available_planets_setting, 1287 self.aspects_list, 1288 550, 1289 450, 1290 ) 1291 1292 template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160) 1293 1294 # Chart title 1295 template_dict["stringTitle"] = f"{self.first_obj.name} {self.language_settings['and_word']} {self.second_obj.name}" # type: ignore 1296 1297 # Top left section 1298 template_dict["top_left_0"] = f"{self.first_obj.name}:" 1299 template_dict["top_left_1"] = f"{self.first_obj.city}, {self.first_obj.nation}" # type: ignore 1300 template_dict["top_left_2"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore 1301 template_dict["top_left_3"] = f"{self.second_obj.name}: " # type: ignore 1302 template_dict["top_left_4"] = f"{self.second_obj.city}, {self.second_obj.nation}" # type: ignore 1303 template_dict["top_left_5"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore 1304 1305 # Bottom left section 1306 if self.first_obj.zodiac_type == "Tropic": 1307 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 1308 else: 1309 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 1310 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 1311 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 1312 1313 template_dict["bottom_left_0"] = "" 1314 # FIXME! 1315 template_dict["bottom_left_1"] = "" # f"Compatibility Score: {16}/44" # type: ignore 1316 template_dict["bottom_left_2"] = zodiac_info 1317 template_dict["bottom_left_3"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}" 1318 template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}' 1319 1320 # Moon phase section calculations 1321 template_dict["makeLunarPhase"] = "" 1322 1323 # Houses and planet drawing 1324 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 1325 main_subject_houses_list=first_subject_houses_list, 1326 text_color=self.chart_colors_settings["paper_0"], 1327 house_cusp_generale_name_label=self.language_settings["cusp"], 1328 ) 1329 1330 template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid( 1331 secondary_subject_houses_list=second_subject_houses_list, 1332 text_color=self.chart_colors_settings["paper_0"], 1333 house_cusp_generale_name_label=self.language_settings["cusp"], 1334 ) 1335 1336 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 1337 r=self.main_radius, 1338 first_subject_houses_list=first_subject_houses_list, 1339 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 1340 first_house_color=self.planets_settings[12]["color"], 1341 tenth_house_color=self.planets_settings[13]["color"], 1342 seventh_house_color=self.planets_settings[14]["color"], 1343 fourth_house_color=self.planets_settings[15]["color"], 1344 c1=self.first_circle_radius, 1345 c3=self.third_circle_radius, 1346 chart_type=self.chart_type, 1347 second_subject_houses_list=second_subject_houses_list, 1348 transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"], 1349 ) 1350 1351 template_dict["makePlanets"] = draw_planets( 1352 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1353 available_planets_setting=self.available_planets_setting, 1354 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1355 radius=self.main_radius, 1356 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 1357 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 1358 chart_type=self.chart_type, 1359 third_circle_radius=self.third_circle_radius, 1360 ) 1361 1362 # Planet grid 1363 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 1364 planets_and_houses_grid_title="", 1365 subject_name=f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})", 1366 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1367 chart_type=self.chart_type, 1368 text_color=self.chart_colors_settings["paper_0"], 1369 celestial_point_language=self.language_settings["celestial_points"], 1370 ) 1371 template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid( 1372 planets_and_houses_grid_title="", 1373 second_subject_name= f"{self.second_obj.name} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})", # type: ignore 1374 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1375 chart_type=self.chart_type, 1376 text_color=self.chart_colors_settings["paper_0"], 1377 celestial_point_language=self.language_settings["celestial_points"], 1378 ) 1379 template_dict["makeHouseComparisonGrid"] = "" 1380 1381 elif self.chart_type == "Return": 1382 # Set viewbox 1383 template_dict["viewbox"] = self._ULTRA_WIDE_CHART_VIEWBOX 1384 1385 # Get houses list for secondary subject 1386 second_subject_houses_list = get_houses_list(self.second_obj) # type: ignore 1387 1388 # Rings and circles 1389 template_dict["transitRing"] = draw_transit_ring( 1390 self.main_radius, 1391 self.chart_colors_settings["paper_1"], 1392 self.chart_colors_settings["zodiac_transit_ring_3"], 1393 ) 1394 template_dict["degreeRing"] = draw_transit_ring_degree_steps(self.main_radius, self.first_obj.seventh_house.abs_pos) 1395 template_dict["background_circle"] = draw_background_circle( 1396 self.main_radius, 1397 self.chart_colors_settings["paper_1"], 1398 self.chart_colors_settings["paper_1"], 1399 ) 1400 template_dict["first_circle"] = draw_first_circle( 1401 self.main_radius, 1402 self.chart_colors_settings["zodiac_transit_ring_2"], 1403 self.chart_type, 1404 ) 1405 template_dict["second_circle"] = draw_second_circle( 1406 self.main_radius, 1407 self.chart_colors_settings["zodiac_transit_ring_1"], 1408 self.chart_colors_settings["paper_1"], 1409 self.chart_type, 1410 ) 1411 template_dict["third_circle"] = draw_third_circle( 1412 self.main_radius, 1413 self.chart_colors_settings["zodiac_transit_ring_0"], 1414 self.chart_colors_settings["paper_1"], 1415 self.chart_type, 1416 self.third_circle_radius, 1417 ) 1418 1419 # Aspects 1420 if self.double_chart_aspect_grid_type == "list": 1421 title = self.language_settings.get("return_aspects", "Natal to Return Aspects") 1422 template_dict["makeAspectGrid"] = "" 1423 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_list(title, self.aspects_list, self.planets_settings, self.aspects_settings, max_columns=7) 1424 else: 1425 template_dict["makeAspectGrid"] = "" 1426 template_dict["makeDoubleChartAspectList"] = draw_transit_aspect_grid( 1427 self.chart_colors_settings["paper_0"], 1428 self.available_planets_setting, 1429 self.aspects_list, 1430 550, 1431 450, 1432 ) 1433 1434 template_dict["makeAspects"] = self._draw_all_transit_aspects_lines(self.main_radius, self.main_radius - 160) 1435 1436 # Chart title 1437 if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar": 1438 template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('solar_return', 'Solar Return')}" 1439 else: 1440 template_dict["stringTitle"] = f"{self.first_obj.name} - {self.language_settings.get('lunar_return', 'Lunar Return')}" 1441 1442 1443 # Top left section 1444 # Subject 1445 latitude_string = convert_latitude_coordinate_to_string(self.first_obj.lat, self.language_settings["north"], self.language_settings["south"]) # type: ignore 1446 longitude_string = convert_longitude_coordinate_to_string(self.first_obj.lng, self.language_settings["east"], self.language_settings["west"]) # type: ignore 1447 1448 # Return 1449 return_latitude_string = convert_latitude_coordinate_to_string(self.second_obj.lat, self.language_settings["north"], self.language_settings["south"]) # type: ignore 1450 return_longitude_string = convert_longitude_coordinate_to_string(self.second_obj.lng, self.language_settings["east"], self.language_settings["west"]) # type: ignore 1451 1452 if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar": 1453 template_dict["top_left_0"] = f"{self.language_settings.get('solar_return', 'Solar Return')}:" 1454 else: 1455 template_dict["top_left_0"] = f"{self.language_settings.get('lunar_return', 'Lunar Return')}:" 1456 template_dict["top_left_1"] = format_datetime_with_timezone(self.second_obj.iso_formatted_local_datetime) # type: ignore 1457 template_dict["top_left_2"] = f"{return_latitude_string} / {return_longitude_string}" 1458 template_dict["top_left_3"] = f"{self.first_obj.name}" 1459 template_dict["top_left_4"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore 1460 template_dict["top_left_5"] = f"{latitude_string} / {longitude_string}" 1461 1462 # Bottom left section 1463 if self.first_obj.zodiac_type == "Tropic": 1464 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 1465 else: 1466 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 1467 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 1468 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 1469 1470 template_dict["bottom_left_0"] = zodiac_info 1471 template_dict["bottom_left_1"] = f"{self.language_settings.get('domification', 'Domification')}: {self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)}" 1472 1473 # Lunar phase information (optional) 1474 if self.first_obj.lunar_phase is not None: 1475 template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}' 1476 template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}' 1477 else: 1478 template_dict["bottom_left_2"] = "" 1479 template_dict["bottom_left_3"] = "" 1480 1481 template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}' 1482 1483 # Moon phase section calculations 1484 if self.first_obj.lunar_phase is not None: 1485 template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat) 1486 else: 1487 template_dict["makeLunarPhase"] = "" 1488 1489 # Houses and planet drawing 1490 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 1491 main_subject_houses_list=first_subject_houses_list, 1492 text_color=self.chart_colors_settings["paper_0"], 1493 house_cusp_generale_name_label=self.language_settings["cusp"], 1494 ) 1495 1496 template_dict["makeSecondaryHousesGrid"] = draw_secondary_house_grid( 1497 secondary_subject_houses_list=second_subject_houses_list, 1498 text_color=self.chart_colors_settings["paper_0"], 1499 house_cusp_generale_name_label=self.language_settings["cusp"], 1500 ) 1501 1502 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 1503 r=self.main_radius, 1504 first_subject_houses_list=first_subject_houses_list, 1505 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 1506 first_house_color=self.planets_settings[12]["color"], 1507 tenth_house_color=self.planets_settings[13]["color"], 1508 seventh_house_color=self.planets_settings[14]["color"], 1509 fourth_house_color=self.planets_settings[15]["color"], 1510 c1=self.first_circle_radius, 1511 c3=self.third_circle_radius, 1512 chart_type=self.chart_type, 1513 second_subject_houses_list=second_subject_houses_list, 1514 transit_house_cusp_color=self.chart_colors_settings["houses_transit_line"], 1515 ) 1516 1517 template_dict["makePlanets"] = draw_planets( 1518 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1519 available_planets_setting=self.available_planets_setting, 1520 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1521 radius=self.main_radius, 1522 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 1523 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 1524 chart_type=self.chart_type, 1525 third_circle_radius=self.third_circle_radius, 1526 ) 1527 1528 # Planet grid 1529 if self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar": 1530 first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})" 1531 second_return_grid_title = f"{self.language_settings.get('solar_return', 'Solar Return')} ({self.language_settings.get('outer_wheel', 'Outer Wheel')})" 1532 else: 1533 first_return_grid_title = f"{self.first_obj.name} ({self.language_settings.get('inner_wheel', 'Inner Wheel')})" 1534 second_return_grid_title = f'{self.language_settings.get("lunar_return", "Lunar Return")} ({self.language_settings.get("outer_wheel", "Outer Wheel")})' 1535 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 1536 planets_and_houses_grid_title="", 1537 subject_name=first_return_grid_title, 1538 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1539 chart_type=self.chart_type, 1540 text_color=self.chart_colors_settings["paper_0"], 1541 celestial_point_language=self.language_settings["celestial_points"], 1542 ) 1543 template_dict["makeSecondaryPlanetGrid"] = draw_secondary_planet_grid( 1544 planets_and_houses_grid_title="", 1545 second_subject_name=second_return_grid_title, 1546 second_subject_available_kerykeion_celestial_points=self.t_available_kerykeion_celestial_points, 1547 chart_type=self.chart_type, 1548 text_color=self.chart_colors_settings["paper_0"], 1549 celestial_point_language=self.language_settings["celestial_points"], 1550 ) 1551 1552 house_comparison_factory = HouseComparisonFactory( 1553 first_subject=self.first_obj, 1554 second_subject=self.second_obj, 1555 active_points=self.active_points, 1556 ) 1557 house_comparison = house_comparison_factory.get_house_comparison() 1558 1559 template_dict["makeHouseComparisonGrid"] = draw_house_comparison_grid( 1560 house_comparison, 1561 celestial_point_language=self.language_settings["celestial_points"], 1562 active_points=self.active_points, 1563 points_owner_subject_number=2, # The second subject is the Solar Return 1564 house_position_comparison_label=self.language_settings.get("house_position_comparison", "House Position Comparison"), 1565 return_point_label=self.language_settings.get("return_point", "Return Point"), 1566 return_label=self.language_settings.get("Return", "Return"), 1567 radix_label=self.language_settings.get("Natal", "Natal"), 1568 ) 1569 1570 elif self.chart_type == "SingleWheelReturn": 1571 # Set viewbox 1572 template_dict["viewbox"] = self._BASIC_CHART_VIEWBOX 1573 1574 # Rings and circles 1575 template_dict["transitRing"] = "" 1576 template_dict["degreeRing"] = draw_degree_ring( 1577 self.main_radius, 1578 self.first_circle_radius, 1579 self.first_obj.seventh_house.abs_pos, 1580 self.chart_colors_settings["paper_0"], 1581 ) 1582 template_dict["background_circle"] = draw_background_circle( 1583 self.main_radius, 1584 self.chart_colors_settings["paper_1"], 1585 self.chart_colors_settings["paper_1"], 1586 ) 1587 template_dict["first_circle"] = draw_first_circle( 1588 self.main_radius, 1589 self.chart_colors_settings["zodiac_radix_ring_2"], 1590 self.chart_type, 1591 self.first_circle_radius, 1592 ) 1593 template_dict["second_circle"] = draw_second_circle( 1594 self.main_radius, 1595 self.chart_colors_settings["zodiac_radix_ring_1"], 1596 self.chart_colors_settings["paper_1"], 1597 self.chart_type, 1598 self.second_circle_radius, 1599 ) 1600 template_dict["third_circle"] = draw_third_circle( 1601 self.main_radius, 1602 self.chart_colors_settings["zodiac_radix_ring_0"], 1603 self.chart_colors_settings["paper_1"], 1604 self.chart_type, 1605 self.third_circle_radius, 1606 ) 1607 1608 # Aspects 1609 template_dict["makeDoubleChartAspectList"] = "" 1610 template_dict["makeAspectGrid"] = draw_aspect_grid( 1611 self.chart_colors_settings["paper_0"], 1612 self.available_planets_setting, 1613 self.aspects_list, 1614 ) 1615 template_dict["makeAspects"] = self._draw_all_aspects_lines(self.main_radius, self.main_radius - self.third_circle_radius) 1616 1617 # Chart title 1618 template_dict["stringTitle"] = self.first_obj.name 1619 1620 # Top left section 1621 latitude_string = convert_latitude_coordinate_to_string(self.geolat, self.language_settings["north"], self.language_settings["south"]) 1622 longitude_string = convert_longitude_coordinate_to_string(self.geolon, self.language_settings["east"], self.language_settings["west"]) 1623 1624 template_dict["top_left_0"] = f'{self.language_settings["info"]}:' 1625 template_dict["top_left_1"] = format_datetime_with_timezone(self.first_obj.iso_formatted_local_datetime) # type: ignore 1626 template_dict["top_left_2"] = f"{self.first_obj.city}, {self.first_obj.nation}" 1627 template_dict["top_left_3"] = f"{self.language_settings['latitude']}: {latitude_string}" 1628 template_dict["top_left_4"] = f"{self.language_settings['longitude']}: {longitude_string}" 1629 1630 if hasattr(self.first_obj, 'return_type') and self.first_obj.return_type == "Solar": 1631 template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get('solar_return', 'Solar Return')}" 1632 else: 1633 template_dict["top_left_5"] = f"{self.language_settings['type']}: {self.language_settings.get('lunar_return', 'Lunar Return')}" 1634 1635 # Bottom left section 1636 if self.first_obj.zodiac_type == "Tropic": 1637 zodiac_info = f"{self.language_settings.get('zodiac', 'Zodiac')}: {self.language_settings.get('tropical', 'Tropical')}" 1638 else: 1639 mode_const = "SIDM_" + self.first_obj.sidereal_mode # type: ignore 1640 mode_name = swe.get_ayanamsa_name(getattr(swe, mode_const)) 1641 zodiac_info = f"{self.language_settings.get('ayanamsa', 'Ayanamsa')}: {mode_name}" 1642 1643 template_dict["bottom_left_0"] = zodiac_info 1644 template_dict["bottom_left_1"] = f"{self.language_settings.get('houses_system_' + self.first_obj.houses_system_identifier, self.first_obj.houses_system_name)} {self.language_settings.get('houses', 'Houses')}" 1645 1646 # Lunar phase information (optional) 1647 if self.first_obj.lunar_phase is not None: 1648 template_dict["bottom_left_2"] = f'{self.language_settings.get("lunation_day", "Lunation Day")}: {self.first_obj.lunar_phase.get("moon_phase", "")}' 1649 template_dict["bottom_left_3"] = f'{self.language_settings.get("lunar_phase", "Lunar Phase")}: {self.language_settings.get(self.first_obj.lunar_phase.moon_phase_name.lower().replace(" ", "_"), self.first_obj.lunar_phase.moon_phase_name)}' 1650 else: 1651 template_dict["bottom_left_2"] = "" 1652 template_dict["bottom_left_3"] = "" 1653 1654 template_dict["bottom_left_4"] = f'{self.language_settings.get("perspective_type", "Perspective")}: {self.language_settings.get(self.first_obj.perspective_type.lower().replace(" ", "_"), self.first_obj.perspective_type)}' 1655 1656 # Moon phase section calculations 1657 if self.first_obj.lunar_phase is not None: 1658 template_dict["makeLunarPhase"] = makeLunarPhase(self.first_obj.lunar_phase["degrees_between_s_m"], self.geolat) 1659 else: 1660 template_dict["makeLunarPhase"] = "" 1661 1662 # Houses and planet drawing 1663 template_dict["makeMainHousesGrid"] = draw_main_house_grid( 1664 main_subject_houses_list=first_subject_houses_list, 1665 text_color=self.chart_colors_settings["paper_0"], 1666 house_cusp_generale_name_label=self.language_settings["cusp"], 1667 ) 1668 template_dict["makeSecondaryHousesGrid"] = "" 1669 1670 template_dict["makeHouses"] = draw_houses_cusps_and_text_number( 1671 r=self.main_radius, 1672 first_subject_houses_list=first_subject_houses_list, 1673 standard_house_cusp_color=self.chart_colors_settings["houses_radix_line"], 1674 first_house_color=self.planets_settings[12]["color"], 1675 tenth_house_color=self.planets_settings[13]["color"], 1676 seventh_house_color=self.planets_settings[14]["color"], 1677 fourth_house_color=self.planets_settings[15]["color"], 1678 c1=self.first_circle_radius, 1679 c3=self.third_circle_radius, 1680 chart_type=self.chart_type, 1681 ) 1682 1683 template_dict["makePlanets"] = draw_planets( 1684 available_planets_setting=self.available_planets_setting, 1685 chart_type=self.chart_type, 1686 radius=self.main_radius, 1687 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1688 third_circle_radius=self.third_circle_radius, 1689 main_subject_first_house_degree_ut=self.first_obj.first_house.abs_pos, 1690 main_subject_seventh_house_degree_ut=self.first_obj.seventh_house.abs_pos, 1691 ) 1692 1693 template_dict["makeMainPlanetGrid"] = draw_main_planet_grid( 1694 planets_and_houses_grid_title=self.language_settings["planets_and_house"], 1695 subject_name=self.first_obj.name, 1696 available_kerykeion_celestial_points=self.available_kerykeion_celestial_points, 1697 chart_type=self.chart_type, 1698 text_color=self.chart_colors_settings["paper_0"], 1699 celestial_point_language=self.language_settings["celestial_points"], 1700 ) 1701 template_dict["makeSecondaryPlanetGrid"] = "" 1702 template_dict["makeHouseComparisonGrid"] = "" 1703 1704 return ChartTemplateDictionary(**template_dict) 1705 1706 def makeTemplate(self, minify: bool = False, remove_css_variables=False) -> str: 1707 """ 1708 Render the full chart SVG as a string. 1709 1710 Reads the XML template, substitutes variables, and optionally inlines CSS 1711 variables and minifies the output. 1712 1713 Args: 1714 minify (bool): Remove whitespace and quotes for compactness. 1715 remove_css_variables (bool): Embed CSS variable definitions. 1716 1717 Returns: 1718 str: SVG markup as a string. 1719 """ 1720 td = self._create_template_dictionary() 1721 1722 DATA_DIR = Path(__file__).parent 1723 xml_svg = DATA_DIR / "templates" / "chart.xml" 1724 1725 # read template 1726 with open(xml_svg, "r", encoding="utf-8", errors="ignore") as f: 1727 template = Template(f.read()).substitute(td) 1728 1729 # return filename 1730 1731 logging.debug(f"Template dictionary keys: {td.keys()}") 1732 1733 self._create_template_dictionary() 1734 1735 if remove_css_variables: 1736 template = inline_css_variables_in_svg(template) 1737 1738 if minify: 1739 template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "") 1740 1741 else: 1742 template = template.replace('"', "'") 1743 1744 return template 1745 1746 def makeSVG(self, minify: bool = False, remove_css_variables=False): 1747 """ 1748 Generate and save the full chart SVG to disk. 1749 1750 Calls makeTemplate to render the SVG, then writes a file named 1751 "{subject.name} - {chart_type} Chart.svg" in the output directory. 1752 1753 Args: 1754 minify (bool): Pass-through to makeTemplate for compact output. 1755 remove_css_variables (bool): Pass-through to makeTemplate to embed CSS variables. 1756 1757 Returns: 1758 None 1759 """ 1760 1761 self.template = self.makeTemplate(minify, remove_css_variables) 1762 1763 if self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Lunar": 1764 chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Lunar Return.svg" 1765 elif self.chart_type == "Return" and self.second_obj is not None and hasattr(self.second_obj, 'return_type') and self.second_obj.return_type == "Solar": 1766 chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Solar Return.svg" 1767 else: 1768 chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart.svg" 1769 1770 with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file: 1771 output_file.write(self.template) 1772 1773 print(f"SVG Generated Correctly in: {chartname}") 1774 1775 def makeWheelOnlyTemplate(self, minify: bool = False, remove_css_variables=False): 1776 """ 1777 Render the wheel-only chart SVG as a string. 1778 1779 Reads the wheel-only XML template, substitutes chart data, and applies optional 1780 CSS inlining and minification. 1781 1782 Args: 1783 minify (bool): Remove whitespace and quotes for compactness. 1784 remove_css_variables (bool): Embed CSS variable definitions. 1785 1786 Returns: 1787 str: SVG markup for the chart wheel only. 1788 """ 1789 1790 with open( 1791 Path(__file__).parent / "templates" / "wheel_only.xml", 1792 "r", 1793 encoding="utf-8", 1794 errors="ignore", 1795 ) as f: 1796 template = f.read() 1797 1798 template_dict = self._create_template_dictionary() 1799 template = Template(template).substitute(template_dict) 1800 1801 if remove_css_variables: 1802 template = inline_css_variables_in_svg(template) 1803 1804 if minify: 1805 template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "") 1806 1807 else: 1808 template = template.replace('"', "'") 1809 1810 return template 1811 1812 def makeWheelOnlySVG(self, minify: bool = False, remove_css_variables=False): 1813 """ 1814 Generate and save wheel-only chart SVG to disk. 1815 1816 Calls makeWheelOnlyTemplate and writes a file named 1817 "{subject.name} - {chart_type} Chart - Wheel Only.svg" in the output directory. 1818 1819 Args: 1820 minify (bool): Pass-through to makeWheelOnlyTemplate for compact output. 1821 remove_css_variables (bool): Pass-through to makeWheelOnlyTemplate to embed CSS variables. 1822 1823 Returns: 1824 None 1825 """ 1826 1827 template = self.makeWheelOnlyTemplate(minify, remove_css_variables) 1828 chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Wheel Only.svg" 1829 1830 with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file: 1831 output_file.write(template) 1832 1833 print(f"SVG Generated Correctly in: {chartname}") 1834 1835 def makeAspectGridOnlyTemplate(self, minify: bool = False, remove_css_variables=False): 1836 """ 1837 Render the aspect-grid-only chart SVG as a string. 1838 1839 Reads the aspect-grid XML template, generates the aspect grid based on chart type, 1840 and applies optional CSS inlining and minification. 1841 1842 Args: 1843 minify (bool): Remove whitespace and quotes for compactness. 1844 remove_css_variables (bool): Embed CSS variable definitions. 1845 1846 Returns: 1847 str: SVG markup for the aspect grid only. 1848 """ 1849 1850 with open( 1851 Path(__file__).parent / "templates" / "aspect_grid_only.xml", 1852 "r", 1853 encoding="utf-8", 1854 errors="ignore", 1855 ) as f: 1856 template = f.read() 1857 1858 template_dict = self._create_template_dictionary() 1859 1860 if self.chart_type in ["Transit", "Synastry", "Return"]: 1861 aspects_grid = draw_transit_aspect_grid( 1862 self.chart_colors_settings["paper_0"], 1863 self.available_planets_setting, 1864 self.aspects_list, 1865 ) 1866 else: 1867 aspects_grid = draw_aspect_grid( 1868 self.chart_colors_settings["paper_0"], 1869 self.available_planets_setting, 1870 self.aspects_list, 1871 x_start=50, 1872 y_start=250, 1873 ) 1874 1875 template = Template(template).substitute({**template_dict, "makeAspectGrid": aspects_grid}) 1876 1877 if remove_css_variables: 1878 template = inline_css_variables_in_svg(template) 1879 1880 if minify: 1881 template = scourString(template).replace('"', "'").replace("\n", "").replace("\t", "").replace(" ", "").replace(" ", "") 1882 1883 else: 1884 template = template.replace('"', "'") 1885 1886 return template 1887 1888 def makeAspectGridOnlySVG(self, minify: bool = False, remove_css_variables=False): 1889 """ 1890 Generate and save aspect-grid-only chart SVG to disk. 1891 1892 Calls makeAspectGridOnlyTemplate and writes a file named 1893 "{subject.name} - {chart_type} Chart - Aspect Grid Only.svg" in the output directory. 1894 1895 Args: 1896 minify (bool): Pass-through to makeAspectGridOnlyTemplate for compact output. 1897 remove_css_variables (bool): Pass-through to makeAspectGridOnlyTemplate to embed CSS variables. 1898 1899 Returns: 1900 None 1901 """ 1902 1903 template = self.makeAspectGridOnlyTemplate(minify, remove_css_variables) 1904 chartname = self.output_directory / f"{self.first_obj.name} - {self.chart_type} Chart - Aspect Grid Only.svg" 1905 1906 with open(chartname, "w", encoding="utf-8", errors="ignore") as output_file: 1907 output_file.write(template) 1908 1909 print(f"SVG Generated Correctly in: {chartname}")
ChartDrawer generates astrological chart visualizations as SVG files.
This class supports creating full chart SVGs, wheel-only SVGs, and aspect-grid-only SVGs for various chart types including Natal, ExternalNatal, Transit, Synastry, and Composite. Charts are rendered using XML templates and drawing utilities, with customizable themes, language, active points, and aspects. The rendered SVGs can be saved to a specified output directory or, by default, to the user's home directory.
NOTE: The generated SVG files are optimized for web use, opening in browsers. If you want to use them in other applications, you might need to adjust the SVG settings or styles.
Args: first_obj (AstrologicalSubject | AstrologicalSubjectModel | CompositeSubjectModel): The primary astrological subject for the chart. chart_type (ChartType, optional): The type of chart to generate ('Natal', 'ExternalNatal', 'Transit', 'Synastry', 'Composite'). Defaults to 'Natal'. second_obj (AstrologicalSubject | AstrologicalSubjectModel, optional): The secondary subject for Transit or Synastry charts. Not required for Natal or Composite. new_output_directory (str | Path, optional): Directory to write generated SVG files. Defaults to the user's home directory. new_settings_file (Path | dict | KerykeionSettingsModel, optional): Path or settings object to override default chart configuration (colors, fonts, aspects). theme (KerykeionChartTheme, optional): CSS theme for the chart. If None, no default styles are applied. Defaults to 'classic'. double_chart_aspect_grid_type (Literal['list', 'table'], optional): Specifies rendering style for double-chart aspect grids. Defaults to 'list'. chart_language (KerykeionChartLanguage, optional): Language code for chart labels. Defaults to 'EN'. active_points (list[AstrologicalPoint], optional): List of celestial points and angles to include. Defaults to DEFAULT_ACTIVE_POINTS. Example: ["Sun", "Moon", "Mercury", "Venus"]
active_aspects (list[ActiveAspect], optional):
List of aspects (name and orb) to calculate. Defaults to DEFAULT_ACTIVE_ASPECTS.
Example:
[
{"name": "conjunction", "orb": 10},
{"name": "opposition", "orb": 10},
{"name": "trine", "orb": 8},
{"name": "sextile", "orb": 6},
{"name": "square", "orb": 5},
{"name": "quintile", "orb": 1},
]
Public Methods:
makeTemplate(minify=False, remove_css_variables=False) -> str:
Render the full chart SVG as a string without writing to disk. Use minify=True
to remove whitespace and quotes, and remove_css_variables=True to embed CSS vars.
makeSVG(minify=False, remove_css_variables=False) -> None:
Generate and write the full chart SVG file to the output directory.
Filenames follow the pattern:
'{subject.name} - {chart_type} Chart.svg'.
makeWheelOnlyTemplate(minify=False, remove_css_variables=False) -> str:
Render only the chart wheel (no aspect grid) as an SVG string.
makeWheelOnlySVG(minify=False, remove_css_variables=False) -> None:
Generate and write the wheel-only SVG file:
'{subject.name} - {chart_type} Chart - Wheel Only.svg'.
makeAspectGridOnlyTemplate(minify=False, remove_css_variables=False) -> str:
Render only the aspect grid as an SVG string.
makeAspectGridOnlySVG(minify=False, remove_css_variables=False) -> None:
Generate and write the aspect-grid-only SVG file:
'{subject.name} - {chart_type} Chart - Aspect Grid Only.svg'.
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.
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.
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.
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.
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.
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
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.
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
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.
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
59class CompositeSubjectFactory: 60 """ 61 Factory class to create composite astrological charts from two astrological subjects. 62 63 A composite chart represents the relationship between two people by calculating the midpoint 64 between corresponding planetary positions and house cusps. This creates a single chart 65 that symbolizes the energy of the relationship itself. 66 67 Currently supports the midpoint method for composite chart calculation, where: 68 - Planetary positions are calculated as the circular mean of corresponding planets 69 - House cusps are calculated as the circular mean of corresponding houses 70 - Houses are reordered to maintain consistency with the original house system 71 - Only common active points between both subjects are included 72 73 The resulting composite chart maintains the zodiac type, sidereal mode, houses system, 74 and perspective type of the input subjects (which must be identical between subjects). 75 76 Attributes: 77 model (CompositeSubjectModel | None): The generated composite subject model 78 first_subject (AstrologicalSubjectModel): First astrological subject 79 second_subject (AstrologicalSubjectModel): Second astrological subject 80 name (str): Name of the composite chart 81 composite_chart_type (CompositeChartType): Type of composite chart (currently "Midpoint") 82 zodiac_type (ZodiacType): Zodiac system used (Tropical or Sidereal) 83 sidereal_mode (SiderealMode | None): Sidereal calculation mode if applicable 84 houses_system_identifier (HousesSystemIdentifier): House system identifier 85 houses_system_name (str): Human-readable house system name 86 perspective_type (PerspectiveType): Astrological perspective type 87 houses_names_list (list[Houses]): List of house names 88 active_points (list[AstrologicalPoint]): Common active planetary points 89 90 Example: 91 >>> first_person = AstrologicalSubjectFactory.from_birth_data( 92 ... "John", 1990, 1, 1, 12, 0, "New York", "US" 93 ... ) 94 >>> second_person = AstrologicalSubjectFactory.from_birth_data( 95 ... "Jane", 1992, 6, 15, 14, 30, "Los Angeles", "US" 96 ... ) 97 >>> composite = CompositeSubjectFactory(first_person, second_person) 98 >>> composite_model = composite.get_midpoint_composite_subject_model() 99 100 Raises: 101 KerykeionException: When subjects have incompatible settings (different zodiac types, 102 sidereal modes, house systems, or perspective types) 103 """ 104 105 model: Union[CompositeSubjectModel, None] 106 first_subject: AstrologicalSubjectModel 107 second_subject: AstrologicalSubjectModel 108 name: str 109 composite_chart_type: CompositeChartType 110 zodiac_type: ZodiacType 111 sidereal_mode: Union[SiderealMode, None] 112 houses_system_identifier: HousesSystemIdentifier 113 houses_system_name: str 114 perspective_type: PerspectiveType 115 houses_names_list: list[Houses] 116 active_points: list[AstrologicalPoint] 117 118 def __init__( 119 self, 120 first_subject: AstrologicalSubjectModel, 121 second_subject: AstrologicalSubjectModel, 122 chart_name: Union[str, None] = None 123 ): 124 """ 125 Initialize the composite subject factory with two astrological subjects. 126 127 Validates that both subjects have compatible settings and extracts common 128 active points for composite chart calculation. 129 130 Args: 131 first_subject (AstrologicalSubjectModel): First astrological subject for the composite 132 second_subject (AstrologicalSubjectModel): Second astrological subject for the composite 133 chart_name (str | None, optional): Custom name for the composite chart. 134 If None, generates name from subject names. 135 Defaults to None. 136 137 Raises: 138 KerykeionException: If subjects have different zodiac types, sidereal modes, 139 house systems, house system names, or perspective types. 140 141 Note: 142 Both subjects must have identical astrological calculation settings to ensure 143 meaningful composite chart calculations. 144 """ 145 self.model: Union[CompositeSubjectModel, None] = None 146 self.composite_chart_type = "Midpoint" 147 148 self.first_subject = first_subject 149 self.second_subject = second_subject 150 self.active_points = find_common_active_points( 151 first_subject.active_points, 152 second_subject.active_points 153 ) 154 155 # Name 156 if chart_name is None: 157 self.name = f"{first_subject.name} and {second_subject.name} Composite Chart" 158 else: 159 self.name = chart_name 160 161 # Zodiac Type 162 if first_subject.zodiac_type != second_subject.zodiac_type: 163 raise KerykeionException("Both subjects must have the same zodiac type") 164 self.zodiac_type = first_subject.zodiac_type 165 166 # Sidereal Mode 167 if first_subject.sidereal_mode != second_subject.sidereal_mode: 168 raise KerykeionException("Both subjects must have the same sidereal mode") 169 170 if first_subject.sidereal_mode is not None: 171 self.sidereal_mode = first_subject.sidereal_mode 172 else: 173 self.sidereal_mode = None 174 175 # Houses System 176 if first_subject.houses_system_identifier != second_subject.houses_system_identifier: 177 raise KerykeionException("Both subjects must have the same houses system") 178 self.houses_system_identifier = first_subject.houses_system_identifier 179 180 # Houses System Name 181 if first_subject.houses_system_name != second_subject.houses_system_name: 182 raise KerykeionException("Both subjects must have the same houses system name") 183 self.houses_system_name = first_subject.houses_system_name 184 185 # Perspective Type 186 if first_subject.perspective_type != second_subject.perspective_type: 187 raise KerykeionException("Both subjects must have the same perspective type") 188 self.perspective_type = first_subject.perspective_type 189 190 # Planets Names List 191 self.active_points = [] 192 for planet in first_subject.active_points: 193 if planet in second_subject.active_points: 194 self.active_points.append(planet) 195 196 # Houses Names List 197 self.houses_names_list = self.first_subject.houses_names_list 198 199 def __str__(self): 200 """ 201 Return string representation of the composite subject. 202 203 Returns: 204 str: Human-readable string describing the composite chart. 205 """ 206 return f"Composite Chart Data for {self.name}" 207 208 def __repr__(self): 209 """ 210 Return detailed string representation of the composite subject. 211 212 Returns: 213 str: Detailed string representation for debugging purposes. 214 """ 215 return f"Composite Chart Data for {self.name}" 216 217 def __eq__(self, other): 218 """ 219 Check equality with another composite subject. 220 221 Args: 222 other (CompositeSubjectFactory): Another composite subject to compare with. 223 224 Returns: 225 bool: True if both subjects and chart name are identical. 226 """ 227 return self.first_subject == other.first_subject and self.second_subject == other.second_subject and self.name == other.chart_name 228 229 def __ne__(self, other): 230 """ 231 Check inequality with another composite subject. 232 233 Args: 234 other (CompositeSubjectFactory): Another composite subject to compare with. 235 236 Returns: 237 bool: True if subjects or chart name are different. 238 """ 239 return not self.__eq__(other) 240 241 def __hash__(self): 242 """ 243 Generate hash for the composite subject. 244 245 Returns: 246 int: Hash value based on both subjects and chart name. 247 """ 248 return hash((self.first_subject, self.second_subject, self.name)) 249 250 def __copy__(self): 251 """ 252 Create a shallow copy of the composite subject. 253 254 Returns: 255 CompositeSubjectFactory: New instance with the same subjects and name. 256 """ 257 return CompositeSubjectFactory(self.first_subject, self.second_subject, self.name) 258 259 def __setitem__(self, key, value): 260 """ 261 Set an attribute using dictionary-style access. 262 263 Args: 264 key (str): Attribute name to set. 265 value: Value to assign to the attribute. 266 """ 267 setattr(self, key, value) 268 269 def __getitem__(self, key): 270 """ 271 Get an attribute using dictionary-style access. 272 273 Args: 274 key (str): Attribute name to retrieve. 275 276 Returns: 277 Any: Value of the requested attribute. 278 279 Raises: 280 AttributeError: If the attribute doesn't exist. 281 """ 282 return getattr(self, key) 283 284 def _calculate_midpoint_composite_points_and_houses(self): 285 """ 286 Calculate midpoint positions for all planets and house cusps in the composite chart. 287 288 This method implements the midpoint composite technique by: 289 1. Computing circular means of house cusp positions from both subjects 290 2. Sorting house positions to maintain proper house order 291 3. Creating composite house cusps with calculated positions 292 4. Computing circular means of planetary positions for common active points 293 5. Assigning planets to their appropriate houses in the composite chart 294 295 The circular mean calculation ensures proper handling of zodiacal positions 296 around the 360-degree boundary (e.g., when one position is at 350° and 297 another at 10°, the midpoint is correctly calculated as 0°). 298 299 Side Effects: 300 - Updates instance attributes with calculated house cusp positions 301 - Updates instance attributes with calculated planetary positions 302 - Sets house assignments for each planetary position 303 304 Note: 305 This is an internal method called by get_midpoint_composite_subject_model(). 306 Only planets that exist in both subjects' active_points are included. 307 """ 308 # Houses 309 house_degree_list_ut = [] 310 for house in self.first_subject.houses_names_list: 311 house_lower = house.lower() 312 house_degree_list_ut.append( 313 circular_mean( 314 self.first_subject[house_lower]["abs_pos"], 315 self.second_subject[house_lower]["abs_pos"] 316 ) 317 ) 318 house_degree_list_ut = circular_sort(house_degree_list_ut) 319 320 for house_index, house_name in enumerate(self.first_subject.houses_names_list): 321 house_lower = house_name.lower() 322 self[house_lower] = get_kerykeion_point_from_degree( 323 house_degree_list_ut[house_index], 324 house_name, 325 "House" 326 ) 327 328 329 # Planets 330 common_planets = [] 331 for planet in self.first_subject.active_points: 332 if planet in self.second_subject.active_points: 333 common_planets.append(planet) 334 335 planets = {} 336 for planet in common_planets: 337 planet_lower = planet.lower() 338 planets[planet_lower] = {} 339 planets[planet_lower]["abs_pos"] = circular_mean( 340 self.first_subject[planet_lower]["abs_pos"], 341 self.second_subject[planet_lower]["abs_pos"] 342 ) 343 self[planet_lower] = get_kerykeion_point_from_degree(planets[planet_lower]["abs_pos"], planet, "AstrologicalPoint") 344 self[planet_lower]["house"] = get_planet_house(self[planet_lower]['abs_pos'], house_degree_list_ut) 345 346 def _calculate_composite_lunar_phase(self): 347 """ 348 Calculate the lunar phase for the composite chart based on Sun-Moon midpoints. 349 350 Uses the composite positions of the Sun and Moon to determine the lunar phase 351 angle, representing the relationship's emotional and instinctual dynamics. 352 353 Side Effects: 354 Sets the lunar_phase attribute with the calculated phase information. 355 356 Note: 357 This method should be called after _calculate_midpoint_composite_points_and_houses() 358 to ensure Sun and Moon composite positions are available. 359 """ 360 self.lunar_phase = calculate_moon_phase( 361 self['moon'].abs_pos, 362 self['sun'].abs_pos 363 ) 364 365 def get_midpoint_composite_subject_model(self): 366 """ 367 Generate the complete composite chart model using the midpoint technique. 368 369 This is the main public method for creating a composite chart. It orchestrates 370 the calculation of all composite positions and creates a complete CompositeSubjectModel 371 containing all necessary astrological data for the relationship chart. 372 373 The process includes: 374 1. Calculating midpoint positions for all planets and house cusps 375 2. Computing the composite lunar phase 376 3. Assembling all data into a comprehensive model 377 378 Returns: 379 CompositeSubjectModel: Complete composite chart data model containing: 380 - All calculated planetary positions and their house placements 381 - House cusp positions maintaining proper house system order 382 - Lunar phase information for the composite chart 383 - All metadata from the original subjects (names, chart type, etc.) 384 385 Example: 386 >>> composite = CompositeSubjectFactory(person1, person2, "Our Relationship") 387 >>> model = composite.get_midpoint_composite_subject_model() 388 >>> print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}") 389 390 Note: 391 This method performs all calculations internally and returns a complete, 392 ready-to-use composite chart model suitable for analysis or chart drawing. 393 """ 394 self._calculate_midpoint_composite_points_and_houses() 395 self._calculate_composite_lunar_phase() 396 397 return CompositeSubjectModel( 398 **self.__dict__ 399 )
Factory class to create composite astrological charts from two astrological subjects.
A composite chart represents the relationship between two people by calculating the midpoint between corresponding planetary positions and house cusps. This creates a single chart that symbolizes the energy of the relationship itself.
Currently supports the midpoint method for composite chart calculation, where:
- Planetary positions are calculated as the circular mean of corresponding planets
- House cusps are calculated as the circular mean of corresponding houses
- Houses are reordered to maintain consistency with the original house system
- Only common active points between both subjects are included
The resulting composite chart maintains the zodiac type, sidereal mode, houses system, and perspective type of the input subjects (which must be identical between subjects).
Attributes: model (CompositeSubjectModel | None): The generated composite subject model first_subject (AstrologicalSubjectModel): First astrological subject second_subject (AstrologicalSubjectModel): Second astrological subject name (str): Name of the composite chart composite_chart_type (CompositeChartType): Type of composite chart (currently "Midpoint") zodiac_type (ZodiacType): Zodiac system used (Tropical or Sidereal) sidereal_mode (SiderealMode | None): Sidereal calculation mode if applicable houses_system_identifier (HousesSystemIdentifier): House system identifier houses_system_name (str): Human-readable house system name perspective_type (PerspectiveType): Astrological perspective type houses_names_list (list[Houses]): List of house names active_points (list[AstrologicalPoint]): Common active planetary points
Example:
first_person = AstrologicalSubjectFactory.from_birth_data( ... "John", 1990, 1, 1, 12, 0, "New York", "US" ... ) second_person = AstrologicalSubjectFactory.from_birth_data( ... "Jane", 1992, 6, 15, 14, 30, "Los Angeles", "US" ... ) composite = CompositeSubjectFactory(first_person, second_person) composite_model = composite.get_midpoint_composite_subject_model()
Raises: KerykeionException: When subjects have incompatible settings (different zodiac types, sidereal modes, house systems, or perspective types)
118 def __init__( 119 self, 120 first_subject: AstrologicalSubjectModel, 121 second_subject: AstrologicalSubjectModel, 122 chart_name: Union[str, None] = None 123 ): 124 """ 125 Initialize the composite subject factory with two astrological subjects. 126 127 Validates that both subjects have compatible settings and extracts common 128 active points for composite chart calculation. 129 130 Args: 131 first_subject (AstrologicalSubjectModel): First astrological subject for the composite 132 second_subject (AstrologicalSubjectModel): Second astrological subject for the composite 133 chart_name (str | None, optional): Custom name for the composite chart. 134 If None, generates name from subject names. 135 Defaults to None. 136 137 Raises: 138 KerykeionException: If subjects have different zodiac types, sidereal modes, 139 house systems, house system names, or perspective types. 140 141 Note: 142 Both subjects must have identical astrological calculation settings to ensure 143 meaningful composite chart calculations. 144 """ 145 self.model: Union[CompositeSubjectModel, None] = None 146 self.composite_chart_type = "Midpoint" 147 148 self.first_subject = first_subject 149 self.second_subject = second_subject 150 self.active_points = find_common_active_points( 151 first_subject.active_points, 152 second_subject.active_points 153 ) 154 155 # Name 156 if chart_name is None: 157 self.name = f"{first_subject.name} and {second_subject.name} Composite Chart" 158 else: 159 self.name = chart_name 160 161 # Zodiac Type 162 if first_subject.zodiac_type != second_subject.zodiac_type: 163 raise KerykeionException("Both subjects must have the same zodiac type") 164 self.zodiac_type = first_subject.zodiac_type 165 166 # Sidereal Mode 167 if first_subject.sidereal_mode != second_subject.sidereal_mode: 168 raise KerykeionException("Both subjects must have the same sidereal mode") 169 170 if first_subject.sidereal_mode is not None: 171 self.sidereal_mode = first_subject.sidereal_mode 172 else: 173 self.sidereal_mode = None 174 175 # Houses System 176 if first_subject.houses_system_identifier != second_subject.houses_system_identifier: 177 raise KerykeionException("Both subjects must have the same houses system") 178 self.houses_system_identifier = first_subject.houses_system_identifier 179 180 # Houses System Name 181 if first_subject.houses_system_name != second_subject.houses_system_name: 182 raise KerykeionException("Both subjects must have the same houses system name") 183 self.houses_system_name = first_subject.houses_system_name 184 185 # Perspective Type 186 if first_subject.perspective_type != second_subject.perspective_type: 187 raise KerykeionException("Both subjects must have the same perspective type") 188 self.perspective_type = first_subject.perspective_type 189 190 # Planets Names List 191 self.active_points = [] 192 for planet in first_subject.active_points: 193 if planet in second_subject.active_points: 194 self.active_points.append(planet) 195 196 # Houses Names List 197 self.houses_names_list = self.first_subject.houses_names_list
Initialize the composite subject factory with two astrological subjects.
Validates that both subjects have compatible settings and extracts common active points for composite chart calculation.
Args: first_subject (AstrologicalSubjectModel): First astrological subject for the composite second_subject (AstrologicalSubjectModel): Second astrological subject for the composite chart_name (str | None, optional): Custom name for the composite chart. If None, generates name from subject names. Defaults to None.
Raises: KerykeionException: If subjects have different zodiac types, sidereal modes, house systems, house system names, or perspective types.
Note: Both subjects must have identical astrological calculation settings to ensure meaningful composite chart calculations.
365 def get_midpoint_composite_subject_model(self): 366 """ 367 Generate the complete composite chart model using the midpoint technique. 368 369 This is the main public method for creating a composite chart. It orchestrates 370 the calculation of all composite positions and creates a complete CompositeSubjectModel 371 containing all necessary astrological data for the relationship chart. 372 373 The process includes: 374 1. Calculating midpoint positions for all planets and house cusps 375 2. Computing the composite lunar phase 376 3. Assembling all data into a comprehensive model 377 378 Returns: 379 CompositeSubjectModel: Complete composite chart data model containing: 380 - All calculated planetary positions and their house placements 381 - House cusp positions maintaining proper house system order 382 - Lunar phase information for the composite chart 383 - All metadata from the original subjects (names, chart type, etc.) 384 385 Example: 386 >>> composite = CompositeSubjectFactory(person1, person2, "Our Relationship") 387 >>> model = composite.get_midpoint_composite_subject_model() 388 >>> print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}") 389 390 Note: 391 This method performs all calculations internally and returns a complete, 392 ready-to-use composite chart model suitable for analysis or chart drawing. 393 """ 394 self._calculate_midpoint_composite_points_and_houses() 395 self._calculate_composite_lunar_phase() 396 397 return CompositeSubjectModel( 398 **self.__dict__ 399 )
Generate the complete composite chart model using the midpoint technique.
This is the main public method for creating a composite chart. It orchestrates the calculation of all composite positions and creates a complete CompositeSubjectModel containing all necessary astrological data for the relationship chart.
The process includes:
- Calculating midpoint positions for all planets and house cusps
- Computing the composite lunar phase
- Assembling all data into a comprehensive model
Returns: CompositeSubjectModel: Complete composite chart data model containing: - All calculated planetary positions and their house placements - House cusp positions maintaining proper house system order - Lunar phase information for the composite chart - All metadata from the original subjects (names, chart type, etc.)
Example:
composite = CompositeSubjectFactory(person1, person2, "Our Relationship") model = composite.get_midpoint_composite_subject_model() print(f"Composite Sun at {model.sun.abs_pos}° in House {model.sun.house}")
Note: This method performs all calculations internally and returns a complete, ready-to-use composite chart model suitable for analysis or chart drawing.
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.
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.")
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)
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
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()
46 def __init__(self, 47 first_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"], 48 second_subject: Union["AstrologicalSubjectModel", "PlanetReturnModel"], 49 active_points: list[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS, 50 51 ): 52 """ 53 Initialize the house comparison factory. 54 55 Args: 56 first_subject: First astrological subject for comparison 57 second_subject: Second astrological subject for comparison 58 active_points: List of astrological points to include in analysis. 59 Defaults to standard active points. 60 61 Note: 62 Both subjects must have valid house system data for accurate analysis. 63 """ 64 self.first_subject = first_subject 65 self.second_subject = second_subject 66 self.active_points = active_points
Initialize the house comparison factory.
Args: first_subject: First astrological subject for comparison second_subject: Second astrological subject for comparison active_points: List of astrological points to include in analysis. Defaults to standard active points.
Note: Both subjects must have valid house system data for accurate analysis.
68 def get_house_comparison(self) -> "HouseComparisonModel": 69 """ 70 Generate bidirectional house comparison analysis between the two subjects. 71 72 Calculates where each active astrological point from one subject falls within 73 the house system of the other subject, and vice versa. 74 75 Returns: 76 HouseComparisonModel: Model containing: 77 - first_subject_name: Name of the first subject 78 - second_subject_name: Name of the second subject 79 - first_points_in_second_houses: First subject's points in second subject's houses 80 - second_points_in_first_houses: Second subject's points in first subject's houses 81 82 Note: 83 Analysis scope is determined by the active_points list. Only specified 84 points will be included in the results. 85 """ 86 first_points_in_second_houses = calculate_points_in_reciprocal_houses(self.first_subject, self.second_subject, self.active_points) 87 second_points_in_first_houses = calculate_points_in_reciprocal_houses(self.second_subject, self.first_subject, self.active_points) 88 89 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.
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's points positioned in second subject's houses
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).
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
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:
- Converts the ISO datetime to Julian Day format for astronomical calculations
- Uses Swiss Ephemeris functions (solcross_ut/mooncross_ut) to find the exact return moment when the planet reaches its natal degree and minute
- Creates a complete AstrologicalSubject instance for the calculated return time
- Returns a comprehensive PlanetReturnModel with all chart data
Args: iso_formatted_time (str): Starting datetime in ISO format for the search. Must be a valid ISO 8601 datetime string (e.g., "2024-01-15T10:30:00" or "2024-01-15T10:30:00+00:00"). The method will find the next return occurring after this moment. return_type (ReturnType): Type of planetary return to calculate. Must be either "Solar" for Sun returns or "Lunar" for Moon returns. This determines which planet's return cycle to compute.
Returns: PlanetReturnModel: A comprehensive Pydantic model containing complete astrological chart data for the calculated return moment, including: - Exact return datetime (UTC and local timezone) - All planetary positions at the return moment - House cusps and angles for the return location - Complete astrological subject data with all calculated points - Return type identifier and subject name - Julian Day Number for the return moment
Raises: KerykeionException: If return_type is not "Solar" or "Lunar". ValueError: If iso_formatted_time is not a valid ISO datetime format. SwissEphException: If Swiss Ephemeris calculations fail due to invalid date ranges or astronomical calculation errors.
Examples: Calculate next Solar Return after a specific date:
>>> factory = PlanetaryReturnFactory(subject, ...)
>>> solar_return = factory.next_return_from_iso_formatted_time(
... "2024-06-15T12:00:00",
... "Solar"
... )
>>> print(f"Solar Return: {solar_return.iso_formatted_local_datetime}")
>>> print(f"Sun position: {solar_return.sun.abs_pos}°")
Calculate next Lunar Return with timezone:
>>> lunar_return = factory.next_return_from_iso_formatted_time(
... "2024-01-01T00:00:00+00:00",
... "Lunar"
... )
>>> print(f"Moon return in {lunar_return.tz_str}")
>>> print(f"Return occurs: {lunar_return.iso_formatted_local_datetime}")
Access complete chart data from return:
>>> return_chart = factory.next_return_from_iso_formatted_time(
... datetime.now().isoformat(),
... "Solar"
... )
>>> # Access all planetary positions
>>> for planet in return_chart.planets_list:
... print(f"{planet.name}: {planet.abs_pos}° in {planet.sign}")
>>> # Access house cusps
>>> for house in return_chart.houses_list:
... print(f"House {house.number}: {house.abs_pos}°")
Technical Notes: - Solar returns typically occur within 1-2 days of the natal birthday - Lunar returns occur approximately every 27.3 days (sidereal month) - Return moments are calculated to the second for maximum precision - The method accounts for leap years and varying orbital speeds - Return charts use the factory's configured location, not the natal location
Use Cases: - Annual birthday return chart calculations - Monthly lunar return timing for astrological consultation - Research into planetary cycle patterns and timing - Forecasting and predictive astrology applications - Educational demonstrations of astronomical cycles
See Also: next_return_from_year(): Simplified interface for yearly calculations next_return_from_month_and_year(): Monthly calculation interface
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
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
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
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
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
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.
7class ReportGenerator: 8 """ 9 Create a report for a Kerykeion instance. 10 """ 11 12 report_title: str 13 data_table: str 14 planets_table: str 15 houses_table: str 16 17 def __init__(self, instance: AstrologicalSubjectModel): 18 """ 19 Initialize a new ReportGenerator instance. 20 21 Args: 22 instance: The astrological subject model to create a report for. 23 """ 24 self.instance = instance 25 26 self.get_report_title() 27 self.get_data_table() 28 self.get_planets_table() 29 self.get_houses_table() 30 31 def get_report_title(self) -> None: 32 """Generate the report title based on the subject's name.""" 33 self.report_title = f"\n+- Kerykeion report for {self.instance.name} -+" 34 35 def get_data_table(self) -> None: 36 """ 37 Creates the data table of the report. 38 """ 39 40 main_data = [["Date", "Time", "Location", "Longitude", "Latitude"]] + [ 41 [ 42 f"{self.instance.day}/{self.instance.month}/{self.instance.year}", 43 f"{self.instance.hour}:{self.instance.minute}", 44 f"{self.instance.city}, {self.instance.nation}", 45 self.instance.lng, 46 self.instance.lat, 47 ] 48 ] 49 self.data_table = AsciiTable(main_data).table 50 51 def get_planets_table(self) -> None: 52 """ 53 Creates the planets table. 54 """ 55 56 planets_data = [["AstrologicalPoint", "Sign", "Pos.", "Ret.", "House"]] + [ 57 [ 58 planet.name, 59 planet.sign, 60 round(float(planet.position), 2), 61 ("R" if planet.retrograde else "-"), 62 planet.house, 63 ] 64 for planet in get_available_astrological_points_list(self.instance) 65 ] 66 67 self.planets_table = AsciiTable(planets_data).table 68 69 def get_houses_table(self) -> None: 70 """ 71 Creates the houses table. 72 """ 73 74 houses_data = [["House", "Sign", "Position"]] + [ 75 [house.name, house.sign, round(float(house.position), 2)] for house in get_houses_list(self.instance) 76 ] 77 78 self.houses_table = AsciiTable(houses_data).table 79 80 def get_full_report(self) -> str: 81 """ 82 Returns the full report. 83 """ 84 85 return f"{self.report_title}\n{self.data_table}\n{self.planets_table}\n{self.houses_table}" 86 87 def print_report(self) -> None: 88 """ 89 Print the report. 90 """ 91 92 print(self.get_full_report())
Create a report for a Kerykeion instance.
17 def __init__(self, instance: AstrologicalSubjectModel): 18 """ 19 Initialize a new ReportGenerator instance. 20 21 Args: 22 instance: The astrological subject model to create a report for. 23 """ 24 self.instance = instance 25 26 self.get_report_title() 27 self.get_data_table() 28 self.get_planets_table() 29 self.get_houses_table()
Initialize a new ReportGenerator instance.
Args: instance: The astrological subject model to create a report for.
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.
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.
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.
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.
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.
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
72class TransitsTimeRangeFactory: 73 """ 74 Factory class for calculating astrological transits over time periods. 75 76 This class analyzes the angular relationships (aspects) between transiting 77 celestial bodies and natal chart positions across multiple time points, 78 generating structured transit data for astrological analysis. 79 80 The factory compares ephemeris data points (representing planetary positions 81 at different moments) with a natal chart to identify when specific geometric 82 configurations occur between transiting and natal celestial bodies. 83 84 Args: 85 natal_chart (AstrologicalSubjectModel): The natal chart used as the reference 86 point for transit calculations. All transiting positions are compared 87 against this chart's planetary positions. 88 ephemeris_data_points (List[AstrologicalSubjectModel]): A list of astrological 89 subject models representing different moments in time, typically generated 90 by EphemerisDataFactory. Each point contains planetary positions for 91 a specific date/time. 92 active_points (List[AstrologicalPoint], optional): List of celestial bodies 93 to include in aspect calculations (e.g., Sun, Moon, planets, asteroids). 94 Defaults to DEFAULT_ACTIVE_POINTS. 95 active_aspects (List[ActiveAspect], optional): List of aspect types to 96 calculate (e.g., conjunction, opposition, trine, square, sextile). 97 Defaults to DEFAULT_ACTIVE_ASPECTS. 98 settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional): 99 Configuration settings for calculations. Can be a file path, settings 100 model, dictionary, or None for defaults. Defaults to None. 101 102 Attributes: 103 natal_chart: The reference natal chart for transit calculations. 104 ephemeris_data_points: Time-series planetary position data. 105 active_points: Celestial bodies included in calculations. 106 active_aspects: Aspect types considered for analysis. 107 settings_file: Configuration settings for the calculations. 108 109 Examples: 110 Basic transit calculation: 111 112 >>> natal_chart = AstrologicalSubjectFactory.from_birth_data(...) 113 >>> ephemeris_data = ephemeris_factory.get_ephemeris_data_as_astrological_subjects() 114 >>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data) 115 >>> transits = factory.get_transit_moments() 116 117 Custom configuration: 118 119 >>> from kerykeion.schemas import AstrologicalPoint, ActiveAspect 120 >>> custom_points = ["Sun", "Moon"] 121 >>> custom_aspects = [ActiveAspect.CONJUNCTION, ActiveAspect.OPPOSITION] 122 >>> factory = TransitsTimeRangeFactory( 123 ... natal_chart, ephemeris_data, 124 ... active_points=custom_points, 125 ... active_aspects=custom_aspects 126 ... ) 127 128 Note: 129 - Calculation time scales with the number of ephemeris data points 130 - More active points and aspects increase computational requirements 131 - The natal chart's coordinate system should match the ephemeris data 132 """ 133 134 def __init__( 135 self, 136 natal_chart: AstrologicalSubjectModel, 137 ephemeris_data_points: List[AstrologicalSubjectModel], 138 active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS, 139 active_aspects: List[ActiveAspect] = DEFAULT_ACTIVE_ASPECTS, 140 settings_file: Union[Path, KerykeionSettingsModel, dict, None] = None, 141 ): 142 """ 143 Initialize the TransitsTimeRangeFactory with calculation parameters. 144 145 Sets up the factory with all necessary data and configuration for calculating 146 transits across the specified time period. The natal chart serves as the 147 reference point, while ephemeris data points provide the transiting positions 148 for comparison. 149 150 Args: 151 natal_chart (AstrologicalSubjectModel): Reference natal chart containing 152 the baseline planetary positions for transit calculations. 153 ephemeris_data_points (List[AstrologicalSubjectModel]): Time-ordered list 154 of planetary positions representing different moments in time. 155 Typically generated by EphemerisDataFactory. 156 active_points (List[AstrologicalPoint], optional): Celestial bodies to 157 include in aspect calculations. Determines which planets/points are 158 analyzed for aspects. Defaults to DEFAULT_ACTIVE_POINTS. 159 active_aspects (List[ActiveAspect], optional): Types of angular relationships 160 to calculate between natal and transiting positions. Defaults to 161 DEFAULT_ACTIVE_ASPECTS. 162 settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional): 163 Configuration settings for orb tolerances, calculation methods, and 164 other parameters. Defaults to None (uses system defaults). 165 166 Note: 167 - All ephemeris data points should use the same coordinate system as the natal chart 168 - The order of ephemeris_data_points determines the chronological sequence 169 - Settings affect orb tolerances and calculation precision 170 """ 171 self.natal_chart = natal_chart 172 self.ephemeris_data_points = ephemeris_data_points 173 self.active_points = active_points 174 self.active_aspects = active_aspects 175 self.settings_file = settings_file 176 177 def get_transit_moments(self) -> TransitsTimeRangeModel: 178 """ 179 Calculate and generate transit data for all configured time points. 180 181 This method processes each ephemeris data point to identify angular relationships 182 (aspects) between transiting celestial bodies and natal chart positions. It 183 creates a comprehensive model containing all transit moments with their 184 corresponding aspects and timestamps. 185 186 The calculation process: 187 1. Iterates through each ephemeris data point chronologically 188 2. Compares transiting planetary positions with natal chart positions 189 3. Identifies aspects that fall within the configured orb tolerances 190 4. Creates timestamped transit moment records 191 5. Compiles all data into a structured model for analysis 192 193 Returns: 194 TransitsTimeRangeModel: A comprehensive model containing: 195 - dates (List[str]): ISO-formatted datetime strings for all data points 196 - subject (AstrologicalSubjectModel): The natal chart used as reference 197 - transits (List[TransitMomentModel]): Chronological list of transit moments, 198 each containing: 199 * date (str): ISO-formatted timestamp for the transit moment 200 * aspects (List[RelevantAspect]): All aspects formed at this moment 201 between transiting and natal positions 202 203 Examples: 204 Basic usage: 205 206 >>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data) 207 >>> results = factory.get_transit_moments() 208 >>> 209 >>> # Access specific data 210 >>> all_dates = results.dates 211 >>> first_transit = results.transits[0] 212 >>> aspects_at_first_moment = first_transit.aspects 213 214 Processing results: 215 216 >>> results = factory.get_transit_moments() 217 >>> for transit_moment in results.transits: 218 ... print(f"Date: {transit_moment.date}") 219 ... for aspect in transit_moment.aspects: 220 ... print(f" {aspect.p1_name} {aspect.aspect} {aspect.p2_name}") 221 222 Performance Notes: 223 - Calculation time is proportional to: number of time points × active points × active aspects 224 - Large datasets may require significant processing time 225 - Memory usage scales with the number of aspects found 226 - Consider filtering active_points and active_aspects for better performance 227 228 See Also: 229 TransitMomentModel: Individual transit moment structure 230 TransitsTimeRangeModel: Complete transit dataset structure 231 AspectsFactory: Underlying aspect calculation engine 232 """ 233 transit_moments = [] 234 235 for ephemeris_point in self.ephemeris_data_points: 236 # Calculate aspects between transit positions and natal chart 237 aspects = AspectsFactory.dual_chart_aspects( 238 ephemeris_point, 239 self.natal_chart, 240 active_points=self.active_points, 241 active_aspects=self.active_aspects, 242 ).relevant_aspects 243 244 # Create a transit moment for this point in time 245 transit_moments.append( 246 TransitMomentModel( 247 date=ephemeris_point.iso_formatted_utc_datetime, 248 aspects=aspects, 249 ) 250 ) 251 252 # Create and return the complete transits model 253 return TransitsTimeRangeModel( 254 dates=[point.iso_formatted_utc_datetime for point in self.ephemeris_data_points], 255 subject=self.natal_chart, 256 transits=transit_moments 257 )
Factory class for calculating astrological transits over time periods.
This class analyzes the angular relationships (aspects) between transiting celestial bodies and natal chart positions across multiple time points, generating structured transit data for astrological analysis.
The factory compares ephemeris data points (representing planetary positions at different moments) with a natal chart to identify when specific geometric configurations occur between transiting and natal celestial bodies.
Args: natal_chart (AstrologicalSubjectModel): The natal chart used as the reference point for transit calculations. All transiting positions are compared against this chart's planetary positions. ephemeris_data_points (List[AstrologicalSubjectModel]): A list of astrological subject models representing different moments in time, typically generated by EphemerisDataFactory. Each point contains planetary positions for a specific date/time. active_points (List[AstrologicalPoint], optional): List of celestial bodies to include in aspect calculations (e.g., Sun, Moon, planets, asteroids). Defaults to DEFAULT_ACTIVE_POINTS. active_aspects (List[ActiveAspect], optional): List of aspect types to calculate (e.g., conjunction, opposition, trine, square, sextile). Defaults to DEFAULT_ACTIVE_ASPECTS. settings_file (Union[Path, KerykeionSettingsModel, dict, None], optional): Configuration settings for calculations. Can be a file path, settings model, dictionary, or None for defaults. Defaults to None.
Attributes: natal_chart: The reference natal chart for transit calculations. ephemeris_data_points: Time-series planetary position data. active_points: Celestial bodies included in calculations. active_aspects: Aspect types considered for analysis. settings_file: Configuration settings for the calculations.
Examples: Basic transit calculation:
>>> natal_chart = AstrologicalSubjectFactory.from_birth_data(...)
>>> ephemeris_data = ephemeris_factory.get_ephemeris_data_as_astrological_subjects()
>>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
>>> transits = factory.get_transit_moments()
Custom configuration:
>>> from kerykeion.schemas import AstrologicalPoint, ActiveAspect
>>> custom_points = ["Sun", "Moon"]
>>> custom_aspects = [ActiveAspect.CONJUNCTION, ActiveAspect.OPPOSITION]
>>> factory = TransitsTimeRangeFactory(
... natal_chart, ephemeris_data,
... active_points=custom_points,
... active_aspects=custom_aspects
... )
Note: - Calculation time scales with the number of ephemeris data points - More active points and aspects increase computational requirements - The natal chart's coordinate system should match the ephemeris data
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
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:
- Iterates through each ephemeris data point chronologically
- Compares transiting planetary positions with natal chart positions
- Identifies aspects that fall within the configured orb tolerances
- Creates timestamped transit moment records
- Compiles all data into a structured model for analysis
Returns: TransitsTimeRangeModel: A comprehensive model containing: - dates (List[str]): ISO-formatted datetime strings for all data points - subject (AstrologicalSubjectModel): The natal chart used as reference - transits (List[TransitMomentModel]): Chronological list of transit moments, each containing: * date (str): ISO-formatted timestamp for the transit moment * aspects (List[RelevantAspect]): All aspects formed at this moment between transiting and natal positions
Examples: Basic usage:
>>> factory = TransitsTimeRangeFactory(natal_chart, ephemeris_data)
>>> results = factory.get_transit_moments()
>>>
>>> # Access specific data
>>> all_dates = results.dates
>>> first_transit = results.transits[0]
>>> aspects_at_first_moment = first_transit.aspects
Processing results:
>>> results = factory.get_transit_moments()
>>> for transit_moment in results.transits:
... print(f"Date: {transit_moment.date}")
... for aspect in transit_moment.aspects:
... print(f" {aspect.p1_name} {aspect.aspect} {aspect.p2_name}")
Performance Notes: - Calculation time is proportional to: number of time points × active points × active aspects - Large datasets may require significant processing time - Memory usage scales with the number of aspects found - Consider filtering active_points and active_aspects for better performance
See Also: TransitMomentModel: Individual transit moment structure TransitsTimeRangeModel: Complete transit dataset structure AspectsFactory: Underlying aspect calculation engine