kerykeion 4.26.3__py3-none-any.whl → 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kerykeion might be problematic. Click here for more details.

Files changed (76) hide show
  1. kerykeion/__init__.py +54 -11
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +569 -0
  4. kerykeion/aspects/aspects_utils.py +81 -8
  5. kerykeion/astrological_subject_factory.py +1897 -0
  6. kerykeion/backword.py +773 -0
  7. kerykeion/chart_data_factory.py +549 -0
  8. kerykeion/charts/chart_drawer.py +2601 -0
  9. kerykeion/charts/charts_utils.py +948 -177
  10. kerykeion/charts/draw_planets.py +602 -351
  11. kerykeion/charts/templates/aspect_grid_only.xml +328 -202
  12. kerykeion/charts/templates/chart.xml +432 -272
  13. kerykeion/charts/templates/wheel_only.xml +350 -214
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +107 -76
  16. kerykeion/charts/themes/dark-high-contrast.css +145 -107
  17. kerykeion/charts/themes/dark.css +146 -107
  18. kerykeion/charts/themes/light.css +146 -103
  19. kerykeion/charts/themes/strawberry.css +158 -0
  20. kerykeion/composite_subject_factory.py +253 -51
  21. kerykeion/ephemeris_data_factory.py +434 -0
  22. kerykeion/fetch_geonames.py +27 -8
  23. kerykeion/house_comparison/__init__.py +6 -0
  24. kerykeion/house_comparison/house_comparison_factory.py +103 -0
  25. kerykeion/house_comparison/house_comparison_utils.py +126 -0
  26. kerykeion/kr_types/__init__.py +66 -6
  27. kerykeion/kr_types/chart_template_model.py +20 -0
  28. kerykeion/kr_types/kerykeion_exception.py +15 -9
  29. kerykeion/kr_types/kr_literals.py +14 -132
  30. kerykeion/kr_types/kr_models.py +14 -318
  31. kerykeion/kr_types/settings_models.py +15 -203
  32. kerykeion/planetary_return_factory.py +805 -0
  33. kerykeion/relationship_score_factory.py +301 -0
  34. kerykeion/report.py +751 -64
  35. kerykeion/schemas/__init__.py +106 -0
  36. kerykeion/schemas/chart_template_model.py +367 -0
  37. kerykeion/schemas/kerykeion_exception.py +20 -0
  38. kerykeion/schemas/kr_literals.py +181 -0
  39. kerykeion/schemas/kr_models.py +605 -0
  40. kerykeion/schemas/settings_models.py +180 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +117 -12
  44. kerykeion/settings/kerykeion_settings.py +31 -73
  45. kerykeion/settings/translation_strings.py +1479 -0
  46. kerykeion/settings/translations.py +74 -0
  47. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  48. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  50. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  51. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  52. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  54. kerykeion/sweph/sefstars.txt +1602 -0
  55. kerykeion/transits_time_range_factory.py +302 -0
  56. kerykeion/utilities.py +393 -114
  57. kerykeion-5.0.0.dist-info/METADATA +1176 -0
  58. kerykeion-5.0.0.dist-info/RECORD +63 -0
  59. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info}/WHEEL +1 -1
  60. kerykeion/aspects/natal_aspects.py +0 -172
  61. kerykeion/aspects/synastry_aspects.py +0 -124
  62. kerykeion/aspects/transits_time_range.py +0 -41
  63. kerykeion/astrological_subject.py +0 -841
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -1219
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -242
  67. kerykeion/kr_types/chart_types.py +0 -95
  68. kerykeion/relationship_score/__init__.py +0 -2
  69. kerykeion/relationship_score/relationship_score.py +0 -175
  70. kerykeion/relationship_score/relationship_score_factory.py +0 -230
  71. kerykeion/settings/kr.config.json +0 -1258
  72. kerykeion/transits_time_range.py +0 -124
  73. kerykeion-4.26.3.dist-info/METADATA +0 -634
  74. kerykeion-4.26.3.dist-info/RECORD +0 -45
  75. kerykeion-4.26.3.dist-info/entry_points.txt +0 -3
  76. {kerykeion-4.26.3.dist-info → kerykeion-5.0.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,1897 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Astrological Subject Factory Module
4
+
5
+ This module provides factory classes for creating astrological subjects with comprehensive
6
+ astrological calculations including planetary positions, house cusps, aspects, and various
7
+ astrological points.
8
+
9
+ The main factory class AstrologicalSubjectFactory offers multiple creation methods for
10
+ different initialization scenarios, supporting both online and offline calculation modes,
11
+ various zodiac systems (Tropical/Sidereal), house systems, and coordinate perspectives.
12
+
13
+ Key Features:
14
+ - Planetary position calculations for all traditional and modern planets
15
+ - House cusp calculations with multiple house systems
16
+ - Lunar nodes, Lilith points, asteroids, and trans-Neptunian objects
17
+ - Arabic parts (lots) calculations
18
+ - Fixed star positions
19
+ - Automatic location data fetching via GeoNames API
20
+ - Comprehensive timezone and coordinate handling
21
+ - Flexible point selection for performance optimization
22
+
23
+ Classes:
24
+ ChartConfiguration: Configuration settings for astrological calculations
25
+ LocationData: Geographical location information and utilities
26
+ AstrologicalSubjectFactory: Main factory for creating astrological subjects
27
+
28
+ Author: Giacomo Battaglia
29
+ Copyright: (C) 2025 Kerykeion Project
30
+ License: AGPL-3.0
31
+ """
32
+
33
+ import pytz
34
+ import swisseph as swe
35
+ import logging
36
+ import math
37
+ from datetime import datetime
38
+ from pathlib import Path
39
+ from typing import Optional, List, Dict, Any, get_args, cast
40
+ from dataclasses import dataclass, field
41
+ from contextlib import contextmanager
42
+
43
+
44
+ from kerykeion.fetch_geonames import FetchGeonames
45
+ from kerykeion.schemas import (
46
+ KerykeionException,
47
+ ZodiacType,
48
+ AstrologicalSubjectModel,
49
+ PointType,
50
+ SiderealMode,
51
+ HousesSystemIdentifier,
52
+ PerspectiveType,
53
+ AstrologicalPoint,
54
+ Houses,
55
+ )
56
+ from kerykeion.utilities import (
57
+ get_kerykeion_point_from_degree,
58
+ get_planet_house,
59
+ check_and_adjust_polar_latitude,
60
+ calculate_moon_phase,
61
+ datetime_to_julian,
62
+ get_house_number
63
+ )
64
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
65
+
66
+ # Default configuration values
67
+ DEFAULT_GEONAMES_USERNAME = "century.boy"
68
+ DEFAULT_SIDEREAL_MODE: SiderealMode = "FAGAN_BRADLEY"
69
+ DEFAULT_HOUSES_SYSTEM_IDENTIFIER: HousesSystemIdentifier = "P"
70
+ DEFAULT_ZODIAC_TYPE: ZodiacType = "Tropic"
71
+ DEFAULT_PERSPECTIVE_TYPE: PerspectiveType = "Apparent Geocentric"
72
+ DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS = 30
73
+
74
+ # Warning messages
75
+ GEONAMES_DEFAULT_USERNAME_WARNING = (
76
+ "\n********\n"
77
+ "NO GEONAMES USERNAME SET!\n"
78
+ "Using the default geonames username is not recommended, please set a custom one!\n"
79
+ "You can get one for free here:\n"
80
+ "https://www.geonames.org/login\n"
81
+ "Keep in mind that the default username is limited to 2000 requests per hour and is shared with everyone else using this library.\n"
82
+ "********"
83
+ )
84
+
85
+ @contextmanager
86
+ def ephemeris_context(
87
+ ephe_path: str,
88
+ config: "ChartConfiguration",
89
+ lng: float,
90
+ lat: float,
91
+ alt: Optional[float] = None,
92
+ ):
93
+ """Context manager that isolates Swiss Ephemeris configuration.
94
+
95
+ Responsibilities:
96
+ - Set ephemeris path and calculation flags
97
+ - Configure perspective (true geo / helio / topo)
98
+ - Configure sidereal mode when needed
99
+ - Apply topocentric observer only inside the with-block
100
+ - Yield iflag for calculations
101
+ - Reset topocentric coordinates afterward (defensive)
102
+
103
+ Args:
104
+ ephe_path: Path containing Swiss Ephemeris data files.
105
+ config: Validated chart configuration.
106
+ lng: Observer longitude (used for topocentric charts).
107
+ lat: Observer latitude (used for topocentric charts).
108
+ alt: Observer altitude (meters) for topocentric charts.
109
+
110
+ Yields:
111
+ int: iflag to be passed to swe.calc_ut / swe.fixstar_ut.
112
+ """
113
+ swe.set_ephe_path(ephe_path)
114
+ iflag = swe.FLG_SWIEPH | swe.FLG_SPEED
115
+
116
+ topo_used = False
117
+
118
+ # Perspective configuration
119
+ if config.perspective_type == "True Geocentric":
120
+ iflag |= swe.FLG_TRUEPOS
121
+ elif config.perspective_type == "Heliocentric":
122
+ iflag |= swe.FLG_HELCTR
123
+ elif config.perspective_type == "Topocentric":
124
+ iflag |= swe.FLG_TOPOCTR
125
+ swe.set_topo(lng, lat, alt or 0.0)
126
+ topo_used = True
127
+
128
+ # Sidereal configuration
129
+ if config.zodiac_type == "Sidereal":
130
+ iflag |= swe.FLG_SIDEREAL
131
+ swe.set_sid_mode(getattr(swe, f"SIDM_{config.sidereal_mode}"))
132
+
133
+ try:
134
+ yield iflag
135
+ finally:
136
+ # Defensive cleanup: reset topo if it was set
137
+ if topo_used:
138
+ swe.set_topo(0.0, 0.0, 0.0)
139
+
140
+ @dataclass
141
+ class ChartConfiguration:
142
+ """
143
+ Configuration settings for astrological chart calculations.
144
+
145
+ This class encapsulates all the configuration parameters needed for astrological
146
+ calculations, including zodiac type, coordinate systems, house systems, and
147
+ calculation perspectives. It provides validation to ensure compatible settings
148
+ combinations.
149
+
150
+ Attributes:
151
+ zodiac_type (ZodiacType): The zodiac system to use ('Tropic' or 'Sidereal').
152
+ Defaults to 'Tropic'.
153
+ sidereal_mode (Optional[SiderealMode]): The sidereal calculation mode when using
154
+ sidereal zodiac. Only required/used when zodiac_type is 'Sidereal'.
155
+ Defaults to None (auto-set to FAGAN_BRADLEY for sidereal).
156
+ houses_system_identifier (HousesSystemIdentifier): The house system to use for
157
+ house cusp calculations. Defaults to 'P' (Placidus).
158
+ perspective_type (PerspectiveType): The coordinate perspective for calculations.
159
+ Options include 'Apparent Geocentric', 'True Geocentric', 'Heliocentric',
160
+ or 'Topocentric'. Defaults to 'Apparent Geocentric'.
161
+
162
+ Raises:
163
+ KerykeionException: When invalid configuration combinations are detected,
164
+ such as setting sidereal_mode with tropical zodiac, or using invalid
165
+ enumeration values.
166
+
167
+ Example:
168
+ >>> config = ChartConfiguration(
169
+ ... zodiac_type="Sidereal",
170
+ ... sidereal_mode="LAHIRI",
171
+ ... houses_system_identifier="K",
172
+ ... perspective_type="Topocentric"
173
+ ... )
174
+ """
175
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE
176
+ sidereal_mode: Optional[SiderealMode] = None
177
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER
178
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE
179
+
180
+ def __post_init__(self) -> None:
181
+ self.validate()
182
+
183
+ def validate(self) -> None:
184
+ """
185
+ Validate configuration settings for internal consistency.
186
+
187
+ Performs comprehensive validation of all configuration parameters to ensure
188
+ they form a valid, compatible combination. This includes checking enumeration
189
+ values, zodiac/sidereal mode compatibility, and setting defaults where needed.
190
+
191
+ Raises:
192
+ KerykeionException: If any configuration parameter is invalid or if
193
+ incompatible parameter combinations are detected.
194
+
195
+ Side Effects:
196
+ - Sets default sidereal_mode to FAGAN_BRADLEY if zodiac_type is Sidereal
197
+ and no sidereal_mode is specified
198
+ - Logs informational message when setting default sidereal mode
199
+ """
200
+ # Validate zodiac type
201
+ if self.zodiac_type not in get_args(ZodiacType):
202
+ raise KerykeionException(
203
+ f"'{self.zodiac_type}' is not a valid zodiac type! Available types are: {get_args(ZodiacType)}"
204
+ )
205
+
206
+ # Validate sidereal mode settings
207
+ if self.sidereal_mode and self.zodiac_type == "Tropic":
208
+ raise KerykeionException("You can't set a sidereal mode with a Tropic zodiac type!")
209
+
210
+ if self.zodiac_type == "Sidereal":
211
+ if not self.sidereal_mode:
212
+ self.sidereal_mode = DEFAULT_SIDEREAL_MODE
213
+ logging.info("No sidereal mode set, using default FAGAN_BRADLEY")
214
+ elif self.sidereal_mode not in get_args(SiderealMode):
215
+ raise KerykeionException(
216
+ f"'{self.sidereal_mode}' is not a valid sidereal mode! Available modes are: {get_args(SiderealMode)}"
217
+ )
218
+
219
+ # Validate houses system
220
+ if self.houses_system_identifier not in get_args(HousesSystemIdentifier):
221
+ raise KerykeionException(
222
+ f"'{self.houses_system_identifier}' is not a valid house system! Available systems are: {get_args(HousesSystemIdentifier)}"
223
+ )
224
+
225
+ # Validate perspective type
226
+ if self.perspective_type not in get_args(PerspectiveType):
227
+ raise KerykeionException(
228
+ f"'{self.perspective_type}' is not a valid chart perspective! Available perspectives are: {get_args(PerspectiveType)}"
229
+ )
230
+
231
+
232
+ @dataclass
233
+ class LocationData:
234
+ """
235
+ Information about a geographical location for astrological calculations.
236
+
237
+ This class handles all location-related data including coordinates, timezone
238
+ information, and interaction with the GeoNames API for automatic location
239
+ data retrieval. It provides methods for fetching location data online and
240
+ preparing coordinates for astrological calculations.
241
+
242
+ Attributes:
243
+ city (str): Name of the city or location. Defaults to "Greenwich".
244
+ nation (str): ISO country code (2-letter). Defaults to "GB" (United Kingdom).
245
+ lat (float): Latitude in decimal degrees. Positive for North, negative for South.
246
+ Defaults to 51.5074 (Greenwich).
247
+ lng (float): Longitude in decimal degrees. Positive for East, negative for West.
248
+ Defaults to 0.0 (Greenwich).
249
+ tz_str (str): IANA timezone identifier (e.g., 'Europe/London', 'America/New_York').
250
+ Defaults to "Etc/GMT".
251
+ altitude (Optional[float]): Altitude above sea level in meters. Used for
252
+ topocentric calculations. Defaults to None (sea level assumed).
253
+ city_data (Dict[str, str]): Raw data retrieved from GeoNames API. Used internally
254
+ for caching and validation. Defaults to empty dictionary.
255
+
256
+ Note:
257
+ When using online mode, the initial coordinate and timezone values may be
258
+ overridden by data fetched from the GeoNames API based on city and nation.
259
+ For polar regions, latitude values are automatically adjusted to prevent
260
+ calculation errors.
261
+
262
+ Example:
263
+ >>> location = LocationData(city="Rome", nation="IT")
264
+ >>> location.fetch_from_geonames("your_username", 30)
265
+ >>> location.prepare_for_calculation()
266
+ """
267
+ city: str = "Greenwich"
268
+ nation: str = "GB"
269
+ lat: float = 51.5074
270
+ lng: float = 0.0
271
+ tz_str: str = "Etc/GMT"
272
+ altitude: Optional[float] = None
273
+
274
+ # Storage for city data fetched from geonames
275
+ city_data: Dict[str, str] = field(default_factory=dict)
276
+
277
+ def fetch_from_geonames(self, username: str, cache_expire_after_days: int) -> None:
278
+ """
279
+ Fetch location data from GeoNames API.
280
+
281
+ Retrieves accurate coordinates, timezone, and country code information
282
+ for the specified city and country from the GeoNames web service.
283
+ Updates the instance attributes with the fetched data.
284
+
285
+ Args:
286
+ username (str): GeoNames API username. Must be registered at geonames.org.
287
+ Free accounts are limited to 2000 requests per hour.
288
+ cache_expire_after_days (int): Number of days to cache the location data
289
+ locally before refreshing from the API.
290
+
291
+ Raises:
292
+ KerykeionException: If required data is missing from the API response,
293
+ typically due to network issues, invalid location names, or API limits.
294
+
295
+ Side Effects:
296
+ - Updates city_data with raw API response
297
+ - Updates nation, lng, lat, and tz_str with fetched values
298
+ - May create or update local cache files
299
+
300
+ Note:
301
+ The method validates that all required fields (countryCode, timezonestr,
302
+ lat, lng) are present in the API response before updating instance attributes.
303
+ """
304
+ logging.info(f"Fetching timezone/coordinates for {self.city}, {self.nation} from geonames")
305
+
306
+ geonames = FetchGeonames(
307
+ self.city,
308
+ self.nation,
309
+ username=username,
310
+ cache_expire_after_days=cache_expire_after_days
311
+ )
312
+
313
+ self.city_data = geonames.get_serialized_data()
314
+
315
+ # Validate data
316
+ required_fields = ["countryCode", "timezonestr", "lat", "lng"]
317
+ missing_fields = [field for field in required_fields if field not in self.city_data]
318
+
319
+ if missing_fields:
320
+ raise KerykeionException(
321
+ f"Missing data from geonames: {', '.join(missing_fields)}. "
322
+ "Check your connection or try a different location."
323
+ )
324
+
325
+ # Update location data
326
+ self.nation = self.city_data["countryCode"]
327
+ self.lng = float(self.city_data["lng"])
328
+ self.lat = float(self.city_data["lat"])
329
+ self.tz_str = self.city_data["timezonestr"]
330
+
331
+ def prepare_for_calculation(self) -> None:
332
+ """
333
+ Prepare location data for astrological calculations.
334
+
335
+ Performs final adjustments to location data to ensure compatibility
336
+ with Swiss Ephemeris calculations. This includes handling special cases
337
+ like polar regions where extreme latitudes can cause calculation errors.
338
+
339
+ Side Effects:
340
+ - Adjusts latitude values for polar regions (beyond ±66.5°) to
341
+ prevent Swiss Ephemeris calculation failures
342
+ - May log warnings about latitude adjustments
343
+
344
+ Note:
345
+ This method should be called after all location data has been set,
346
+ either manually or via fetch_from_geonames(), and before performing
347
+ any astrological calculations.
348
+ """
349
+ # Adjust latitude for polar regions
350
+ self.lat = check_and_adjust_polar_latitude(self.lat)
351
+
352
+
353
+ class AstrologicalSubjectFactory:
354
+ """
355
+ Factory class for creating comprehensive astrological subjects.
356
+
357
+ This factory creates AstrologicalSubjectModel instances with complete astrological
358
+ information including planetary positions, house cusps, aspects, lunar phases, and
359
+ various specialized astrological points. It provides multiple class methods for
360
+ different initialization scenarios and supports both online and offline calculation modes.
361
+
362
+ The factory handles complex astrological calculations using the Swiss Ephemeris library,
363
+ supports multiple coordinate systems and house systems, and can automatically fetch
364
+ location data from online sources.
365
+
366
+ Supported Astrological Points:
367
+ - Traditional Planets: Sun through Pluto
368
+ - Lunar Nodes: Mean and True North/South Nodes
369
+ - Lilith Points: Mean and True Black Moon
370
+ - Asteroids: Ceres, Pallas, Juno, Vesta
371
+ - Centaurs: Chiron, Pholus
372
+ - Trans-Neptunian Objects: Eris, Sedna, Haumea, Makemake, Ixion, Orcus, Quaoar
373
+ - Fixed Stars: Regulus, Spica (extensible)
374
+ - Arabic Parts: Pars Fortunae, Pars Spiritus, Pars Amoris, Pars Fidei
375
+ - Special Points: Vertex, Anti-Vertex, Earth (for heliocentric charts)
376
+ - House Cusps: All 12 houses with configurable house systems
377
+ - Angles: Ascendant, Medium Coeli, Descendant, Imum Coeli
378
+
379
+ Supported Features:
380
+ - Multiple zodiac systems (Tropical/Sidereal with various ayanamshas)
381
+ - Multiple house systems (Placidus, Koch, Equal, Whole Sign, etc.)
382
+ - Multiple coordinate perspectives (Geocentric, Heliocentric, Topocentric)
383
+ - Automatic timezone and coordinate resolution via GeoNames API
384
+ - Lunar phase calculations
385
+ - Day/night chart detection for Arabic parts
386
+ - Performance optimization through selective point calculation
387
+ - Comprehensive error handling and validation
388
+
389
+ Class Methods:
390
+ from_birth_data: Create subject from standard birth data (most flexible)
391
+ from_iso_utc_time: Create subject from ISO UTC timestamp
392
+ from_current_time: Create subject for current moment
393
+
394
+ Example:
395
+ >>> # Create natal chart
396
+ >>> subject = AstrologicalSubjectFactory.from_birth_data(
397
+ ... name="John Doe",
398
+ ... year=1990, month=6, day=15,
399
+ ... hour=14, minute=30,
400
+ ... city="Rome", nation="IT",
401
+ ... online=True
402
+ ... )
403
+ >>> print(f"Sun: {subject.sun.sign} {subject.sun.abs_pos}°")
404
+ >>> print(f"Active points: {len(subject.active_points)}")
405
+
406
+ >>> # Create chart for current time
407
+ >>> now_subject = AstrologicalSubjectFactory.from_current_time(
408
+ ... name="Current Moment",
409
+ ... city="London", nation="GB"
410
+ ... )
411
+
412
+ Thread Safety:
413
+ This factory is not thread-safe due to its use of the Swiss Ephemeris library
414
+ which maintains global state. Use separate instances in multi-threaded applications
415
+ or implement appropriate locking mechanisms.
416
+ """
417
+
418
+ @classmethod
419
+ def from_birth_data(
420
+ cls,
421
+ name: str = "Now",
422
+ year: Optional[int] = None,
423
+ month: Optional[int] = None,
424
+ day: Optional[int] = None,
425
+ hour: Optional[int] = None,
426
+ minute: Optional[int] = None,
427
+ city: Optional[str] = None,
428
+ nation: Optional[str] = None,
429
+ lng: Optional[float] = None,
430
+ lat: Optional[float] = None,
431
+ tz_str: Optional[str] = None,
432
+ geonames_username: Optional[str] = None,
433
+ online: bool = True,
434
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
435
+ sidereal_mode: Optional[SiderealMode] = None,
436
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
437
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
438
+ cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
439
+ is_dst: Optional[bool] = None,
440
+ altitude: Optional[float] = None,
441
+ active_points: Optional[List[AstrologicalPoint]] = None,
442
+ calculate_lunar_phase: bool = True,
443
+ *,
444
+ seconds: int = 0,
445
+ suppress_geonames_warning: bool = False,
446
+
447
+ ) -> AstrologicalSubjectModel:
448
+ """
449
+ Create an astrological subject from standard birth or event data.
450
+
451
+ This is the most flexible and commonly used factory method. It creates a complete
452
+ astrological subject with planetary positions, house cusps, and specialized points
453
+ for a specific date, time, and location. Supports both online location resolution
454
+ and offline calculation modes.
455
+
456
+ Args:
457
+ name (str, optional): Name or identifier for the subject. Defaults to "Now".
458
+ year (int, optional): Year of birth/event. Defaults to current year.
459
+ month (int, optional): Month of birth/event (1-12). Defaults to current month.
460
+ day (int, optional): Day of birth/event (1-31). Defaults to current day.
461
+ hour (int, optional): Hour of birth/event (0-23). Defaults to current hour.
462
+ minute (int, optional): Minute of birth/event (0-59). Defaults to current minute.
463
+ seconds (int, optional): Seconds of birth/event (0-59). Defaults to 0.
464
+ city (str, optional): City name for location lookup. Used with online=True.
465
+ Defaults to None (Greenwich if not specified).
466
+ nation (str, optional): ISO country code (e.g., 'US', 'GB', 'IT'). Used with
467
+ online=True. Defaults to None ('GB' if not specified).
468
+ lng (float, optional): Longitude in decimal degrees. East is positive, West
469
+ is negative. If not provided and online=True, fetched from GeoNames.
470
+ lat (float, optional): Latitude in decimal degrees. North is positive, South
471
+ is negative. If not provided and online=True, fetched from GeoNames.
472
+ tz_str (str, optional): IANA timezone identifier (e.g., 'Europe/London').
473
+ If not provided and online=True, fetched from GeoNames.
474
+ geonames_username (str, optional): Username for GeoNames API. Required for
475
+ online location lookup. Get one free at geonames.org.
476
+ online (bool, optional): Whether to fetch location data online. If False,
477
+ lng, lat, and tz_str must be provided. Defaults to True.
478
+ zodiac_type (ZodiacType, optional): Zodiac system - 'Tropic' or 'Sidereal'.
479
+ Defaults to 'Tropic'.
480
+ sidereal_mode (SiderealMode, optional): Sidereal calculation mode (e.g.,
481
+ 'FAGAN_BRADLEY', 'LAHIRI'). Only used with zodiac_type='Sidereal'.
482
+ houses_system_identifier (HousesSystemIdentifier, optional): House system
483
+ for cusp calculations (e.g., 'P'=Placidus, 'K'=Koch, 'E'=Equal).
484
+ Defaults to 'P' (Placidus).
485
+ perspective_type (PerspectiveType, optional): Calculation perspective:
486
+ - 'Apparent Geocentric': Standard geocentric with light-time correction
487
+ - 'True Geocentric': Geometric geocentric positions
488
+ - 'Heliocentric': Sun-centered coordinates
489
+ - 'Topocentric': Earth surface perspective (requires altitude)
490
+ Defaults to 'Apparent Geocentric'.
491
+ cache_expire_after_days (int, optional): Days to cache GeoNames data locally.
492
+ Defaults to 30.
493
+ is_dst (bool, optional): Daylight Saving Time flag for ambiguous times.
494
+ If None, pytz attempts automatic detection. Set explicitly for
495
+ times during DST transitions.
496
+ altitude (float, optional): Altitude above sea level in meters. Used for
497
+ topocentric calculations and atmospheric corrections. Defaults to None
498
+ (sea level assumed).
499
+ active_points (Optional[List[AstrologicalPoint]], optional): List of astrological
500
+ points to calculate. Omitting points can improve performance for
501
+ specialized applications. If None, uses DEFAULT_ACTIVE_POINTS.
502
+ calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
503
+ Requires Sun and Moon in active_points. Defaults to True.
504
+ suppress_geonames_warning (bool, optional): If True, suppresses the warning
505
+ message when using the default GeoNames username. Useful for testing
506
+ or automated processes. Defaults to False.
507
+
508
+ Returns:
509
+ AstrologicalSubjectModel: Complete astrological subject with calculated
510
+ positions, houses, and metadata. Access planetary positions via
511
+ attributes like .sun, .moon, .mercury, etc.
512
+
513
+ Raises:
514
+ KerykeionException:
515
+ - If offline mode is used without required location data
516
+ - If invalid zodiac/sidereal mode combinations are specified
517
+ - If GeoNames data is missing or invalid
518
+ - If timezone localization fails (ambiguous DST times)
519
+
520
+ Examples:
521
+ >>> # Basic natal chart with online location lookup
522
+ >>> chart = AstrologicalSubjectFactory.from_birth_data(
523
+ ... name="Jane Doe",
524
+ ... year=1985, month=3, day=21,
525
+ ... hour=15, minute=30,
526
+ ... city="Paris", nation="FR",
527
+ ... geonames_username="your_username"
528
+ ... )
529
+
530
+ >>> # Offline calculation with manual coordinates
531
+ >>> chart = AstrologicalSubjectFactory.from_birth_data(
532
+ ... name="John Smith",
533
+ ... year=1990, month=12, day=25,
534
+ ... hour=0, minute=0,
535
+ ... lng=-74.006, lat=40.7128, tz_str="America/New_York",
536
+ ... online=False
537
+ ... )
538
+
539
+ >>> # Sidereal chart with specific points
540
+ >>> chart = AstrologicalSubjectFactory.from_birth_data(
541
+ ... name="Vedic Chart",
542
+ ... year=2000, month=6, day=15, hour=12,
543
+ ... city="Mumbai", nation="IN",
544
+ ... zodiac_type="Sidereal",
545
+ ... sidereal_mode="LAHIRI",
546
+ ... active_points=["Sun", "Moon", "Mercury", "Venus", "Mars",
547
+ ... "Jupiter", "Saturn", "Ascendant"]
548
+ ... )
549
+
550
+ Note:
551
+ - For high-precision calculations, consider providing seconds parameter
552
+ - Use topocentric perspective for observer-specific calculations
553
+ - Some Arabic parts automatically activate required base points
554
+ - The method handles polar regions by adjusting extreme latitudes
555
+ - Time zones are handled with full DST awareness via pytz
556
+ """
557
+ # Resolve time defaults using current time
558
+ if year is None or month is None or day is None or hour is None or minute is None or seconds is None:
559
+ now = datetime.now()
560
+ year = year if year is not None else now.year
561
+ month = month if month is not None else now.month
562
+ day = day if day is not None else now.day
563
+ hour = hour if hour is not None else now.hour
564
+ minute = minute if minute is not None else now.minute
565
+ seconds = seconds if seconds is not None else now.second
566
+
567
+ # Create a calculation data container
568
+ calc_data: Dict[str, Any] = {}
569
+
570
+ # Basic identity
571
+ calc_data["name"] = name
572
+ calc_data["json_dir"] = str(Path.home())
573
+
574
+ # Create a deep copy of active points to avoid modifying the original list
575
+ if active_points is None:
576
+ active_points_list: List[AstrologicalPoint] = list(DEFAULT_ACTIVE_POINTS)
577
+ else:
578
+ active_points_list = list(active_points)
579
+
580
+ calc_data["active_points"] = active_points_list
581
+
582
+ # Initialize configuration
583
+ config = ChartConfiguration(
584
+ zodiac_type=zodiac_type,
585
+ sidereal_mode=sidereal_mode,
586
+ houses_system_identifier=houses_system_identifier,
587
+ perspective_type=perspective_type,
588
+ )
589
+
590
+ # Add configuration data to calculation data
591
+ calc_data["zodiac_type"] = config.zodiac_type
592
+ calc_data["sidereal_mode"] = config.sidereal_mode
593
+ calc_data["houses_system_identifier"] = config.houses_system_identifier
594
+ calc_data["perspective_type"] = config.perspective_type
595
+
596
+ # Set up geonames username if needed
597
+ if geonames_username is None and online and (not lat or not lng or not tz_str):
598
+ if not suppress_geonames_warning:
599
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
600
+ geonames_username = DEFAULT_GEONAMES_USERNAME
601
+
602
+ # Initialize location data
603
+ location = LocationData(
604
+ city=city or "Greenwich",
605
+ nation=nation or "GB",
606
+ lat=lat if lat is not None else 51.5074,
607
+ lng=lng if lng is not None else 0.0,
608
+ tz_str=tz_str or "Etc/GMT",
609
+ altitude=altitude
610
+ )
611
+
612
+ # If offline mode is requested but required data is missing, raise error
613
+ if not online and (not tz_str or lat is None or lng is None):
614
+ raise KerykeionException(
615
+ "For offline mode, you must provide timezone (tz_str) and coordinates (lat, lng)"
616
+ )
617
+
618
+ # Fetch location data if needed
619
+ if online and (not tz_str or lat is None or lng is None):
620
+ location.fetch_from_geonames(
621
+ username=geonames_username or DEFAULT_GEONAMES_USERNAME,
622
+ cache_expire_after_days=cache_expire_after_days
623
+ )
624
+
625
+ # Prepare location for calculations
626
+ location.prepare_for_calculation()
627
+
628
+ # Add location data to calculation data
629
+ calc_data["city"] = location.city
630
+ calc_data["nation"] = location.nation
631
+ calc_data["lat"] = location.lat
632
+ calc_data["lng"] = location.lng
633
+ calc_data["tz_str"] = location.tz_str
634
+ calc_data["altitude"] = location.altitude
635
+
636
+ # Store calculation parameters
637
+ calc_data["year"] = year
638
+ calc_data["month"] = month
639
+ calc_data["day"] = day
640
+ calc_data["hour"] = hour
641
+ calc_data["minute"] = minute
642
+ calc_data["seconds"] = seconds
643
+ calc_data["is_dst"] = is_dst
644
+
645
+ # Calculate time conversions
646
+ AstrologicalSubjectFactory._calculate_time_conversions(calc_data, location)
647
+ # Initialize Swiss Ephemeris and calculate houses and planets with context manager
648
+ ephe_path = str(Path(__file__).parent.absolute() / "sweph")
649
+ with ephemeris_context(
650
+ ephe_path=ephe_path,
651
+ config=config,
652
+ lng=calc_data["lng"],
653
+ lat=calc_data["lat"],
654
+ alt=calc_data["altitude"],
655
+ ) as iflag:
656
+ calc_data["_iflag"] = iflag
657
+ # House system name (previously set in _setup_ephemeris)
658
+ calc_data["houses_system_name"] = swe.house_name(
659
+ config.houses_system_identifier.encode("ascii")
660
+ )
661
+ calculated_axial_cusps = AstrologicalSubjectFactory._calculate_houses(
662
+ calc_data, active_points_list
663
+ )
664
+ AstrologicalSubjectFactory._calculate_planets(
665
+ calc_data, active_points_list, calculated_axial_cusps
666
+ )
667
+ AstrologicalSubjectFactory._calculate_day_of_week(calc_data)
668
+
669
+ # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
670
+ if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
671
+ calc_data["lunar_phase"] = calculate_moon_phase(
672
+ calc_data["moon"].abs_pos, # type: ignore[attr-defined,union-attr]
673
+ calc_data["sun"].abs_pos # type: ignore[attr-defined,union-attr]
674
+ )
675
+ else:
676
+ calc_data["lunar_phase"] = None
677
+
678
+ # Create and return the AstrologicalSubjectModel
679
+ return AstrologicalSubjectModel(**calc_data)
680
+
681
+ @classmethod
682
+ def from_iso_utc_time(
683
+ cls,
684
+ name: str,
685
+ iso_utc_time: str,
686
+ city: str = "Greenwich",
687
+ nation: str = "GB",
688
+ tz_str: str = "Etc/GMT",
689
+ online: bool = True,
690
+ lng: float = 0.0,
691
+ lat: float = 51.5074,
692
+ geonames_username: str = DEFAULT_GEONAMES_USERNAME,
693
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
694
+ sidereal_mode: Optional[SiderealMode] = None,
695
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
696
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
697
+ altitude: Optional[float] = None,
698
+ active_points: Optional[List[AstrologicalPoint]] = None,
699
+ calculate_lunar_phase: bool = True,
700
+ suppress_geonames_warning: bool = False
701
+ ) -> AstrologicalSubjectModel:
702
+ """
703
+ Create an astrological subject from an ISO formatted UTC timestamp.
704
+
705
+ This method is ideal for creating astrological subjects from standardized
706
+ time formats, such as those stored in databases or received from APIs.
707
+ It automatically handles timezone conversion from UTC to the specified
708
+ local timezone.
709
+
710
+ Args:
711
+ name (str): Name or identifier for the subject.
712
+ iso_utc_time (str): ISO 8601 formatted UTC timestamp. Supported formats:
713
+ - "2023-06-15T14:30:00Z" (with Z suffix)
714
+ - "2023-06-15T14:30:00+00:00" (with UTC offset)
715
+ - "2023-06-15T14:30:00.123Z" (with milliseconds)
716
+ city (str, optional): City name for location. Defaults to "Greenwich".
717
+ nation (str, optional): ISO country code. Defaults to "GB".
718
+ tz_str (str, optional): IANA timezone identifier for result conversion.
719
+ The ISO time is assumed to be in UTC and will be converted to this
720
+ timezone. Defaults to "Etc/GMT".
721
+ online (bool, optional): Whether to fetch coordinates online. If True,
722
+ coordinates are fetched via GeoNames API. Defaults to True.
723
+ lng (float, optional): Longitude in decimal degrees. Used when online=False
724
+ or as fallback. Defaults to 0.0 (Greenwich).
725
+ lat (float, optional): Latitude in decimal degrees. Used when online=False
726
+ or as fallback. Defaults to 51.5074 (Greenwich).
727
+ geonames_username (str, optional): GeoNames API username. Required when
728
+ online=True. Defaults to DEFAULT_GEONAMES_USERNAME.
729
+ zodiac_type (ZodiacType, optional): Zodiac system. Defaults to 'Tropic'.
730
+ sidereal_mode (SiderealMode, optional): Sidereal mode when zodiac_type
731
+ is 'Sidereal'. Defaults to None.
732
+ houses_system_identifier (HousesSystemIdentifier, optional): House system.
733
+ Defaults to 'P' (Placidus).
734
+ perspective_type (PerspectiveType, optional): Calculation perspective.
735
+ Defaults to 'Apparent Geocentric'.
736
+ altitude (float, optional): Altitude in meters for topocentric calculations.
737
+ Defaults to None (sea level).
738
+ active_points (Optional[List[AstrologicalPoint]], optional): Points to calculate.
739
+ If None, uses DEFAULT_ACTIVE_POINTS.
740
+ calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
741
+ Defaults to True.
742
+
743
+ Returns:
744
+ AstrologicalSubjectModel: Astrological subject with positions calculated
745
+ for the specified UTC time converted to local timezone.
746
+
747
+ Raises:
748
+ ValueError: If the ISO timestamp format is invalid or cannot be parsed.
749
+ KerykeionException: If location data cannot be fetched or is invalid.
750
+
751
+ Examples:
752
+ >>> # From API timestamp with online location lookup
753
+ >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
754
+ ... name="Event Chart",
755
+ ... iso_utc_time="2023-12-25T12:00:00Z",
756
+ ... city="Tokyo", nation="JP",
757
+ ... tz_str="Asia/Tokyo",
758
+ ... geonames_username="your_username"
759
+ ... )
760
+
761
+ >>> # From database timestamp with manual coordinates
762
+ >>> subject = AstrologicalSubjectFactory.from_iso_utc_time(
763
+ ... name="Historical Event",
764
+ ... iso_utc_time="1969-07-20T20:17:00Z",
765
+ ... lng=-95.0969, lat=37.4419, # Houston
766
+ ... tz_str="America/Chicago",
767
+ ... online=False
768
+ ... )
769
+
770
+ Note:
771
+ - The method assumes the input timestamp is in UTC
772
+ - Local time conversion respects DST rules for the target timezone
773
+ - Milliseconds in the timestamp are supported but truncated to seconds
774
+ - When online=True, the city/nation parameters override lng/lat
775
+ """
776
+ # Parse the ISO time
777
+ dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
778
+
779
+ # Get location data if online mode is enabled
780
+ if online:
781
+ if geonames_username == DEFAULT_GEONAMES_USERNAME:
782
+ if not suppress_geonames_warning:
783
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
784
+
785
+ geonames = FetchGeonames(
786
+ city,
787
+ nation,
788
+ username=geonames_username,
789
+ )
790
+
791
+ city_data = geonames.get_serialized_data()
792
+ lng = float(city_data["lng"])
793
+ lat = float(city_data["lat"])
794
+
795
+ # Convert UTC to local time
796
+ local_time = pytz.timezone(tz_str)
797
+ local_datetime = dt.astimezone(local_time)
798
+
799
+ # Create the subject with local time
800
+ return cls.from_birth_data(
801
+ name=name,
802
+ year=local_datetime.year,
803
+ month=local_datetime.month,
804
+ day=local_datetime.day,
805
+ hour=local_datetime.hour,
806
+ minute=local_datetime.minute,
807
+ seconds=local_datetime.second,
808
+ city=city,
809
+ nation=nation,
810
+ lng=lng,
811
+ lat=lat,
812
+ tz_str=tz_str,
813
+ online=False, # Already fetched data if needed
814
+ geonames_username=geonames_username,
815
+ zodiac_type=zodiac_type,
816
+ sidereal_mode=sidereal_mode,
817
+ houses_system_identifier=houses_system_identifier,
818
+ perspective_type=perspective_type,
819
+ altitude=altitude,
820
+ active_points=active_points,
821
+ calculate_lunar_phase=calculate_lunar_phase,
822
+ suppress_geonames_warning=suppress_geonames_warning
823
+ )
824
+
825
+ @classmethod
826
+ def from_current_time(
827
+ cls,
828
+ name: str = "Now",
829
+ city: Optional[str] = None,
830
+ nation: Optional[str] = None,
831
+ lng: Optional[float] = None,
832
+ lat: Optional[float] = None,
833
+ tz_str: Optional[str] = None,
834
+ geonames_username: Optional[str] = None,
835
+ online: bool = True,
836
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
837
+ sidereal_mode: Optional[SiderealMode] = None,
838
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
839
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
840
+ active_points: Optional[List[AstrologicalPoint]] = None,
841
+ calculate_lunar_phase: bool = True,
842
+ suppress_geonames_warning: bool = False
843
+ ) -> AstrologicalSubjectModel:
844
+ """
845
+ Create an astrological subject for the current moment in time.
846
+
847
+ This convenience method creates a "now" chart, capturing the current
848
+ astrological conditions at the moment of execution. Useful for horary
849
+ astrology, electional astrology, or real-time astrological monitoring.
850
+
851
+ Args:
852
+ name (str, optional): Name for the current moment chart.
853
+ Defaults to "Now".
854
+ city (str, optional): City name for location lookup. If not provided
855
+ and online=True, defaults to Greenwich.
856
+ nation (str, optional): ISO country code. If not provided and
857
+ online=True, defaults to 'GB'.
858
+ lng (float, optional): Longitude in decimal degrees. If not provided
859
+ and online=True, fetched from GeoNames API.
860
+ lat (float, optional): Latitude in decimal degrees. If not provided
861
+ and online=True, fetched from GeoNames API.
862
+ tz_str (str, optional): IANA timezone identifier. If not provided
863
+ and online=True, fetched from GeoNames API.
864
+ geonames_username (str, optional): GeoNames API username for location
865
+ lookup. Required when online=True and location is not fully specified.
866
+ online (bool, optional): Whether to fetch location data online.
867
+ Defaults to True.
868
+ zodiac_type (ZodiacType, optional): Zodiac system to use.
869
+ Defaults to 'Tropic'.
870
+ sidereal_mode (SiderealMode, optional): Sidereal calculation mode.
871
+ Only used when zodiac_type is 'Sidereal'. Defaults to None.
872
+ houses_system_identifier (HousesSystemIdentifier, optional): House
873
+ system for calculations. Defaults to 'P' (Placidus).
874
+ perspective_type (PerspectiveType, optional): Calculation perspective.
875
+ Defaults to 'Apparent Geocentric'.
876
+ active_points (Optional[List[AstrologicalPoint]], optional): Astrological points
877
+ to calculate. If None, uses DEFAULT_ACTIVE_POINTS.
878
+ calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
879
+ Defaults to True.
880
+
881
+ Returns:
882
+ AstrologicalSubjectModel: Astrological subject representing current
883
+ astrological conditions at the specified or default location.
884
+
885
+ Raises:
886
+ KerykeionException: If online location lookup fails or if offline mode
887
+ is used without sufficient location data.
888
+
889
+ Examples:
890
+ >>> # Current moment for your location
891
+ >>> now_chart = AstrologicalSubjectFactory.from_current_time(
892
+ ... name="Current Transits",
893
+ ... city="New York", nation="US",
894
+ ... geonames_username="your_username"
895
+ ... )
896
+
897
+ >>> # Horary chart with specific coordinates
898
+ >>> horary = AstrologicalSubjectFactory.from_current_time(
899
+ ... name="Horary Question",
900
+ ... lng=-0.1278, lat=51.5074, # London
901
+ ... tz_str="Europe/London",
902
+ ... online=False
903
+ ... )
904
+
905
+ >>> # Current sidereal positions
906
+ >>> sidereal_now = AstrologicalSubjectFactory.from_current_time(
907
+ ... name="Sidereal Now",
908
+ ... city="Mumbai", nation="IN",
909
+ ... zodiac_type="Sidereal",
910
+ ... sidereal_mode="LAHIRI"
911
+ ... )
912
+
913
+ Note:
914
+ - The exact time is captured at method execution, including seconds
915
+ - For horary astrology, consider the moment of understanding the question
916
+ - System clock accuracy affects precision; ensure accurate system time
917
+ - Time zone detection is automatic when using online location lookup
918
+ """
919
+ now = datetime.now()
920
+
921
+ return cls.from_birth_data(
922
+ name=name,
923
+ year=now.year,
924
+ month=now.month,
925
+ day=now.day,
926
+ hour=now.hour,
927
+ minute=now.minute,
928
+ seconds=now.second,
929
+ city=city,
930
+ nation=nation,
931
+ lng=lng,
932
+ lat=lat,
933
+ tz_str=tz_str,
934
+ geonames_username=geonames_username,
935
+ online=online,
936
+ zodiac_type=zodiac_type,
937
+ sidereal_mode=sidereal_mode,
938
+ houses_system_identifier=houses_system_identifier,
939
+ perspective_type=perspective_type,
940
+ active_points=active_points,
941
+ calculate_lunar_phase=calculate_lunar_phase,
942
+ suppress_geonames_warning=suppress_geonames_warning
943
+ )
944
+
945
+ @staticmethod
946
+ def _calculate_time_conversions(data: Dict[str, Any], location: LocationData) -> None:
947
+ """
948
+ Calculate time conversions between local time, UTC, and Julian Day Number.
949
+
950
+ Handles timezone-aware conversion from local civil time to UTC and astronomical
951
+ Julian Day Number, including proper DST handling and timezone localization.
952
+
953
+ Args:
954
+ data (Dict[str, Any]): Calculation data dictionary containing time components
955
+ (year, month, day, hour, minute, seconds) and optional DST flag.
956
+ location (LocationData): Location data containing timezone information.
957
+
958
+ Raises:
959
+ KerykeionException: If DST ambiguity occurs during timezone transitions
960
+ and is_dst parameter is not explicitly set to resolve the ambiguity.
961
+
962
+ Side Effects:
963
+ Updates data dictionary with:
964
+ - iso_formatted_utc_datetime: ISO 8601 UTC timestamp
965
+ - iso_formatted_local_datetime: ISO 8601 local timestamp
966
+ - julian_day: Julian Day Number for astronomical calculations
967
+
968
+ Note:
969
+ During DST transitions, times may be ambiguous (fall back) or non-existent
970
+ (spring forward). The method raises an exception for ambiguous times unless
971
+ the is_dst parameter is explicitly set to True or False.
972
+ """
973
+ # Convert local time to UTC
974
+ local_timezone = pytz.timezone(location.tz_str)
975
+ naive_datetime = datetime(
976
+ data["year"], data["month"], data["day"],
977
+ data["hour"], data["minute"], data["seconds"]
978
+ )
979
+
980
+ try:
981
+ local_datetime = local_timezone.localize(naive_datetime, is_dst=data.get("is_dst"))
982
+ except pytz.exceptions.AmbiguousTimeError:
983
+ raise KerykeionException(
984
+ "Ambiguous time error! The time falls during a DST transition. "
985
+ "Please specify is_dst=True or is_dst=False to clarify."
986
+ )
987
+ except pytz.exceptions.NonExistentTimeError:
988
+ raise KerykeionException(
989
+ "Non-existent time error! The time does not exist due to DST transition (spring forward). "
990
+ "Please specify a valid time."
991
+ )
992
+
993
+ # Store formatted times
994
+ utc_datetime = local_datetime.astimezone(pytz.utc)
995
+ data["iso_formatted_utc_datetime"] = utc_datetime.isoformat()
996
+ data["iso_formatted_local_datetime"] = local_datetime.isoformat()
997
+
998
+ # Calculate Julian day
999
+ data["julian_day"] = datetime_to_julian(utc_datetime)
1000
+
1001
+
1002
+ @staticmethod
1003
+ def _calculate_houses(data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> List[AstrologicalPoint]:
1004
+ """
1005
+ Calculate house cusps and angular points (Ascendant, MC, etc.).
1006
+
1007
+ Computes the 12 house cusps using the specified house system and calculates
1008
+ the four main angles of the chart. Only calculates angular points that are
1009
+ included in the active_points list for performance optimization.
1010
+
1011
+ Args:
1012
+ data (Dict[str, Any]): Calculation data dictionary containing configuration
1013
+ and location information. Updated with calculated house and angle data.
1014
+ active_points (Optional[List[AstrologicalPoint]]): List of points to calculate.
1015
+ If None, all points are calculated. Angular points not in this list
1016
+ are skipped for performance.
1017
+
1018
+ Side Effects:
1019
+ Updates data dictionary with:
1020
+ - House cusp objects: first_house through twelfth_house
1021
+ - Angular points: ascendant, medium_coeli, descendant, imum_coeli
1022
+ - houses_names_list: List of all house names
1023
+ - _houses_degree_ut: Raw house cusp degrees for internal use
1024
+
1025
+ House Systems Supported:
1026
+ All systems supported by Swiss Ephemeris including Placidus, Koch,
1027
+ Equal House, Whole Sign, Regiomontanus, Campanus, Topocentric, etc.
1028
+
1029
+ Angular Points Calculated:
1030
+ - Ascendant: Eastern horizon point (1st house cusp)
1031
+ - Medium Coeli (Midheaven): Southern meridian point (10th house cusp)
1032
+ - Descendant: Western horizon point (opposite Ascendant)
1033
+ - Imum Coeli: Northern meridian point (opposite Medium Coeli)
1034
+
1035
+ Note:
1036
+ House calculations respect the zodiac type (Tropical/Sidereal) and use
1037
+ the appropriate Swiss Ephemeris function. Angular points include house
1038
+ position and retrograde status (always False for angles).
1039
+ """
1040
+ # Skip calculation if point is not in active_points
1041
+ def should_calculate(point: AstrologicalPoint) -> bool:
1042
+ return not active_points or point in active_points
1043
+ # Track which axial cusps are actually calculated
1044
+ calculated_axial_cusps: List[AstrologicalPoint] = []
1045
+
1046
+ # Calculate houses based on zodiac type
1047
+ if data["zodiac_type"] == "Sidereal":
1048
+ cusps, ascmc = swe.houses_ex(
1049
+ tjdut=data["julian_day"],
1050
+ lat=data["lat"],
1051
+ lon=data["lng"],
1052
+ hsys=str.encode(data["houses_system_identifier"]),
1053
+ flags=swe.FLG_SIDEREAL
1054
+ )
1055
+ else: # Tropical zodiac
1056
+ cusps, ascmc = swe.houses(
1057
+ tjdut=data["julian_day"],
1058
+ lat=data["lat"],
1059
+ lon=data["lng"],
1060
+ hsys=str.encode(data["houses_system_identifier"])
1061
+ )
1062
+
1063
+ # Store house degrees
1064
+ data["_houses_degree_ut"] = cusps
1065
+
1066
+ # Create house objects
1067
+ point_type: PointType = "House"
1068
+ data["first_house"] = get_kerykeion_point_from_degree(cusps[0], "First_House", point_type=point_type)
1069
+ data["second_house"] = get_kerykeion_point_from_degree(cusps[1], "Second_House", point_type=point_type)
1070
+ data["third_house"] = get_kerykeion_point_from_degree(cusps[2], "Third_House", point_type=point_type)
1071
+ data["fourth_house"] = get_kerykeion_point_from_degree(cusps[3], "Fourth_House", point_type=point_type)
1072
+ data["fifth_house"] = get_kerykeion_point_from_degree(cusps[4], "Fifth_House", point_type=point_type)
1073
+ data["sixth_house"] = get_kerykeion_point_from_degree(cusps[5], "Sixth_House", point_type=point_type)
1074
+ data["seventh_house"] = get_kerykeion_point_from_degree(cusps[6], "Seventh_House", point_type=point_type)
1075
+ data["eighth_house"] = get_kerykeion_point_from_degree(cusps[7], "Eighth_House", point_type=point_type)
1076
+ data["ninth_house"] = get_kerykeion_point_from_degree(cusps[8], "Ninth_House", point_type=point_type)
1077
+ data["tenth_house"] = get_kerykeion_point_from_degree(cusps[9], "Tenth_House", point_type=point_type)
1078
+ data["eleventh_house"] = get_kerykeion_point_from_degree(cusps[10], "Eleventh_House", point_type=point_type)
1079
+ data["twelfth_house"] = get_kerykeion_point_from_degree(cusps[11], "Twelfth_House", point_type=point_type)
1080
+
1081
+ # Store house names
1082
+ data["houses_names_list"] = list(get_args(Houses))
1083
+
1084
+ # Calculate axis points
1085
+ point_type = "AstrologicalPoint"
1086
+
1087
+ # Calculate Ascendant if needed
1088
+ if should_calculate("Ascendant"):
1089
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1090
+ data["ascendant"].house = get_planet_house(data["ascendant"].abs_pos, data["_houses_degree_ut"])
1091
+ data["ascendant"].retrograde = False
1092
+ calculated_axial_cusps.append("Ascendant")
1093
+
1094
+ # Calculate Medium Coeli if needed
1095
+ if should_calculate("Medium_Coeli"):
1096
+ data["medium_coeli"] = get_kerykeion_point_from_degree(ascmc[1], "Medium_Coeli", point_type=point_type)
1097
+ data["medium_coeli"].house = get_planet_house(data["medium_coeli"].abs_pos, data["_houses_degree_ut"])
1098
+ data["medium_coeli"].retrograde = False
1099
+ calculated_axial_cusps.append("Medium_Coeli")
1100
+
1101
+ # Calculate Descendant if needed
1102
+ if should_calculate("Descendant"):
1103
+ dsc_deg = math.fmod(ascmc[0] + 180, 360)
1104
+ data["descendant"] = get_kerykeion_point_from_degree(dsc_deg, "Descendant", point_type=point_type)
1105
+ data["descendant"].house = get_planet_house(data["descendant"].abs_pos, data["_houses_degree_ut"])
1106
+ data["descendant"].retrograde = False
1107
+ calculated_axial_cusps.append("Descendant")
1108
+
1109
+ # Calculate Imum Coeli if needed
1110
+ if should_calculate("Imum_Coeli"):
1111
+ ic_deg = math.fmod(ascmc[1] + 180, 360)
1112
+ data["imum_coeli"] = get_kerykeion_point_from_degree(ic_deg, "Imum_Coeli", point_type=point_type)
1113
+ data["imum_coeli"].house = get_planet_house(data["imum_coeli"].abs_pos, data["_houses_degree_ut"])
1114
+ data["imum_coeli"].retrograde = False
1115
+ calculated_axial_cusps.append("Imum_Coeli")
1116
+
1117
+ return calculated_axial_cusps
1118
+
1119
+ @staticmethod
1120
+ def _calculate_single_planet(
1121
+ data: Dict[str, Any],
1122
+ planet_name: AstrologicalPoint,
1123
+ planet_id: int,
1124
+ julian_day: float,
1125
+ iflag: int,
1126
+ houses_degree_ut: List[float],
1127
+ point_type: PointType,
1128
+ calculated_planets: List[AstrologicalPoint],
1129
+ active_points: List[AstrologicalPoint]
1130
+ ) -> None:
1131
+ """
1132
+ Calculate a single celestial body's position with comprehensive error handling.
1133
+
1134
+ Computes the position of a single planet, asteroid, or other celestial object
1135
+ using Swiss Ephemeris, creates a Kerykeion point object, determines house
1136
+ position, and assesses retrograde status. Handles calculation errors gracefully
1137
+ by logging and removing failed points from the active list.
1138
+
1139
+ Args:
1140
+ data (Dict[str, Any]): Main calculation data dictionary to store results.
1141
+ planet_name (AstrologicalPoint): Name identifier for the celestial body.
1142
+ planet_id (int): Swiss Ephemeris numerical identifier for the object.
1143
+ julian_day (float): Julian Day Number for the calculation moment.
1144
+ iflag (int): Swiss Ephemeris calculation flags (perspective, zodiac, etc.).
1145
+ houses_degree_ut (List[float]): House cusp degrees for house determination.
1146
+ point_type (PointType): Classification of the point type for the object.
1147
+ calculated_planets (List[str]): Running list of successfully calculated objects.
1148
+ active_points (List[AstrologicalPoint]): Active points list (modified on error).
1149
+
1150
+ Side Effects:
1151
+ - Adds calculated object to data dictionary using lowercase planet_name as key
1152
+ - Appends planet_name to calculated_planets list on success
1153
+ - Removes planet_name from active_points list on calculation failure
1154
+ - Logs error messages for calculation failures
1155
+
1156
+ Calculated Properties:
1157
+ - Zodiacal position (longitude) in degrees
1158
+ - House position based on house cusp positions
1159
+ - Retrograde status based on velocity (negative = retrograde)
1160
+ - Sign, degree, and minute components
1161
+
1162
+ Error Handling:
1163
+ If Swiss Ephemeris calculation fails (e.g., for distant asteroids outside
1164
+ ephemeris range), the method logs the error and removes the object from
1165
+ active_points to prevent cascade failures.
1166
+
1167
+ Note:
1168
+ The method uses the Swiss Ephemeris calc_ut function which returns position
1169
+ and velocity data. Retrograde determination is based on the velocity
1170
+ component being negative (element index 3).
1171
+ """
1172
+ try:
1173
+ # Calculate planet position using Swiss Ephemeris (ecliptic coordinates)
1174
+ planet_calc = swe.calc_ut(julian_day, planet_id, iflag)[0]
1175
+
1176
+ # Get declination from equatorial coordinates
1177
+ planet_eq = swe.calc_ut(julian_day, planet_id, iflag | swe.FLG_EQUATORIAL)[0]
1178
+ declination = planet_eq[1] # Declination from equatorial coordinates
1179
+
1180
+ # Create Kerykeion point from degree
1181
+ data[planet_name.lower()] = get_kerykeion_point_from_degree(
1182
+ planet_calc[0], planet_name, point_type=point_type, speed=planet_calc[3], declination=declination
1183
+ )
1184
+
1185
+ # Calculate house position
1186
+ data[planet_name.lower()].house = get_planet_house(planet_calc[0], houses_degree_ut)
1187
+
1188
+ # Determine if planet is retrograde
1189
+ data[planet_name.lower()].retrograde = planet_calc[3] < 0
1190
+
1191
+ # Track calculated planet
1192
+ calculated_planets.append(planet_name)
1193
+
1194
+ except Exception as e:
1195
+ logging.error(f"Error calculating {planet_name}: {e}")
1196
+ if planet_name in active_points:
1197
+ active_points.remove(planet_name)
1198
+
1199
+ @staticmethod
1200
+ def _calculate_planets(data: Dict[str, Any], active_points: List[AstrologicalPoint], calculated_axial_cusps: Optional[List[AstrologicalPoint]] = None) -> None:
1201
+ """
1202
+ Calculate positions for all requested celestial bodies and special points.
1203
+
1204
+ This comprehensive method calculates positions for a wide range of astrological
1205
+ points including traditional planets, lunar nodes, asteroids, trans-Neptunian
1206
+ objects, fixed stars, Arabic parts, and specialized points like Vertex.
1207
+
1208
+ The calculation is performed selectively based on the active_points list for
1209
+ performance optimization. Some Arabic parts automatically activate their
1210
+ prerequisite points if needed.
1211
+
1212
+ Args:
1213
+ data (Dict[str, Any]): Main calculation data dictionary. Updated with all
1214
+ calculated planetary positions and related metadata.
1215
+ active_points (List[AstrologicalPoint]): Mutable list of points to calculate.
1216
+ Modified during execution to remove failed calculations and add
1217
+ automatically required points for Arabic parts.
1218
+
1219
+ Celestial Bodies Calculated:
1220
+ Traditional Planets:
1221
+ - Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn
1222
+ - Uranus, Neptune, Pluto
1223
+
1224
+ Lunar Nodes:
1225
+ - Mean Node, True Node (North nodes)
1226
+ - Mean South Node, True South Node (calculated as opposites)
1227
+
1228
+ Lilith Points:
1229
+ - Mean Lilith (Mean Black Moon Lilith)
1230
+ - True Lilith (Osculating Black Moon Lilith)
1231
+
1232
+ Asteroids:
1233
+ - Ceres, Pallas, Juno, Vesta (main belt asteroids)
1234
+
1235
+ Centaurs:
1236
+ - Chiron, Pholus
1237
+
1238
+ Trans-Neptunian Objects:
1239
+ - Eris, Sedna, Haumea, Makemake
1240
+ - Ixion, Orcus, Quaoar
1241
+
1242
+ Fixed Stars:
1243
+ - Regulus, Spica (examples, extensible)
1244
+
1245
+ Arabic Parts (Lots):
1246
+ - Pars Fortunae (Part of Fortune)
1247
+ - Pars Spiritus (Part of Spirit)
1248
+ - Pars Amoris (Part of Love/Eros)
1249
+ - Pars Fidei (Part of Faith)
1250
+
1251
+ Special Points:
1252
+ - Earth (for heliocentric perspectives)
1253
+ - Vertex and Anti-Vertex
1254
+
1255
+ Side Effects:
1256
+ - Updates data dictionary with all calculated positions
1257
+ - Modifies active_points list by removing failed calculations
1258
+ - Adds prerequisite points for Arabic parts calculations
1259
+ - Updates data["active_points"] with successfully calculated objects
1260
+
1261
+ Error Handling:
1262
+ Individual calculation failures (e.g., asteroids outside ephemeris range)
1263
+ are handled gracefully with logging and removal from active_points.
1264
+ This prevents cascade failures while maintaining calculation integrity.
1265
+
1266
+ Arabic Parts Logic:
1267
+ - Day/night birth detection based on Sun's house position
1268
+ - Automatic activation of required base points (Sun, Moon, Ascendant, etc.)
1269
+ - Classical formulae with day/night variations where applicable
1270
+ - All parts marked as non-retrograde (conceptual points)
1271
+
1272
+ Performance Notes:
1273
+ - Only points in active_points are calculated (selective computation)
1274
+ - Failed calculations are removed to prevent repeated attempts
1275
+ - Some expensive calculations (like distant TNOs) may timeout
1276
+ - Fixed stars use different calculation methods than planets
1277
+
1278
+ Note:
1279
+ The method maintains a running list of successfully calculated planets
1280
+ and updates the active_points list to reflect actual availability.
1281
+ This ensures that dependent calculations and aspects only use valid data.
1282
+ """
1283
+ # Skip calculation if point is not in active_points
1284
+ def should_calculate(point: AstrologicalPoint) -> bool:
1285
+ return not active_points or point in active_points
1286
+
1287
+ point_type: PointType = "AstrologicalPoint"
1288
+ julian_day = data["julian_day"]
1289
+ iflag = data["_iflag"]
1290
+ houses_degree_ut = data["_houses_degree_ut"]
1291
+
1292
+ # Track which planets are actually calculated
1293
+ calculated_planets: List[AstrologicalPoint] = []
1294
+
1295
+ # ==================
1296
+ # MAIN PLANETS
1297
+ # ==================
1298
+
1299
+ # Calculate Sun
1300
+ if should_calculate("Sun"):
1301
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1302
+
1303
+ # Calculate Moon
1304
+ if should_calculate("Moon"):
1305
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1306
+
1307
+ # Calculate Mercury
1308
+ if should_calculate("Mercury"):
1309
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1310
+
1311
+ # Calculate Venus
1312
+ if should_calculate("Venus"):
1313
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1314
+
1315
+ # Calculate Mars
1316
+ if should_calculate("Mars"):
1317
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1318
+
1319
+ # Calculate Jupiter
1320
+ if should_calculate("Jupiter"):
1321
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1322
+
1323
+ # Calculate Saturn
1324
+ if should_calculate("Saturn"):
1325
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1326
+
1327
+ # Calculate Uranus
1328
+ if should_calculate("Uranus"):
1329
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1330
+
1331
+ # Calculate Neptune
1332
+ if should_calculate("Neptune"):
1333
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1334
+
1335
+ # Calculate Pluto
1336
+ if should_calculate("Pluto"):
1337
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1338
+
1339
+ # ==================
1340
+ # LUNAR NODES
1341
+ # ==================
1342
+
1343
+ # Calculate Mean North Lunar Node
1344
+ if should_calculate("Mean_North_Lunar_Node"):
1345
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mean_North_Lunar_Node", 10, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1346
+ # Get correct declination using equatorial coordinates
1347
+ if "mean_north_lunar_node" in data:
1348
+ mean_north_lunar_node_eq = swe.calc_ut(julian_day, 10, iflag | swe.FLG_EQUATORIAL)[0]
1349
+ data["mean_north_lunar_node"].declination = mean_north_lunar_node_eq[1] # Declination from equatorial coordinates
1350
+
1351
+ # Calculate True North Lunar Node
1352
+ if should_calculate("True_North_Lunar_Node"):
1353
+ AstrologicalSubjectFactory._calculate_single_planet(data, "True_North_Lunar_Node", 11, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1354
+ # Get correct declination using equatorial coordinates
1355
+ if "true_north_lunar_node" in data:
1356
+ true_north_lunar_node_eq = swe.calc_ut(julian_day, 11, iflag | swe.FLG_EQUATORIAL)[0]
1357
+ data["true_north_lunar_node"].declination = true_north_lunar_node_eq[1] # Declination from equatorial coordinates
1358
+
1359
+ # Calculate Mean South Lunar Node (opposite to Mean North Lunar Node)
1360
+ if should_calculate("Mean_South_Lunar_Node") and "mean_north_lunar_node" in data:
1361
+ mean_south_lunar_node_deg = math.fmod(data["mean_north_lunar_node"].abs_pos + 180, 360)
1362
+ data["mean_south_lunar_node"] = get_kerykeion_point_from_degree(
1363
+ mean_south_lunar_node_deg, "Mean_South_Lunar_Node", point_type=point_type,
1364
+ speed=-data["mean_north_lunar_node"].speed if data["mean_north_lunar_node"].speed is not None else None,
1365
+ declination=-data["mean_north_lunar_node"].declination if data["mean_north_lunar_node"].declination is not None else None
1366
+ )
1367
+ data["mean_south_lunar_node"].house = get_planet_house(mean_south_lunar_node_deg, houses_degree_ut)
1368
+ data["mean_south_lunar_node"].retrograde = data["mean_north_lunar_node"].retrograde
1369
+ calculated_planets.append("Mean_South_Lunar_Node")
1370
+
1371
+ # Calculate True South Lunar Node (opposite to True North Lunar Node)
1372
+ if should_calculate("True_South_Lunar_Node") and "true_north_lunar_node" in data:
1373
+ true_south_lunar_node_deg = math.fmod(data["true_north_lunar_node"].abs_pos + 180, 360)
1374
+ data["true_south_lunar_node"] = get_kerykeion_point_from_degree(
1375
+ true_south_lunar_node_deg, "True_South_Lunar_Node", point_type=point_type,
1376
+ speed=-data["true_north_lunar_node"].speed if data["true_north_lunar_node"].speed is not None else None,
1377
+ declination=-data["true_north_lunar_node"].declination if data["true_north_lunar_node"].declination is not None else None
1378
+ )
1379
+ data["true_south_lunar_node"].house = get_planet_house(true_south_lunar_node_deg, houses_degree_ut)
1380
+ data["true_south_lunar_node"].retrograde = data["true_north_lunar_node"].retrograde
1381
+ calculated_planets.append("True_South_Lunar_Node")
1382
+
1383
+ # ==================
1384
+ # LILITH POINTS
1385
+ # ==================
1386
+
1387
+ # Calculate Mean Lilith (Mean Black Moon)
1388
+ if should_calculate("Mean_Lilith"):
1389
+ AstrologicalSubjectFactory._calculate_single_planet(
1390
+ data, "Mean_Lilith", 12, julian_day, iflag, houses_degree_ut,
1391
+ point_type, calculated_planets, active_points
1392
+ )
1393
+
1394
+ # Calculate True Lilith (Osculating Black Moon)
1395
+ if should_calculate("True_Lilith"):
1396
+ AstrologicalSubjectFactory._calculate_single_planet(
1397
+ data, "True_Lilith", 13, julian_day, iflag, houses_degree_ut,
1398
+ point_type, calculated_planets, active_points
1399
+ )
1400
+
1401
+ # ==================
1402
+ # SPECIAL POINTS
1403
+ # ==================
1404
+
1405
+ # Calculate Earth - useful for heliocentric charts
1406
+ if should_calculate("Earth"):
1407
+ AstrologicalSubjectFactory._calculate_single_planet(
1408
+ data, "Earth", 14, julian_day, iflag, houses_degree_ut,
1409
+ point_type, calculated_planets, active_points
1410
+ )
1411
+
1412
+ # Calculate Chiron
1413
+ if should_calculate("Chiron"):
1414
+ AstrologicalSubjectFactory._calculate_single_planet(
1415
+ data, "Chiron", 15, julian_day, iflag, houses_degree_ut,
1416
+ point_type, calculated_planets, active_points
1417
+ )
1418
+
1419
+ # Calculate Pholus
1420
+ if should_calculate("Pholus"):
1421
+ AstrologicalSubjectFactory._calculate_single_planet(
1422
+ data, "Pholus", 16, julian_day, iflag, houses_degree_ut,
1423
+ point_type, calculated_planets, active_points
1424
+ )
1425
+
1426
+ # ==================
1427
+ # ASTEROIDS
1428
+ # ==================
1429
+
1430
+ # Calculate Ceres
1431
+ if should_calculate("Ceres"):
1432
+ AstrologicalSubjectFactory._calculate_single_planet(
1433
+ data, "Ceres", 17, julian_day, iflag, houses_degree_ut,
1434
+ point_type, calculated_planets, active_points
1435
+ )
1436
+
1437
+ # Calculate Pallas
1438
+ if should_calculate("Pallas"):
1439
+ AstrologicalSubjectFactory._calculate_single_planet(
1440
+ data, "Pallas", 18, julian_day, iflag, houses_degree_ut,
1441
+ point_type, calculated_planets, active_points
1442
+ )
1443
+
1444
+ # Calculate Juno
1445
+ if should_calculate("Juno"):
1446
+ AstrologicalSubjectFactory._calculate_single_planet(
1447
+ data, "Juno", 19, julian_day, iflag, houses_degree_ut,
1448
+ point_type, calculated_planets, active_points
1449
+ )
1450
+
1451
+ # Calculate Vesta
1452
+ if should_calculate("Vesta"):
1453
+ AstrologicalSubjectFactory._calculate_single_planet(
1454
+ data, "Vesta", 20, julian_day, iflag, houses_degree_ut,
1455
+ point_type, calculated_planets, active_points
1456
+ )
1457
+
1458
+ # ==================
1459
+ # TRANS-NEPTUNIAN OBJECTS
1460
+ # ==================
1461
+
1462
+ # Calculate Eris
1463
+ if should_calculate("Eris"):
1464
+ try:
1465
+ eris_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136199, iflag)[0]
1466
+ data["eris"] = get_kerykeion_point_from_degree(eris_calc[0], "Eris", point_type=point_type, speed=eris_calc[3], declination=eris_calc[1])
1467
+ data["eris"].house = get_planet_house(eris_calc[0], houses_degree_ut)
1468
+ data["eris"].retrograde = eris_calc[3] < 0
1469
+ calculated_planets.append("Eris")
1470
+ except Exception as e:
1471
+ logging.warning(f"Could not calculate Eris position: {e}")
1472
+ active_points.remove("Eris") # Remove if not calculated
1473
+
1474
+ # Calculate Sedna
1475
+ if should_calculate("Sedna"):
1476
+ try:
1477
+ sedna_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90377, iflag)[0]
1478
+ data["sedna"] = get_kerykeion_point_from_degree(sedna_calc[0], "Sedna", point_type=point_type, speed=sedna_calc[3], declination=sedna_calc[1])
1479
+ data["sedna"].house = get_planet_house(sedna_calc[0], houses_degree_ut)
1480
+ data["sedna"].retrograde = sedna_calc[3] < 0
1481
+ calculated_planets.append("Sedna")
1482
+ except Exception as e:
1483
+ logging.warning(f"Could not calculate Sedna position: {e}")
1484
+ active_points.remove("Sedna")
1485
+
1486
+ # Calculate Haumea
1487
+ if should_calculate("Haumea"):
1488
+ try:
1489
+ haumea_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136108, iflag)[0]
1490
+ data["haumea"] = get_kerykeion_point_from_degree(haumea_calc[0], "Haumea", point_type=point_type, speed=haumea_calc[3], declination=haumea_calc[1])
1491
+ data["haumea"].house = get_planet_house(haumea_calc[0], houses_degree_ut)
1492
+ data["haumea"].retrograde = haumea_calc[3] < 0
1493
+ calculated_planets.append("Haumea")
1494
+ except Exception as e:
1495
+ logging.warning(f"Could not calculate Haumea position: {e}")
1496
+ active_points.remove("Haumea") # Remove if not calculated
1497
+
1498
+ # Calculate Makemake
1499
+ if should_calculate("Makemake"):
1500
+ try:
1501
+ makemake_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136472, iflag)[0]
1502
+ data["makemake"] = get_kerykeion_point_from_degree(makemake_calc[0], "Makemake", point_type=point_type, speed=makemake_calc[3], declination=makemake_calc[1])
1503
+ data["makemake"].house = get_planet_house(makemake_calc[0], houses_degree_ut)
1504
+ data["makemake"].retrograde = makemake_calc[3] < 0
1505
+ calculated_planets.append("Makemake")
1506
+ except Exception as e:
1507
+ logging.warning(f"Could not calculate Makemake position: {e}")
1508
+ active_points.remove("Makemake") # Remove if not calculated
1509
+
1510
+ # Calculate Ixion
1511
+ if should_calculate("Ixion"):
1512
+ try:
1513
+ ixion_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 28978, iflag)[0]
1514
+ data["ixion"] = get_kerykeion_point_from_degree(ixion_calc[0], "Ixion", point_type=point_type, speed=ixion_calc[3], declination=ixion_calc[1])
1515
+ data["ixion"].house = get_planet_house(ixion_calc[0], houses_degree_ut)
1516
+ data["ixion"].retrograde = ixion_calc[3] < 0
1517
+ calculated_planets.append("Ixion")
1518
+ except Exception as e:
1519
+ logging.warning(f"Could not calculate Ixion position: {e}")
1520
+ active_points.remove("Ixion") # Remove if not calculated
1521
+
1522
+ # Calculate Orcus
1523
+ if should_calculate("Orcus"):
1524
+ try:
1525
+ orcus_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90482, iflag)[0]
1526
+ data["orcus"] = get_kerykeion_point_from_degree(orcus_calc[0], "Orcus", point_type=point_type, speed=orcus_calc[3], declination=orcus_calc[1])
1527
+ data["orcus"].house = get_planet_house(orcus_calc[0], houses_degree_ut)
1528
+ data["orcus"].retrograde = orcus_calc[3] < 0
1529
+ calculated_planets.append("Orcus")
1530
+ except Exception as e:
1531
+ logging.warning(f"Could not calculate Orcus position: {e}")
1532
+ active_points.remove("Orcus") # Remove if not calculated
1533
+
1534
+ # Calculate Quaoar
1535
+ if should_calculate("Quaoar"):
1536
+ try:
1537
+ quaoar_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 50000, iflag)[0]
1538
+ data["quaoar"] = get_kerykeion_point_from_degree(quaoar_calc[0], "Quaoar", point_type=point_type, speed=quaoar_calc[3], declination=quaoar_calc[1])
1539
+ data["quaoar"].house = get_planet_house(quaoar_calc[0], houses_degree_ut)
1540
+ data["quaoar"].retrograde = quaoar_calc[3] < 0
1541
+ calculated_planets.append("Quaoar")
1542
+ except Exception as e:
1543
+ logging.warning(f"Could not calculate Quaoar position: {e}")
1544
+ active_points.remove("Quaoar") # Remove if not calculated
1545
+
1546
+ # ==================
1547
+ # FIXED STARS
1548
+ # ==================
1549
+
1550
+ # Calculate Regulus (example fixed star)
1551
+ if should_calculate("Regulus"):
1552
+ try:
1553
+ star_name = "Regulus"
1554
+ pos = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1555
+ regulus_deg = pos[0]
1556
+ regulus_speed = pos[3] if len(pos) > 3 else 0.0 # Fixed stars have very slow speed
1557
+ regulus_dec = pos[1] if len(pos) > 1 else None # Declination
1558
+ data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type, speed=regulus_speed, declination=regulus_dec)
1559
+ data["regulus"].house = get_planet_house(regulus_deg, houses_degree_ut)
1560
+ data["regulus"].retrograde = False # Fixed stars are never retrograde
1561
+ calculated_planets.append("Regulus")
1562
+ except Exception as e:
1563
+ logging.warning(f"Could not calculate Regulus position: {e}")
1564
+ active_points.remove("Regulus") # Remove if not calculated
1565
+
1566
+ # Calculate Spica (example fixed star)
1567
+ if should_calculate("Spica"):
1568
+ try:
1569
+ star_name = "Spica"
1570
+ pos = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1571
+ spica_deg = pos[0]
1572
+ spica_speed = pos[3] if len(pos) > 3 else 0.0 # Fixed stars have very slow speed
1573
+ spica_dec = pos[1] if len(pos) > 1 else None # Declination
1574
+ data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type, speed=spica_speed, declination=spica_dec)
1575
+ data["spica"].house = get_planet_house(spica_deg, houses_degree_ut)
1576
+ data["spica"].retrograde = False # Fixed stars are never retrograde
1577
+ calculated_planets.append("Spica")
1578
+ except Exception as e:
1579
+ logging.warning(f"Could not calculate Spica position: {e}")
1580
+ active_points.remove("Spica") # Remove if not calculated
1581
+
1582
+ # ==================
1583
+ # ARABIC PARTS / LOTS
1584
+ # ==================
1585
+
1586
+ # Calculate Pars Fortunae (Part of Fortune)
1587
+ if should_calculate("Pars_Fortunae"):
1588
+ # Auto-activate required points with notification
1589
+ pars_fortunae_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1590
+ missing_points = [point for point in pars_fortunae_required_points if point not in active_points]
1591
+ if missing_points:
1592
+ logging.info(f"Automatically adding required points for Pars_Fortunae: {missing_points}")
1593
+ active_points.extend(cast(List[AstrologicalPoint], missing_points))
1594
+ # Recalculate the missing points
1595
+ for point in missing_points:
1596
+ if point == "Ascendant" and "ascendant" not in data:
1597
+ # Calculate Ascendant from houses data if needed
1598
+ if data["zodiac_type"] == "Sidereal":
1599
+ cusps, ascmc = swe.houses_ex(
1600
+ tjdut=data["julian_day"],
1601
+ lat=data["lat"],
1602
+ lon=data["lng"],
1603
+ hsys=str.encode(data["houses_system_identifier"]),
1604
+ flags=swe.FLG_SIDEREAL
1605
+ )
1606
+ else:
1607
+ cusps, ascmc = swe.houses(
1608
+ tjdut=data["julian_day"],
1609
+ lat=data["lat"],
1610
+ lon=data["lng"],
1611
+ hsys=str.encode(data["houses_system_identifier"])
1612
+ )
1613
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1614
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1615
+ data["ascendant"].retrograde = False
1616
+ elif point == "Sun" and "sun" not in data:
1617
+ sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1618
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1619
+ data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1620
+ data["sun"].retrograde = sun_calc[3] < 0
1621
+ elif point == "Moon" and "moon" not in data:
1622
+ moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1623
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1624
+ data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1625
+ data["moon"].retrograde = moon_calc[3] < 0
1626
+
1627
+ # Check if required points are available
1628
+ if all(k in data for k in ["ascendant", "sun", "moon"]):
1629
+ # Different calculation for day and night charts
1630
+ # Day birth (Sun above horizon): ASC + Moon - Sun
1631
+ # Night birth (Sun below horizon): ASC + Sun - Moon
1632
+ if data["sun"].house:
1633
+ is_day_chart = get_house_number(data["sun"].house) < 7 # Houses 1-6 are above horizon
1634
+ else:
1635
+ is_day_chart = True # Default to day chart if house is None
1636
+
1637
+ if is_day_chart:
1638
+ fortune_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1639
+ else:
1640
+ fortune_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1641
+
1642
+ data["pars_fortunae"] = get_kerykeion_point_from_degree(fortune_deg, "Pars_Fortunae", point_type=point_type)
1643
+ data["pars_fortunae"].house = get_planet_house(fortune_deg, houses_degree_ut)
1644
+ data["pars_fortunae"].retrograde = False # Parts are never retrograde
1645
+ calculated_planets.append("Pars_Fortunae")
1646
+
1647
+ # Calculate Pars Spiritus (Part of Spirit)
1648
+ if should_calculate("Pars_Spiritus"):
1649
+ # Auto-activate required points with notification
1650
+ pars_spiritus_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1651
+ missing_points = [point for point in pars_spiritus_required_points if point not in active_points]
1652
+ if missing_points:
1653
+ logging.info(f"Automatically adding required points for Pars_Spiritus: {missing_points}")
1654
+ active_points.extend(cast(List[AstrologicalPoint], missing_points))
1655
+ # Recalculate the missing points
1656
+ for point in missing_points:
1657
+ if point == "Ascendant" and "ascendant" not in data:
1658
+ # Calculate Ascendant from houses data if needed
1659
+ if data["zodiac_type"] == "Sidereal":
1660
+ cusps, ascmc = swe.houses_ex(
1661
+ tjdut=data["julian_day"],
1662
+ lat=data["lat"],
1663
+ lon=data["lng"],
1664
+ hsys=str.encode(data["houses_system_identifier"]),
1665
+ flags=swe.FLG_SIDEREAL
1666
+ )
1667
+ else:
1668
+ cusps, ascmc = swe.houses(
1669
+ tjdut=data["julian_day"],
1670
+ lat=data["lat"],
1671
+ lon=data["lng"],
1672
+ hsys=str.encode(data["houses_system_identifier"])
1673
+ )
1674
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1675
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1676
+ data["ascendant"].retrograde = False
1677
+ elif point == "Sun" and "sun" not in data:
1678
+ sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1679
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1680
+ data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1681
+ data["sun"].retrograde = sun_calc[3] < 0
1682
+ elif point == "Moon" and "moon" not in data:
1683
+ moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1684
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1685
+ data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1686
+ data["moon"].retrograde = moon_calc[3] < 0
1687
+
1688
+ # Check if required points are available
1689
+ if all(k in data for k in ["ascendant", "sun", "moon"]):
1690
+ # Day birth: ASC + Sun - Moon
1691
+ # Night birth: ASC + Moon - Sun
1692
+ if data["sun"].house:
1693
+ is_day_chart = get_house_number(data["sun"].house) < 7
1694
+ else:
1695
+ is_day_chart = True # Default to day chart if house is None
1696
+
1697
+ if is_day_chart:
1698
+ spirit_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1699
+ else:
1700
+ spirit_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1701
+
1702
+ data["pars_spiritus"] = get_kerykeion_point_from_degree(spirit_deg, "Pars_Spiritus", point_type=point_type)
1703
+ data["pars_spiritus"].house = get_planet_house(spirit_deg, houses_degree_ut)
1704
+ data["pars_spiritus"].retrograde = False
1705
+ calculated_planets.append("Pars_Spiritus")
1706
+
1707
+ # Calculate Pars Amoris (Part of Eros/Love)
1708
+ if should_calculate("Pars_Amoris"):
1709
+ # Auto-activate required points with notification
1710
+ pars_amoris_required_points: List[AstrologicalPoint] = ["Ascendant", "Venus", "Sun"]
1711
+ missing_points = [point for point in pars_amoris_required_points if point not in active_points]
1712
+ if missing_points:
1713
+ logging.info(f"Automatically adding required points for Pars_Amoris: {missing_points}")
1714
+ active_points.extend(cast(List[AstrologicalPoint], missing_points))
1715
+ # Recalculate the missing points
1716
+ for point in missing_points:
1717
+ if point == "Ascendant" and "ascendant" not in data:
1718
+ # Calculate Ascendant from houses data if needed
1719
+ if data["zodiac_type"] == "Sidereal":
1720
+ cusps, ascmc = swe.houses_ex(
1721
+ tjdut=data["julian_day"],
1722
+ lat=data["lat"],
1723
+ lon=data["lng"],
1724
+ hsys=str.encode(data["houses_system_identifier"]),
1725
+ flags=swe.FLG_SIDEREAL
1726
+ )
1727
+ else:
1728
+ cusps, ascmc = swe.houses(
1729
+ tjdut=data["julian_day"],
1730
+ lat=data["lat"],
1731
+ lon=data["lng"],
1732
+ hsys=str.encode(data["houses_system_identifier"])
1733
+ )
1734
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1735
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1736
+ data["ascendant"].retrograde = False
1737
+ elif point == "Sun" and "sun" not in data:
1738
+ sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1739
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1740
+ data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1741
+ data["sun"].retrograde = sun_calc[3] < 0
1742
+ elif point == "Venus" and "venus" not in data:
1743
+ venus_calc = swe.calc_ut(julian_day, 3, iflag)[0]
1744
+ data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type, speed=venus_calc[3], declination=venus_calc[1])
1745
+ data["venus"].house = get_planet_house(venus_calc[0], houses_degree_ut)
1746
+ data["venus"].retrograde = venus_calc[3] < 0
1747
+
1748
+ # Check if required points are available
1749
+ if all(k in data for k in ["ascendant", "venus", "sun"]):
1750
+ # ASC + Venus - Sun
1751
+ amoris_deg = math.fmod(data["ascendant"].abs_pos + data["venus"].abs_pos - data["sun"].abs_pos, 360)
1752
+
1753
+ data["pars_amoris"] = get_kerykeion_point_from_degree(amoris_deg, "Pars_Amoris", point_type=point_type)
1754
+ data["pars_amoris"].house = get_planet_house(amoris_deg, houses_degree_ut)
1755
+ data["pars_amoris"].retrograde = False
1756
+ calculated_planets.append("Pars_Amoris")
1757
+
1758
+ # Calculate Pars Fidei (Part of Faith)
1759
+ if should_calculate("Pars_Fidei"):
1760
+ # Auto-activate required points with notification
1761
+ pars_fidei_required_points: List[AstrologicalPoint] = ["Ascendant", "Jupiter", "Saturn"]
1762
+ missing_points = [point for point in pars_fidei_required_points if point not in active_points]
1763
+ if missing_points:
1764
+ logging.info(f"Automatically adding required points for Pars_Fidei: {missing_points}")
1765
+ active_points.extend(cast(List[AstrologicalPoint], missing_points))
1766
+ # Recalculate the missing points
1767
+ for point in missing_points:
1768
+ if point == "Ascendant" and "ascendant" not in data:
1769
+ # Calculate Ascendant from houses data if needed
1770
+ if data["zodiac_type"] == "Sidereal":
1771
+ cusps, ascmc = swe.houses_ex(
1772
+ tjdut=data["julian_day"],
1773
+ lat=data["lat"],
1774
+ lon=data["lng"],
1775
+ hsys=str.encode(data["houses_system_identifier"]),
1776
+ flags=swe.FLG_SIDEREAL
1777
+ )
1778
+ else:
1779
+ cusps, ascmc = swe.houses(
1780
+ tjdut=data["julian_day"],
1781
+ lat=data["lat"],
1782
+ lon=data["lng"],
1783
+ hsys=str.encode(data["houses_system_identifier"])
1784
+ )
1785
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1786
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1787
+ data["ascendant"].retrograde = False
1788
+ elif point == "Jupiter" and "jupiter" not in data:
1789
+ jupiter_calc = swe.calc_ut(julian_day, 5, iflag)[0]
1790
+ data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type, speed=jupiter_calc[3], declination=jupiter_calc[1])
1791
+ data["jupiter"].house = get_planet_house(jupiter_calc[0], houses_degree_ut)
1792
+ data["jupiter"].retrograde = jupiter_calc[3] < 0
1793
+ elif point == "Saturn" and "saturn" not in data:
1794
+ saturn_calc = swe.calc_ut(julian_day, 6, iflag)[0]
1795
+ data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type, speed=saturn_calc[3], declination=saturn_calc[1])
1796
+ data["saturn"].house = get_planet_house(saturn_calc[0], houses_degree_ut)
1797
+ data["saturn"].retrograde = saturn_calc[3] < 0
1798
+
1799
+ # Check if required points are available
1800
+ if all(k in data for k in ["ascendant", "jupiter", "saturn"]):
1801
+ # ASC + Jupiter - Saturn
1802
+ fidei_deg = math.fmod(data["ascendant"].abs_pos + data["jupiter"].abs_pos - data["saturn"].abs_pos, 360)
1803
+
1804
+ data["pars_fidei"] = get_kerykeion_point_from_degree(fidei_deg, "Pars_Fidei", point_type=point_type)
1805
+ data["pars_fidei"].house = get_planet_house(fidei_deg, houses_degree_ut)
1806
+ data["pars_fidei"].retrograde = False
1807
+ calculated_planets.append("Pars_Fidei")
1808
+
1809
+ # Calculate Vertex and/or Anti-Vertex
1810
+ if should_calculate("Vertex") or should_calculate("Anti_Vertex"):
1811
+ try:
1812
+ # Vertex is at ascmc[3] in Swiss Ephemeris
1813
+ if data["zodiac_type"] == "Sidereal":
1814
+ _, ascmc = swe.houses_ex(
1815
+ tjdut=data["julian_day"],
1816
+ lat=data["lat"],
1817
+ lon=data["lng"],
1818
+ hsys=str.encode("V"), # Vertex works best with Vehlow system
1819
+ flags=swe.FLG_SIDEREAL
1820
+ )
1821
+ else:
1822
+ _, ascmc = swe.houses(
1823
+ tjdut=data["julian_day"],
1824
+ lat=data["lat"],
1825
+ lon=data["lng"],
1826
+ hsys=str.encode("V")
1827
+ )
1828
+
1829
+ vertex_deg = ascmc[3]
1830
+
1831
+ # Calculate Vertex if requested
1832
+ if should_calculate("Vertex"):
1833
+ data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1834
+ data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1835
+ data["vertex"].retrograde = False
1836
+ calculated_planets.append("Vertex")
1837
+
1838
+ # Calculate Anti-Vertex if requested
1839
+ if should_calculate("Anti_Vertex"):
1840
+ anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1841
+ data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1842
+ data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1843
+ data["anti_vertex"].retrograde = False
1844
+ calculated_planets.append("Anti_Vertex")
1845
+
1846
+ except Exception as e:
1847
+ logging.warning("Could not calculate Vertex/Anti-Vertex position, error: %s", e)
1848
+ if "Vertex" in active_points:
1849
+ active_points.remove("Vertex")
1850
+ if "Anti_Vertex" in active_points:
1851
+ active_points.remove("Anti_Vertex")
1852
+
1853
+ # Store only the planets that were actually calculated
1854
+ all_calculated_points = calculated_planets.copy()
1855
+ if calculated_axial_cusps:
1856
+ all_calculated_points.extend(calculated_axial_cusps)
1857
+ data["active_points"] = all_calculated_points
1858
+
1859
+ @staticmethod
1860
+ def _calculate_day_of_week(data: Dict[str, Any]) -> None:
1861
+ """
1862
+ Calculate the day of the week for the given astronomical event.
1863
+
1864
+ Determines the day of the week corresponding to the local datetime
1865
+ using the standard library for consistency.
1866
+
1867
+ Args:
1868
+ data (Dict[str, Any]): Calculation data dictionary containing
1869
+ iso_formatted_local_datetime. Updated with the calculated day_of_week string.
1870
+
1871
+ Side Effects:
1872
+ Updates data dictionary with:
1873
+ - day_of_week: Human-readable day name (e.g., "Monday", "Tuesday")
1874
+ """
1875
+ dt = datetime.fromisoformat(data["iso_formatted_local_datetime"])
1876
+ data["day_of_week"] = dt.strftime("%A")
1877
+
1878
+ if __name__ == "__main__":
1879
+ from kerykeion.schemas.kr_literals import AstrologicalPoint
1880
+
1881
+ # Example usage
1882
+ new_active_points: List[AstrologicalPoint] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']
1883
+ subject = AstrologicalSubjectFactory.from_current_time(name="Test Subject", active_points=new_active_points)
1884
+ print(subject.sun)
1885
+ print(subject.pars_amoris)
1886
+ print(subject.eris)
1887
+ print(subject.active_points)
1888
+ print(subject.pars_fidei)
1889
+ print("----")
1890
+ print(subject.anti_vertex)
1891
+
1892
+ # Create JSON output
1893
+ json_string = subject.model_dump_json(exclude_none=True, indent=2)
1894
+
1895
+ # Write JSON to home
1896
+ with open(Path.home() / "kerykeion_subject_example.json", "w", encoding="utf-8") as f:
1897
+ f.write(json_string)