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