kerykeion 5.0.0a9__py3-none-any.whl → 5.1.8__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 (79) hide show
  1. kerykeion/__init__.py +50 -9
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +78 -11
  5. kerykeion/astrological_subject_factory.py +1032 -275
  6. kerykeion/backword.py +820 -0
  7. kerykeion/chart_data_factory.py +552 -0
  8. kerykeion/charts/chart_drawer.py +2661 -0
  9. kerykeion/charts/charts_utils.py +652 -399
  10. kerykeion/charts/draw_planets.py +603 -353
  11. kerykeion/charts/templates/aspect_grid_only.xml +326 -198
  12. kerykeion/charts/templates/chart.xml +306 -256
  13. kerykeion/charts/templates/wheel_only.xml +330 -200
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +11 -0
  16. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  17. kerykeion/charts/themes/dark.css +11 -0
  18. kerykeion/charts/themes/light.css +11 -0
  19. kerykeion/charts/themes/strawberry.css +10 -0
  20. kerykeion/composite_subject_factory.py +232 -13
  21. kerykeion/ephemeris_data_factory.py +443 -0
  22. kerykeion/fetch_geonames.py +78 -21
  23. kerykeion/house_comparison/__init__.py +4 -1
  24. kerykeion/house_comparison/house_comparison_factory.py +52 -19
  25. kerykeion/house_comparison/house_comparison_utils.py +37 -9
  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 -160
  30. kerykeion/kr_types/kr_models.py +14 -291
  31. kerykeion/kr_types/settings_models.py +15 -167
  32. kerykeion/planetary_return_factory.py +545 -40
  33. kerykeion/relationship_score_factory.py +137 -63
  34. kerykeion/report.py +749 -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 +603 -0
  40. kerykeion/schemas/settings_models.py +188 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +88 -12
  44. kerykeion/settings/kerykeion_settings.py +32 -75
  45. kerykeion/settings/translation_strings.py +1499 -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 +289 -204
  57. kerykeion-5.1.8.dist-info/METADATA +1793 -0
  58. kerykeion-5.1.8.dist-info/RECORD +63 -0
  59. kerykeion/aspects/natal_aspects.py +0 -181
  60. kerykeion/aspects/synastry_aspects.py +0 -141
  61. kerykeion/aspects/transits_time_range.py +0 -41
  62. kerykeion/charts/draw_planets_v2.py +0 -649
  63. kerykeion/charts/draw_planets_v3.py +0 -679
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -2038
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -238
  67. kerykeion/house_comparison/house_comparison_models.py +0 -38
  68. kerykeion/kr_types/chart_types.py +0 -106
  69. kerykeion/settings/kr.config.json +0 -1304
  70. kerykeion/settings/legacy/__init__.py +0 -0
  71. kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
  72. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
  73. kerykeion/settings/legacy/legacy_color_settings.py +0 -42
  74. kerykeion/transits_time_range.py +0 -128
  75. kerykeion-5.0.0a9.dist-info/METADATA +0 -636
  76. kerykeion-5.0.0a9.dist-info/RECORD +0 -55
  77. kerykeion-5.0.0a9.dist-info/entry_points.txt +0 -2
  78. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/WHEEL +0 -0
  79. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/licenses/LICENSE +0 -0
@@ -1,28 +1,51 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """
3
- This is part of Kerykeion (C) 2025 Giacomo Battaglia
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
4
31
  """
5
32
 
6
33
  import pytz
7
34
  import swisseph as swe
8
35
  import logging
9
- import warnings
10
36
  import math
11
37
  from datetime import datetime
12
38
  from pathlib import Path
13
- from typing import Union, Optional, List, Dict, Any, Literal, get_args, cast, TypedDict, Set
14
- from functools import cached_property, lru_cache
39
+ from typing import Optional, List, Dict, Any, get_args, cast
15
40
  from dataclasses import dataclass, field
16
- from typing import Callable
41
+ from contextlib import contextmanager
17
42
 
18
43
 
19
44
  from kerykeion.fetch_geonames import FetchGeonames
20
- from kerykeion.kr_types import (
45
+ from kerykeion.schemas import (
21
46
  KerykeionException,
22
47
  ZodiacType,
23
48
  AstrologicalSubjectModel,
24
- LunarPhaseModel,
25
- KerykeionPointModel,
26
49
  PointType,
27
50
  SiderealMode,
28
51
  HousesSystemIdentifier,
@@ -31,13 +54,13 @@ from kerykeion.kr_types import (
31
54
  Houses,
32
55
  )
33
56
  from kerykeion.utilities import (
34
- get_number_from_name,
35
57
  get_kerykeion_point_from_degree,
36
58
  get_planet_house,
37
59
  check_and_adjust_polar_latitude,
38
60
  calculate_moon_phase,
39
61
  datetime_to_julian,
40
- get_house_number
62
+ get_house_number,
63
+ normalize_zodiac_type,
41
64
  )
42
65
  from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
43
66
 
@@ -45,7 +68,7 @@ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
45
68
  DEFAULT_GEONAMES_USERNAME = "century.boy"
46
69
  DEFAULT_SIDEREAL_MODE: SiderealMode = "FAGAN_BRADLEY"
47
70
  DEFAULT_HOUSES_SYSTEM_IDENTIFIER: HousesSystemIdentifier = "P"
48
- DEFAULT_ZODIAC_TYPE: ZodiacType = "Tropic"
71
+ DEFAULT_ZODIAC_TYPE: ZodiacType = "Tropical"
49
72
  DEFAULT_PERSPECTIVE_TYPE: PerspectiveType = "Apparent Geocentric"
50
73
  DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS = 30
51
74
 
@@ -60,28 +83,133 @@ GEONAMES_DEFAULT_USERNAME_WARNING = (
60
83
  "********"
61
84
  )
62
85
 
63
- NOW = datetime.now()
64
-
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)
65
140
 
66
141
  @dataclass
67
142
  class ChartConfiguration:
68
- """Configuration settings for astrological chart calculations"""
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
+ """
69
176
  zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE
70
177
  sidereal_mode: Optional[SiderealMode] = None
71
178
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER
72
179
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE
73
180
 
181
+ def __post_init__(self) -> None:
182
+ self.validate()
183
+
74
184
  def validate(self) -> None:
75
- """Validate configuration settings"""
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
+ """
76
201
  # Validate zodiac type
77
- if self.zodiac_type not in get_args(ZodiacType):
78
- raise KerykeionException(
79
- f"'{self.zodiac_type}' is not a valid zodiac type! Available types are: {get_args(ZodiacType)}"
80
- )
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
81
209
 
82
210
  # Validate sidereal mode settings
83
- if self.sidereal_mode and self.zodiac_type == "Tropic":
84
- raise KerykeionException("You can't set a sidereal mode with a Tropic zodiac type!")
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!")
85
213
 
86
214
  if self.zodiac_type == "Sidereal":
87
215
  if not self.sidereal_mode:
@@ -107,7 +235,39 @@ class ChartConfiguration:
107
235
 
108
236
  @dataclass
109
237
  class LocationData:
110
- """Information about a geographical location"""
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
+ """
111
271
  city: str = "Greenwich"
112
272
  nation: str = "GB"
113
273
  lat: float = 51.5074
@@ -119,7 +279,32 @@ class LocationData:
119
279
  city_data: Dict[str, str] = field(default_factory=dict)
120
280
 
121
281
  def fetch_from_geonames(self, username: str, cache_expire_after_days: int) -> None:
122
- """Fetch location data from geonames API"""
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
+ """
123
308
  logging.info(f"Fetching timezone/coordinates for {self.city}, {self.nation} from geonames")
124
309
 
125
310
  geonames = FetchGeonames(
@@ -148,29 +333,101 @@ class LocationData:
148
333
  self.tz_str = self.city_data["timezonestr"]
149
334
 
150
335
  def prepare_for_calculation(self) -> None:
151
- """Prepare location data for astrological calculations"""
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
+ """
152
353
  # Adjust latitude for polar regions
153
354
  self.lat = check_and_adjust_polar_latitude(self.lat)
154
355
 
155
356
 
156
357
  class AstrologicalSubjectFactory:
157
358
  """
158
- Factory class for creating astrological subjects with planetary positions,
159
- houses, and other astrological information for a specific time and location.
160
-
161
- This factory creates and returns AstrologicalSubjectModel instances and provides
162
- multiple creation methods for different initialization scenarios.
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.
163
420
  """
164
421
 
165
422
  @classmethod
166
423
  def from_birth_data(
167
424
  cls,
168
425
  name: str = "Now",
169
- year: int = NOW.year,
170
- month: int = NOW.month,
171
- day: int = NOW.day,
172
- hour: int = NOW.hour,
173
- minute: int = NOW.minute,
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,
174
431
  city: Optional[str] = None,
175
432
  nation: Optional[str] = None,
176
433
  lng: Optional[float] = None,
@@ -185,48 +442,146 @@ class AstrologicalSubjectFactory:
185
442
  cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
186
443
  is_dst: Optional[bool] = None,
187
444
  altitude: Optional[float] = None,
188
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
445
+ active_points: Optional[List[AstrologicalPoint]] = None,
189
446
  calculate_lunar_phase: bool = True,
190
447
  *,
191
448
  seconds: int = 0,
449
+ suppress_geonames_warning: bool = False,
192
450
 
193
451
  ) -> AstrologicalSubjectModel:
194
452
  """
195
- Create an astrological subject from standard birth/event details.
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.
196
459
 
197
460
  Args:
198
- name: Subject name
199
- year, month, day, hour, minute, seconds: Time components
200
- city: Location name
201
- nation: Country code
202
- lng, lat: Coordinates (optional if online=True)
203
- tz_str: Timezone string (optional if online=True)
204
- geonames_username: Username for geonames API
205
- online: Whether to fetch location data online
206
- zodiac_type: Type of zodiac (Tropical or Sidereal)
207
- sidereal_mode: Mode for sidereal calculations
208
- houses_system_identifier: House system for calculations
209
- perspective_type: Perspective for calculations
210
- cache_expire_after_days: Cache duration for geonames data
211
- is_dst: Daylight saving time flag
212
- altitude: Location altitude for topocentric calculations
213
- active_points: Set of points to calculate (optimization)
214
- calculate_lunar_phase: Whether to calculate lunar phase (requires Sun and Moon)
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.
215
511
 
216
512
  Returns:
217
- An AstrologicalSubjectModel with calculated data
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
218
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
+
219
571
  # Create a calculation data container
220
- calc_data = {}
572
+ calc_data: Dict[str, Any] = {}
221
573
 
222
574
  # Basic identity
223
575
  calc_data["name"] = name
224
576
  calc_data["json_dir"] = str(Path.home())
225
577
 
226
578
  # Create a deep copy of active points to avoid modifying the original list
227
- active_points = list(active_points)
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)
228
583
 
229
- calc_data["active_points"] = active_points
584
+ calc_data["active_points"] = active_points_list
230
585
 
231
586
  # Initialize configuration
232
587
  config = ChartConfiguration(
@@ -235,7 +590,6 @@ class AstrologicalSubjectFactory:
235
590
  houses_system_identifier=houses_system_identifier,
236
591
  perspective_type=perspective_type,
237
592
  )
238
- config.validate()
239
593
 
240
594
  # Add configuration data to calculation data
241
595
  calc_data["zodiac_type"] = config.zodiac_type
@@ -245,7 +599,8 @@ class AstrologicalSubjectFactory:
245
599
 
246
600
  # Set up geonames username if needed
247
601
  if geonames_username is None and online and (not lat or not lng or not tz_str):
248
- logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
602
+ if not suppress_geonames_warning:
603
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
249
604
  geonames_username = DEFAULT_GEONAMES_USERNAME
250
605
 
251
606
  # Initialize location data
@@ -292,20 +647,37 @@ class AstrologicalSubjectFactory:
292
647
  calc_data["is_dst"] = is_dst
293
648
 
294
649
  # Calculate time conversions
295
- cls._calculate_time_conversions(calc_data, location)
296
-
297
- # Initialize Swiss Ephemeris and calculate houses and planets
298
- cls._setup_ephemeris(calc_data, config)
299
- cls._calculate_houses(calc_data, calc_data["active_points"])
300
- cls._calculate_planets(calc_data, calc_data["active_points"])
301
- cls._calculate_day_of_week(calc_data)
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)
302
672
 
303
673
  # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
304
674
  if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
305
675
  calc_data["lunar_phase"] = calculate_moon_phase(
306
- calc_data["moon"].abs_pos,
307
- calc_data["sun"].abs_pos
676
+ calc_data["moon"].abs_pos, # type: ignore[attr-defined,union-attr]
677
+ calc_data["sun"].abs_pos # type: ignore[attr-defined,union-attr]
308
678
  )
679
+ else:
680
+ calc_data["lunar_phase"] = None
309
681
 
310
682
  # Create and return the AstrologicalSubjectModel
311
683
  return AstrologicalSubjectModel(**calc_data)
@@ -327,31 +699,83 @@ class AstrologicalSubjectFactory:
327
699
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
328
700
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
329
701
  altitude: Optional[float] = None,
330
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
331
- calculate_lunar_phase: bool = True
702
+ active_points: Optional[List[AstrologicalPoint]] = None,
703
+ calculate_lunar_phase: bool = True,
704
+ suppress_geonames_warning: bool = False
332
705
  ) -> AstrologicalSubjectModel:
333
706
  """
334
- Create an astrological subject from an ISO formatted UTC time.
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.
335
713
 
336
714
  Args:
337
- name: Subject name
338
- iso_utc_time: ISO formatted UTC time string
339
- city: Location name
340
- nation: Country code
341
- tz_str: Timezone string
342
- online: Whether to fetch location data online
343
- lng, lat: Coordinates
344
- geonames_username: Username for geonames API
345
- zodiac_type: Type of zodiac
346
- sidereal_mode: Mode for sidereal calculations
347
- houses_system_identifier: House system
348
- perspective_type: Perspective for calculations
349
- altitude: Location altitude
350
- active_points: Set of points to calculate
351
- calculate_lunar_phase: Whether to calculate lunar phase
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.
352
746
 
353
747
  Returns:
354
- AstrologicalSubjectModel instance
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
355
779
  """
356
780
  # Parse the ISO time
357
781
  dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
@@ -359,7 +783,8 @@ class AstrologicalSubjectFactory:
359
783
  # Get location data if online mode is enabled
360
784
  if online:
361
785
  if geonames_username == DEFAULT_GEONAMES_USERNAME:
362
- logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
786
+ if not suppress_geonames_warning:
787
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
363
788
 
364
789
  geonames = FetchGeonames(
365
790
  city,
@@ -397,7 +822,8 @@ class AstrologicalSubjectFactory:
397
822
  perspective_type=perspective_type,
398
823
  altitude=altitude,
399
824
  active_points=active_points,
400
- calculate_lunar_phase=calculate_lunar_phase
825
+ calculate_lunar_phase=calculate_lunar_phase,
826
+ suppress_geonames_warning=suppress_geonames_warning
401
827
  )
402
828
 
403
829
  @classmethod
@@ -415,29 +841,84 @@ class AstrologicalSubjectFactory:
415
841
  sidereal_mode: Optional[SiderealMode] = None,
416
842
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
417
843
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
418
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
419
- calculate_lunar_phase: bool = True
844
+ active_points: Optional[List[AstrologicalPoint]] = None,
845
+ calculate_lunar_phase: bool = True,
846
+ suppress_geonames_warning: bool = False
420
847
  ) -> AstrologicalSubjectModel:
421
848
  """
422
- Create an astrological subject for the current time.
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.
423
854
 
424
855
  Args:
425
- name: Subject name
426
- city: Location name
427
- nation: Country code
428
- lng, lat: Coordinates
429
- tz_str: Timezone string
430
- geonames_username: Username for geonames API
431
- online: Whether to fetch location data online
432
- zodiac_type: Type of zodiac
433
- sidereal_mode: Mode for sidereal calculations
434
- houses_system_identifier: House system
435
- perspective_type: Perspective for calculations
436
- active_points: Set of points to calculate
437
- calculate_lunar_phase: Whether to calculate lunar phase
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.
438
884
 
439
885
  Returns:
440
- AstrologicalSubjectModel for current time
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
441
922
  """
442
923
  now = datetime.now()
443
924
 
@@ -461,12 +942,38 @@ class AstrologicalSubjectFactory:
461
942
  houses_system_identifier=houses_system_identifier,
462
943
  perspective_type=perspective_type,
463
944
  active_points=active_points,
464
- calculate_lunar_phase=calculate_lunar_phase
945
+ calculate_lunar_phase=calculate_lunar_phase,
946
+ suppress_geonames_warning=suppress_geonames_warning
465
947
  )
466
948
 
467
- @classmethod
468
- def _calculate_time_conversions(cls, data: Dict[str, Any], location: LocationData) -> None:
469
- """Calculate time conversions between local time, UTC and Julian day"""
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
+ """
470
977
  # Convert local time to UTC
471
978
  local_timezone = pytz.timezone(location.tz_str)
472
979
  naive_datetime = datetime(
@@ -481,6 +988,11 @@ class AstrologicalSubjectFactory:
481
988
  "Ambiguous time error! The time falls during a DST transition. "
482
989
  "Please specify is_dst=True or is_dst=False to clarify."
483
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
+ )
484
996
 
485
997
  # Store formatted times
486
998
  utc_datetime = local_datetime.astimezone(pytz.utc)
@@ -490,46 +1002,50 @@ class AstrologicalSubjectFactory:
490
1002
  # Calculate Julian day
491
1003
  data["julian_day"] = datetime_to_julian(utc_datetime)
492
1004
 
493
- @classmethod
494
- def _setup_ephemeris(cls, data: Dict[str, Any], config: ChartConfiguration) -> None:
495
- """Set up Swiss Ephemeris with appropriate flags"""
496
- # Set ephemeris path
497
- swe.set_ephe_path(str(Path(__file__).parent.absolute() / "sweph"))
498
-
499
- # Base flags
500
- iflag = swe.FLG_SWIEPH + swe.FLG_SPEED
501
-
502
- # Add perspective flags
503
- if config.perspective_type == "True Geocentric":
504
- iflag += swe.FLG_TRUEPOS
505
- elif config.perspective_type == "Heliocentric":
506
- iflag += swe.FLG_HELCTR
507
- elif config.perspective_type == "Topocentric":
508
- iflag += swe.FLG_TOPOCTR
509
- # Set topocentric coordinates
510
- swe.set_topo(data["lng"], data["lat"], data["altitude"] or 0)
511
-
512
- # Add sidereal flag if needed
513
- if config.zodiac_type == "Sidereal":
514
- iflag += swe.FLG_SIDEREAL
515
- # Set sidereal mode
516
- mode = f"SIDM_{config.sidereal_mode}"
517
- swe.set_sid_mode(getattr(swe, mode))
518
- logging.debug(f"Using sidereal mode: {mode}")
519
-
520
- # Save house system name and iflag for later use
521
- data["houses_system_name"] = swe.house_name(
522
- config.houses_system_identifier.encode('ascii')
523
- )
524
- data["_iflag"] = iflag
525
1005
 
526
- @classmethod
527
- def _calculate_houses(cls, data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> None:
528
- """Calculate house cusps and axis points"""
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
+ """
529
1044
  # Skip calculation if point is not in active_points
530
- should_calculate: Callable[[AstrologicalPoint], bool] = lambda point: not active_points or point in active_points
1045
+ def should_calculate(point: AstrologicalPoint) -> bool:
1046
+ return not active_points or point in active_points
531
1047
  # Track which axial cusps are actually calculated
532
- calculated_axial_cusps = []
1048
+ calculated_axial_cusps: List[AstrologicalPoint] = []
533
1049
 
534
1050
  # Calculate houses based on zodiac type
535
1051
  if data["zodiac_type"] == "Sidereal":
@@ -602,9 +1118,10 @@ class AstrologicalSubjectFactory:
602
1118
  data["imum_coeli"].retrograde = False
603
1119
  calculated_axial_cusps.append("Imum_Coeli")
604
1120
 
605
- @classmethod
1121
+ return calculated_axial_cusps
1122
+
1123
+ @staticmethod
606
1124
  def _calculate_single_planet(
607
- cls,
608
1125
  data: Dict[str, Any],
609
1126
  planet_name: AstrologicalPoint,
610
1127
  planet_id: int,
@@ -612,30 +1129,61 @@ class AstrologicalSubjectFactory:
612
1129
  iflag: int,
613
1130
  houses_degree_ut: List[float],
614
1131
  point_type: PointType,
615
- calculated_planets: List[str],
1132
+ calculated_planets: List[AstrologicalPoint],
616
1133
  active_points: List[AstrologicalPoint]
617
1134
  ) -> None:
618
1135
  """
619
- Calculate a single planet's position with error handling and store it in the data dictionary.
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.
620
1142
 
621
1143
  Args:
622
- data: The data dictionary to store the planet information
623
- planet_name: Name of the planet
624
- planet_id: Swiss Ephemeris planet ID
625
- julian_day: Julian day for the calculation
626
- iflag: Swiss Ephemeris calculation flags
627
- houses_degree_ut: House degrees for house calculation
628
- point_type: Type of point being calculated
629
- calculated_planets: List to track calculated planets
630
- active_points: List of active points to modify if error occurs
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).
631
1175
  """
632
1176
  try:
633
- # Calculate planet position using Swiss Ephemeris
1177
+ # Calculate planet position using Swiss Ephemeris (ecliptic coordinates)
634
1178
  planet_calc = swe.calc_ut(julian_day, planet_id, iflag)[0]
635
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
+
636
1184
  # Create Kerykeion point from degree
637
1185
  data[planet_name.lower()] = get_kerykeion_point_from_degree(
638
- planet_calc[0], planet_name, point_type=point_type
1186
+ planet_calc[0], planet_name, point_type=point_type, speed=planet_calc[3], declination=declination
639
1187
  )
640
1188
 
641
1189
  # Calculate house position
@@ -652,11 +1200,93 @@ class AstrologicalSubjectFactory:
652
1200
  if planet_name in active_points:
653
1201
  active_points.remove(planet_name)
654
1202
 
655
- @classmethod
656
- def _calculate_planets(cls, data: Dict[str, Any], active_points: List[AstrologicalPoint]) -> None:
657
- """Calculate planetary positions and related information"""
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
+ """
658
1287
  # Skip calculation if point is not in active_points
659
- should_calculate: Callable[[AstrologicalPoint], bool] = lambda point: not active_points or point in active_points
1288
+ def should_calculate(point: AstrologicalPoint) -> bool:
1289
+ return not active_points or point in active_points
660
1290
 
661
1291
  point_type: PointType = "AstrologicalPoint"
662
1292
  julian_day = data["julian_day"]
@@ -664,7 +1294,7 @@ class AstrologicalSubjectFactory:
664
1294
  houses_degree_ut = data["_houses_degree_ut"]
665
1295
 
666
1296
  # Track which planets are actually calculated
667
- calculated_planets = []
1297
+ calculated_planets: List[AstrologicalPoint] = []
668
1298
 
669
1299
  # ==================
670
1300
  # MAIN PLANETS
@@ -672,75 +1302,87 @@ class AstrologicalSubjectFactory:
672
1302
 
673
1303
  # Calculate Sun
674
1304
  if should_calculate("Sun"):
675
- cls._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1305
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
676
1306
 
677
1307
  # Calculate Moon
678
1308
  if should_calculate("Moon"):
679
- cls._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1309
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
680
1310
 
681
1311
  # Calculate Mercury
682
1312
  if should_calculate("Mercury"):
683
- cls._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1313
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
684
1314
 
685
1315
  # Calculate Venus
686
1316
  if should_calculate("Venus"):
687
- cls._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1317
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
688
1318
 
689
1319
  # Calculate Mars
690
1320
  if should_calculate("Mars"):
691
- cls._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1321
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
692
1322
 
693
1323
  # Calculate Jupiter
694
1324
  if should_calculate("Jupiter"):
695
- cls._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1325
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
696
1326
 
697
1327
  # Calculate Saturn
698
1328
  if should_calculate("Saturn"):
699
- cls._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1329
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
700
1330
 
701
1331
  # Calculate Uranus
702
1332
  if should_calculate("Uranus"):
703
- cls._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1333
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
704
1334
 
705
1335
  # Calculate Neptune
706
1336
  if should_calculate("Neptune"):
707
- cls._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1337
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
708
1338
 
709
1339
  # Calculate Pluto
710
1340
  if should_calculate("Pluto"):
711
- cls._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1341
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
712
1342
 
713
1343
  # ==================
714
1344
  # LUNAR NODES
715
1345
  # ==================
716
1346
 
717
- # Calculate Mean Lunar Node
718
- if should_calculate("Mean_Node"):
719
- cls._calculate_single_planet(data, "Mean_Node", 10, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
720
-
721
- # Calculate True Lunar Node
722
- if should_calculate("True_Node"):
723
- cls._calculate_single_planet(data, "True_Node", 11, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
724
-
725
- # Calculate Mean South Node (opposite to Mean North Node)
726
- if should_calculate("Mean_South_Node") and "mean_node" in data:
727
- mean_south_node_deg = math.fmod(data["mean_node"].abs_pos + 180, 360)
728
- data["mean_south_node"] = get_kerykeion_point_from_degree(
729
- mean_south_node_deg, "Mean_South_Node", point_type=point_type
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
730
1370
  )
731
- data["mean_south_node"].house = get_planet_house(mean_south_node_deg, houses_degree_ut)
732
- data["mean_south_node"].retrograde = data["mean_node"].retrograde
733
- calculated_planets.append("Mean_South_Node")
734
-
735
- # Calculate True South Node (opposite to True North Node)
736
- if should_calculate("True_South_Node") and "true_node" in data:
737
- true_south_node_deg = math.fmod(data["true_node"].abs_pos + 180, 360)
738
- data["true_south_node"] = get_kerykeion_point_from_degree(
739
- true_south_node_deg, "True_South_Node", point_type=point_type
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
740
1382
  )
741
- data["true_south_node"].house = get_planet_house(true_south_node_deg, houses_degree_ut)
742
- data["true_south_node"].retrograde = data["true_node"].retrograde
743
- calculated_planets.append("True_South_Node")
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")
744
1386
 
745
1387
  # ==================
746
1388
  # LILITH POINTS
@@ -748,14 +1390,14 @@ class AstrologicalSubjectFactory:
748
1390
 
749
1391
  # Calculate Mean Lilith (Mean Black Moon)
750
1392
  if should_calculate("Mean_Lilith"):
751
- cls._calculate_single_planet(
1393
+ AstrologicalSubjectFactory._calculate_single_planet(
752
1394
  data, "Mean_Lilith", 12, julian_day, iflag, houses_degree_ut,
753
1395
  point_type, calculated_planets, active_points
754
1396
  )
755
1397
 
756
1398
  # Calculate True Lilith (Osculating Black Moon)
757
1399
  if should_calculate("True_Lilith"):
758
- cls._calculate_single_planet(
1400
+ AstrologicalSubjectFactory._calculate_single_planet(
759
1401
  data, "True_Lilith", 13, julian_day, iflag, houses_degree_ut,
760
1402
  point_type, calculated_planets, active_points
761
1403
  )
@@ -766,21 +1408,21 @@ class AstrologicalSubjectFactory:
766
1408
 
767
1409
  # Calculate Earth - useful for heliocentric charts
768
1410
  if should_calculate("Earth"):
769
- cls._calculate_single_planet(
1411
+ AstrologicalSubjectFactory._calculate_single_planet(
770
1412
  data, "Earth", 14, julian_day, iflag, houses_degree_ut,
771
1413
  point_type, calculated_planets, active_points
772
1414
  )
773
1415
 
774
1416
  # Calculate Chiron
775
1417
  if should_calculate("Chiron"):
776
- cls._calculate_single_planet(
1418
+ AstrologicalSubjectFactory._calculate_single_planet(
777
1419
  data, "Chiron", 15, julian_day, iflag, houses_degree_ut,
778
1420
  point_type, calculated_planets, active_points
779
1421
  )
780
1422
 
781
1423
  # Calculate Pholus
782
1424
  if should_calculate("Pholus"):
783
- cls._calculate_single_planet(
1425
+ AstrologicalSubjectFactory._calculate_single_planet(
784
1426
  data, "Pholus", 16, julian_day, iflag, houses_degree_ut,
785
1427
  point_type, calculated_planets, active_points
786
1428
  )
@@ -791,28 +1433,28 @@ class AstrologicalSubjectFactory:
791
1433
 
792
1434
  # Calculate Ceres
793
1435
  if should_calculate("Ceres"):
794
- cls._calculate_single_planet(
1436
+ AstrologicalSubjectFactory._calculate_single_planet(
795
1437
  data, "Ceres", 17, julian_day, iflag, houses_degree_ut,
796
1438
  point_type, calculated_planets, active_points
797
1439
  )
798
1440
 
799
1441
  # Calculate Pallas
800
1442
  if should_calculate("Pallas"):
801
- cls._calculate_single_planet(
1443
+ AstrologicalSubjectFactory._calculate_single_planet(
802
1444
  data, "Pallas", 18, julian_day, iflag, houses_degree_ut,
803
1445
  point_type, calculated_planets, active_points
804
1446
  )
805
1447
 
806
1448
  # Calculate Juno
807
1449
  if should_calculate("Juno"):
808
- cls._calculate_single_planet(
1450
+ AstrologicalSubjectFactory._calculate_single_planet(
809
1451
  data, "Juno", 19, julian_day, iflag, houses_degree_ut,
810
1452
  point_type, calculated_planets, active_points
811
1453
  )
812
1454
 
813
1455
  # Calculate Vesta
814
1456
  if should_calculate("Vesta"):
815
- cls._calculate_single_planet(
1457
+ AstrologicalSubjectFactory._calculate_single_planet(
816
1458
  data, "Vesta", 20, julian_day, iflag, houses_degree_ut,
817
1459
  point_type, calculated_planets, active_points
818
1460
  )
@@ -825,7 +1467,7 @@ class AstrologicalSubjectFactory:
825
1467
  if should_calculate("Eris"):
826
1468
  try:
827
1469
  eris_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136199, iflag)[0]
828
- data["eris"] = get_kerykeion_point_from_degree(eris_calc[0], "Eris", point_type=point_type)
1470
+ data["eris"] = get_kerykeion_point_from_degree(eris_calc[0], "Eris", point_type=point_type, speed=eris_calc[3], declination=eris_calc[1])
829
1471
  data["eris"].house = get_planet_house(eris_calc[0], houses_degree_ut)
830
1472
  data["eris"].retrograde = eris_calc[3] < 0
831
1473
  calculated_planets.append("Eris")
@@ -837,7 +1479,7 @@ class AstrologicalSubjectFactory:
837
1479
  if should_calculate("Sedna"):
838
1480
  try:
839
1481
  sedna_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90377, iflag)[0]
840
- data["sedna"] = get_kerykeion_point_from_degree(sedna_calc[0], "Sedna", point_type=point_type)
1482
+ data["sedna"] = get_kerykeion_point_from_degree(sedna_calc[0], "Sedna", point_type=point_type, speed=sedna_calc[3], declination=sedna_calc[1])
841
1483
  data["sedna"].house = get_planet_house(sedna_calc[0], houses_degree_ut)
842
1484
  data["sedna"].retrograde = sedna_calc[3] < 0
843
1485
  calculated_planets.append("Sedna")
@@ -849,7 +1491,7 @@ class AstrologicalSubjectFactory:
849
1491
  if should_calculate("Haumea"):
850
1492
  try:
851
1493
  haumea_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136108, iflag)[0]
852
- data["haumea"] = get_kerykeion_point_from_degree(haumea_calc[0], "Haumea", point_type=point_type)
1494
+ data["haumea"] = get_kerykeion_point_from_degree(haumea_calc[0], "Haumea", point_type=point_type, speed=haumea_calc[3], declination=haumea_calc[1])
853
1495
  data["haumea"].house = get_planet_house(haumea_calc[0], houses_degree_ut)
854
1496
  data["haumea"].retrograde = haumea_calc[3] < 0
855
1497
  calculated_planets.append("Haumea")
@@ -861,7 +1503,7 @@ class AstrologicalSubjectFactory:
861
1503
  if should_calculate("Makemake"):
862
1504
  try:
863
1505
  makemake_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136472, iflag)[0]
864
- data["makemake"] = get_kerykeion_point_from_degree(makemake_calc[0], "Makemake", point_type=point_type)
1506
+ data["makemake"] = get_kerykeion_point_from_degree(makemake_calc[0], "Makemake", point_type=point_type, speed=makemake_calc[3], declination=makemake_calc[1])
865
1507
  data["makemake"].house = get_planet_house(makemake_calc[0], houses_degree_ut)
866
1508
  data["makemake"].retrograde = makemake_calc[3] < 0
867
1509
  calculated_planets.append("Makemake")
@@ -873,7 +1515,7 @@ class AstrologicalSubjectFactory:
873
1515
  if should_calculate("Ixion"):
874
1516
  try:
875
1517
  ixion_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 28978, iflag)[0]
876
- data["ixion"] = get_kerykeion_point_from_degree(ixion_calc[0], "Ixion", point_type=point_type)
1518
+ data["ixion"] = get_kerykeion_point_from_degree(ixion_calc[0], "Ixion", point_type=point_type, speed=ixion_calc[3], declination=ixion_calc[1])
877
1519
  data["ixion"].house = get_planet_house(ixion_calc[0], houses_degree_ut)
878
1520
  data["ixion"].retrograde = ixion_calc[3] < 0
879
1521
  calculated_planets.append("Ixion")
@@ -885,7 +1527,7 @@ class AstrologicalSubjectFactory:
885
1527
  if should_calculate("Orcus"):
886
1528
  try:
887
1529
  orcus_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90482, iflag)[0]
888
- data["orcus"] = get_kerykeion_point_from_degree(orcus_calc[0], "Orcus", point_type=point_type)
1530
+ data["orcus"] = get_kerykeion_point_from_degree(orcus_calc[0], "Orcus", point_type=point_type, speed=orcus_calc[3], declination=orcus_calc[1])
889
1531
  data["orcus"].house = get_planet_house(orcus_calc[0], houses_degree_ut)
890
1532
  data["orcus"].retrograde = orcus_calc[3] < 0
891
1533
  calculated_planets.append("Orcus")
@@ -897,7 +1539,7 @@ class AstrologicalSubjectFactory:
897
1539
  if should_calculate("Quaoar"):
898
1540
  try:
899
1541
  quaoar_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 50000, iflag)[0]
900
- data["quaoar"] = get_kerykeion_point_from_degree(quaoar_calc[0], "Quaoar", point_type=point_type)
1542
+ data["quaoar"] = get_kerykeion_point_from_degree(quaoar_calc[0], "Quaoar", point_type=point_type, speed=quaoar_calc[3], declination=quaoar_calc[1])
901
1543
  data["quaoar"].house = get_planet_house(quaoar_calc[0], houses_degree_ut)
902
1544
  data["quaoar"].retrograde = quaoar_calc[3] < 0
903
1545
  calculated_planets.append("Quaoar")
@@ -912,10 +1554,12 @@ class AstrologicalSubjectFactory:
912
1554
  # Calculate Regulus (example fixed star)
913
1555
  if should_calculate("Regulus"):
914
1556
  try:
915
- star_name = b"Regulus"
916
- swe.fixstar_ut(star_name, julian_day, iflag)
917
- regulus_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
918
- data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type)
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)
919
1563
  data["regulus"].house = get_planet_house(regulus_deg, houses_degree_ut)
920
1564
  data["regulus"].retrograde = False # Fixed stars are never retrograde
921
1565
  calculated_planets.append("Regulus")
@@ -926,10 +1570,12 @@ class AstrologicalSubjectFactory:
926
1570
  # Calculate Spica (example fixed star)
927
1571
  if should_calculate("Spica"):
928
1572
  try:
929
- star_name = b"Spica"
930
- swe.fixstar_ut(star_name, julian_day, iflag)
931
- spica_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
932
- data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type)
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)
933
1579
  data["spica"].house = get_planet_house(spica_deg, houses_degree_ut)
934
1580
  data["spica"].retrograde = False # Fixed stars are never retrograde
935
1581
  calculated_planets.append("Spica")
@@ -944,21 +1590,41 @@ class AstrologicalSubjectFactory:
944
1590
  # Calculate Pars Fortunae (Part of Fortune)
945
1591
  if should_calculate("Pars_Fortunae"):
946
1592
  # Auto-activate required points with notification
947
- required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
948
- missing_points = [point for point in required_points if point not in active_points]
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]
949
1595
  if missing_points:
950
1596
  logging.info(f"Automatically adding required points for Pars_Fortunae: {missing_points}")
951
1597
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
952
1598
  # Recalculate the missing points
953
1599
  for point in missing_points:
954
- if point == "Sun" and point not in data:
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:
955
1621
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
956
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1622
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
957
1623
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
958
1624
  data["sun"].retrograde = sun_calc[3] < 0
959
- elif point == "Moon" and point not in data:
1625
+ elif point == "Moon" and "moon" not in data:
960
1626
  moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
961
- data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type)
1627
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
962
1628
  data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
963
1629
  data["moon"].retrograde = moon_calc[3] < 0
964
1630
 
@@ -985,21 +1651,41 @@ class AstrologicalSubjectFactory:
985
1651
  # Calculate Pars Spiritus (Part of Spirit)
986
1652
  if should_calculate("Pars_Spiritus"):
987
1653
  # Auto-activate required points with notification
988
- required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
989
- missing_points = [point for point in required_points if point not in active_points]
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]
990
1656
  if missing_points:
991
1657
  logging.info(f"Automatically adding required points for Pars_Spiritus: {missing_points}")
992
1658
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
993
1659
  # Recalculate the missing points
994
1660
  for point in missing_points:
995
- if point == "Sun" and point not in data:
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:
996
1682
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
997
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1683
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
998
1684
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
999
1685
  data["sun"].retrograde = sun_calc[3] < 0
1000
- elif point == "Moon" and point not in data:
1686
+ elif point == "Moon" and "moon" not in data:
1001
1687
  moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1002
- data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type)
1688
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1003
1689
  data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1004
1690
  data["moon"].retrograde = moon_calc[3] < 0
1005
1691
 
@@ -1025,21 +1711,41 @@ class AstrologicalSubjectFactory:
1025
1711
  # Calculate Pars Amoris (Part of Eros/Love)
1026
1712
  if should_calculate("Pars_Amoris"):
1027
1713
  # Auto-activate required points with notification
1028
- required_points: List[AstrologicalPoint] = ["Ascendant", "Venus", "Sun"]
1029
- missing_points = [point for point in required_points if point not in active_points]
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]
1030
1716
  if missing_points:
1031
1717
  logging.info(f"Automatically adding required points for Pars_Amoris: {missing_points}")
1032
1718
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1033
1719
  # Recalculate the missing points
1034
1720
  for point in missing_points:
1035
- if point == "Sun" and point not in data:
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:
1036
1742
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1037
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1743
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1038
1744
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1039
1745
  data["sun"].retrograde = sun_calc[3] < 0
1040
- elif point == "Venus" and point not in data:
1746
+ elif point == "Venus" and "venus" not in data:
1041
1747
  venus_calc = swe.calc_ut(julian_day, 3, iflag)[0]
1042
- data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type)
1748
+ data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type, speed=venus_calc[3], declination=venus_calc[1])
1043
1749
  data["venus"].house = get_planet_house(venus_calc[0], houses_degree_ut)
1044
1750
  data["venus"].retrograde = venus_calc[3] < 0
1045
1751
 
@@ -1056,21 +1762,41 @@ class AstrologicalSubjectFactory:
1056
1762
  # Calculate Pars Fidei (Part of Faith)
1057
1763
  if should_calculate("Pars_Fidei"):
1058
1764
  # Auto-activate required points with notification
1059
- required_points: List[AstrologicalPoint] = ["Ascendant", "Jupiter", "Saturn"]
1060
- missing_points = [point for point in required_points if point not in active_points]
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]
1061
1767
  if missing_points:
1062
1768
  logging.info(f"Automatically adding required points for Pars_Fidei: {missing_points}")
1063
1769
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1064
1770
  # Recalculate the missing points
1065
1771
  for point in missing_points:
1066
- if point == "Jupiter" and point not in data:
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:
1067
1793
  jupiter_calc = swe.calc_ut(julian_day, 5, iflag)[0]
1068
- data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type)
1794
+ data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type, speed=jupiter_calc[3], declination=jupiter_calc[1])
1069
1795
  data["jupiter"].house = get_planet_house(jupiter_calc[0], houses_degree_ut)
1070
1796
  data["jupiter"].retrograde = jupiter_calc[3] < 0
1071
- elif point == "Saturn" and point not in data:
1797
+ elif point == "Saturn" and "saturn" not in data:
1072
1798
  saturn_calc = swe.calc_ut(julian_day, 6, iflag)[0]
1073
- data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type)
1799
+ data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type, speed=saturn_calc[3], declination=saturn_calc[1])
1074
1800
  data["saturn"].house = get_planet_house(saturn_calc[0], houses_degree_ut)
1075
1801
  data["saturn"].retrograde = saturn_calc[3] < 0
1076
1802
 
@@ -1084,8 +1810,8 @@ class AstrologicalSubjectFactory:
1084
1810
  data["pars_fidei"].retrograde = False
1085
1811
  calculated_planets.append("Pars_Fidei")
1086
1812
 
1087
- # Calculate Vertex (a sort of auxiliary Descendant)
1088
- if should_calculate("Vertex"):
1813
+ # Calculate Vertex and/or Anti-Vertex
1814
+ if should_calculate("Vertex") or should_calculate("Anti_Vertex"):
1089
1815
  try:
1090
1816
  # Vertex is at ascmc[3] in Swiss Ephemeris
1091
1817
  if data["zodiac_type"] == "Sidereal":
@@ -1105,40 +1831,71 @@ class AstrologicalSubjectFactory:
1105
1831
  )
1106
1832
 
1107
1833
  vertex_deg = ascmc[3]
1108
- data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1109
- data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1110
- data["vertex"].retrograde = False
1111
- calculated_planets.append("Vertex")
1112
-
1113
- # Calculate Anti-Vertex (opposite to Vertex)
1114
- anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1115
- data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1116
- data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1117
- data["anti_vertex"].retrograde = False
1118
- calculated_planets.append("Anti_Vertex")
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
+
1119
1850
  except Exception as e:
1120
- logging.warning("Could not calculate Vertex position, error: %s", e)
1121
- active_points.remove("Vertex")
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")
1122
1856
 
1123
1857
  # Store only the planets that were actually calculated
1124
- data["active_points"] = calculated_planets
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
1125
1862
 
1126
- @classmethod
1127
- def _calculate_day_of_week(cls, data: Dict[str, Any]) -> None:
1128
- """Calculate the day of the week for the given Julian Day"""
1129
- # Calculate the day of the week (0=Sunday, 1=Monday, ..., 6=Saturday)
1130
- day_of_week = swe.day_of_week(data["julian_day"])
1131
- # Map to human-readable names
1132
- days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
1133
- data["day_of_week"] = days_of_week[day_of_week]
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")
1134
1881
 
1135
1882
  if __name__ == "__main__":
1883
+ from kerykeion.schemas.kr_literals import AstrologicalPoint
1884
+
1136
1885
  # Example usage
1137
- subject = AstrologicalSubjectFactory.from_current_time(name="Test Subject")
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)
1138
1888
  print(subject.sun)
1139
1889
  print(subject.pars_amoris)
1140
1890
  print(subject.eris)
1141
1891
  print(subject.active_points)
1892
+ print(subject.pars_fidei)
1893
+ print("----")
1894
+ print(subject.anti_vertex)
1142
1895
 
1143
1896
  # Create JSON output
1144
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)