kerykeion 5.0.0a12__py3-none-any.whl → 5.0.0b2__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 (51) hide show
  1. kerykeion/__init__.py +30 -6
  2. kerykeion/aspects/aspects_factory.py +40 -24
  3. kerykeion/aspects/aspects_utils.py +75 -6
  4. kerykeion/astrological_subject_factory.py +377 -226
  5. kerykeion/backword.py +680 -0
  6. kerykeion/chart_data_factory.py +484 -0
  7. kerykeion/charts/{kerykeion_chart_svg.py → chart_drawer.py} +688 -440
  8. kerykeion/charts/charts_utils.py +157 -94
  9. kerykeion/charts/draw_planets.py +38 -28
  10. kerykeion/charts/templates/aspect_grid_only.xml +188 -17
  11. kerykeion/charts/templates/chart.xml +153 -47
  12. kerykeion/charts/templates/wheel_only.xml +195 -24
  13. kerykeion/charts/themes/classic.css +11 -0
  14. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  15. kerykeion/charts/themes/dark.css +11 -0
  16. kerykeion/charts/themes/light.css +11 -0
  17. kerykeion/charts/themes/strawberry.css +10 -0
  18. kerykeion/composite_subject_factory.py +4 -4
  19. kerykeion/ephemeris_data_factory.py +12 -9
  20. kerykeion/house_comparison/__init__.py +0 -3
  21. kerykeion/house_comparison/house_comparison_factory.py +3 -3
  22. kerykeion/house_comparison/house_comparison_utils.py +3 -4
  23. kerykeion/planetary_return_factory.py +8 -4
  24. kerykeion/relationship_score_factory.py +3 -3
  25. kerykeion/report.py +748 -67
  26. kerykeion/{kr_types → schemas}/__init__.py +44 -4
  27. kerykeion/schemas/chart_template_model.py +367 -0
  28. kerykeion/{kr_types → schemas}/kr_literals.py +7 -3
  29. kerykeion/{kr_types → schemas}/kr_models.py +220 -11
  30. kerykeion/{kr_types → schemas}/settings_models.py +7 -7
  31. kerykeion/settings/config_constants.py +75 -8
  32. kerykeion/settings/kerykeion_settings.py +1 -1
  33. kerykeion/settings/kr.config.json +132 -42
  34. kerykeion/settings/legacy/legacy_celestial_points_settings.py +8 -8
  35. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  36. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  37. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  38. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  39. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  40. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  41. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  42. kerykeion/transits_time_range_factory.py +7 -7
  43. kerykeion/utilities.py +61 -38
  44. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/METADATA +507 -120
  45. kerykeion-5.0.0b2.dist-info/RECORD +58 -0
  46. kerykeion/house_comparison/house_comparison_models.py +0 -76
  47. kerykeion/kr_types/chart_types.py +0 -106
  48. kerykeion-5.0.0a12.dist-info/RECORD +0 -50
  49. /kerykeion/{kr_types → schemas}/kerykeion_exception.py +0 -0
  50. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/WHEEL +0 -0
  51. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/licenses/LICENSE +0 -0
@@ -38,10 +38,11 @@ from datetime import datetime
38
38
  from pathlib import Path
39
39
  from typing import Optional, List, Dict, Any, get_args, cast
40
40
  from dataclasses import dataclass, field
41
+ from contextlib import contextmanager
41
42
 
42
43
 
43
44
  from kerykeion.fetch_geonames import FetchGeonames
44
- from kerykeion.kr_types import (
45
+ from kerykeion.schemas import (
45
46
  KerykeionException,
46
47
  ZodiacType,
47
48
  AstrologicalSubjectModel,
@@ -81,8 +82,60 @@ GEONAMES_DEFAULT_USERNAME_WARNING = (
81
82
  "********"
82
83
  )
83
84
 
84
- NOW = datetime.now()
85
-
85
+ @contextmanager
86
+ def ephemeris_context(
87
+ ephe_path: str,
88
+ config: "ChartConfiguration",
89
+ lng: float,
90
+ lat: float,
91
+ alt: Optional[float] = None,
92
+ ):
93
+ """Context manager that isolates Swiss Ephemeris configuration.
94
+
95
+ Responsibilities:
96
+ - Set ephemeris path and calculation flags
97
+ - Configure perspective (true geo / helio / topo)
98
+ - Configure sidereal mode when needed
99
+ - Apply topocentric observer only inside the with-block
100
+ - Yield iflag for calculations
101
+ - Reset topocentric coordinates afterward (defensive)
102
+
103
+ Args:
104
+ ephe_path: Path containing Swiss Ephemeris data files.
105
+ config: Validated chart configuration.
106
+ lng: Observer longitude (used for topocentric charts).
107
+ lat: Observer latitude (used for topocentric charts).
108
+ alt: Observer altitude (meters) for topocentric charts.
109
+
110
+ Yields:
111
+ int: iflag to be passed to swe.calc_ut / swe.fixstar_ut.
112
+ """
113
+ swe.set_ephe_path(ephe_path)
114
+ iflag = swe.FLG_SWIEPH | swe.FLG_SPEED
115
+
116
+ topo_used = False
117
+
118
+ # Perspective configuration
119
+ if config.perspective_type == "True Geocentric":
120
+ iflag |= swe.FLG_TRUEPOS
121
+ elif config.perspective_type == "Heliocentric":
122
+ iflag |= swe.FLG_HELCTR
123
+ elif config.perspective_type == "Topocentric":
124
+ iflag |= swe.FLG_TOPOCTR
125
+ swe.set_topo(lng, lat, alt or 0.0)
126
+ topo_used = True
127
+
128
+ # Sidereal configuration
129
+ if config.zodiac_type == "Sidereal":
130
+ iflag |= swe.FLG_SIDEREAL
131
+ swe.set_sid_mode(getattr(swe, f"SIDM_{config.sidereal_mode}"))
132
+
133
+ try:
134
+ yield iflag
135
+ finally:
136
+ # Defensive cleanup: reset topo if it was set
137
+ if topo_used:
138
+ swe.set_topo(0.0, 0.0, 0.0)
86
139
 
87
140
  @dataclass
88
141
  class ChartConfiguration:
@@ -118,13 +171,15 @@ class ChartConfiguration:
118
171
  ... houses_system_identifier="K",
119
172
  ... perspective_type="Topocentric"
120
173
  ... )
121
- >>> config.validate()
122
174
  """
123
175
  zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE
124
176
  sidereal_mode: Optional[SiderealMode] = None
125
177
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER
126
178
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE
127
179
 
180
+ def __post_init__(self) -> None:
181
+ self.validate()
182
+
128
183
  def validate(self) -> None:
129
184
  """
130
185
  Validate configuration settings for internal consistency.
@@ -364,11 +419,11 @@ class AstrologicalSubjectFactory:
364
419
  def from_birth_data(
365
420
  cls,
366
421
  name: str = "Now",
367
- year: int = NOW.year,
368
- month: int = NOW.month,
369
- day: int = NOW.day,
370
- hour: int = NOW.hour,
371
- minute: int = NOW.minute,
422
+ year: Optional[int] = None,
423
+ month: Optional[int] = None,
424
+ day: Optional[int] = None,
425
+ hour: Optional[int] = None,
426
+ minute: Optional[int] = None,
372
427
  city: Optional[str] = None,
373
428
  nation: Optional[str] = None,
374
429
  lng: Optional[float] = None,
@@ -383,10 +438,11 @@ class AstrologicalSubjectFactory:
383
438
  cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
384
439
  is_dst: Optional[bool] = None,
385
440
  altitude: Optional[float] = None,
386
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
441
+ active_points: Optional[List[AstrologicalPoint]] = None,
387
442
  calculate_lunar_phase: bool = True,
388
443
  *,
389
444
  seconds: int = 0,
445
+ suppress_geonames_warning: bool = False,
390
446
 
391
447
  ) -> AstrologicalSubjectModel:
392
448
  """
@@ -440,11 +496,14 @@ class AstrologicalSubjectFactory:
440
496
  altitude (float, optional): Altitude above sea level in meters. Used for
441
497
  topocentric calculations and atmospheric corrections. Defaults to None
442
498
  (sea level assumed).
443
- active_points (List[AstrologicalPoint], optional): List of astrological
499
+ active_points (Optional[List[AstrologicalPoint]], optional): List of astrological
444
500
  points to calculate. Omitting points can improve performance for
445
- specialized applications. Defaults to DEFAULT_ACTIVE_POINTS.
501
+ specialized applications. If None, uses DEFAULT_ACTIVE_POINTS.
446
502
  calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
447
503
  Requires Sun and Moon in active_points. Defaults to True.
504
+ suppress_geonames_warning (bool, optional): If True, suppresses the warning
505
+ message when using the default GeoNames username. Useful for testing
506
+ or automated processes. Defaults to False.
448
507
 
449
508
  Returns:
450
509
  AstrologicalSubjectModel: Complete astrological subject with calculated
@@ -495,17 +554,30 @@ class AstrologicalSubjectFactory:
495
554
  - The method handles polar regions by adjusting extreme latitudes
496
555
  - Time zones are handled with full DST awareness via pytz
497
556
  """
557
+ # Resolve time defaults using current time
558
+ if year is None or month is None or day is None or hour is None or minute is None or seconds is None:
559
+ now = datetime.now()
560
+ year = year if year is not None else now.year
561
+ month = month if month is not None else now.month
562
+ day = day if day is not None else now.day
563
+ hour = hour if hour is not None else now.hour
564
+ minute = minute if minute is not None else now.minute
565
+ seconds = seconds if seconds is not None else now.second
566
+
498
567
  # Create a calculation data container
499
- calc_data = {}
568
+ calc_data: Dict[str, Any] = {}
500
569
 
501
570
  # Basic identity
502
571
  calc_data["name"] = name
503
572
  calc_data["json_dir"] = str(Path.home())
504
573
 
505
574
  # Create a deep copy of active points to avoid modifying the original list
506
- active_points = list(active_points)
575
+ if active_points is None:
576
+ active_points_list: List[AstrologicalPoint] = list(DEFAULT_ACTIVE_POINTS)
577
+ else:
578
+ active_points_list = list(active_points)
507
579
 
508
- calc_data["active_points"] = active_points
580
+ calc_data["active_points"] = active_points_list
509
581
 
510
582
  # Initialize configuration
511
583
  config = ChartConfiguration(
@@ -514,7 +586,6 @@ class AstrologicalSubjectFactory:
514
586
  houses_system_identifier=houses_system_identifier,
515
587
  perspective_type=perspective_type,
516
588
  )
517
- config.validate()
518
589
 
519
590
  # Add configuration data to calculation data
520
591
  calc_data["zodiac_type"] = config.zodiac_type
@@ -524,7 +595,8 @@ class AstrologicalSubjectFactory:
524
595
 
525
596
  # Set up geonames username if needed
526
597
  if geonames_username is None and online and (not lat or not lng or not tz_str):
527
- logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
598
+ if not suppress_geonames_warning:
599
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
528
600
  geonames_username = DEFAULT_GEONAMES_USERNAME
529
601
 
530
602
  # Initialize location data
@@ -571,20 +643,37 @@ class AstrologicalSubjectFactory:
571
643
  calc_data["is_dst"] = is_dst
572
644
 
573
645
  # Calculate time conversions
574
- cls._calculate_time_conversions(calc_data, location)
575
-
576
- # Initialize Swiss Ephemeris and calculate houses and planets
577
- cls._setup_ephemeris(calc_data, config)
578
- cls._calculate_houses(calc_data, calc_data["active_points"])
579
- cls._calculate_planets(calc_data, calc_data["active_points"])
580
- cls._calculate_day_of_week(calc_data)
646
+ AstrologicalSubjectFactory._calculate_time_conversions(calc_data, location)
647
+ # Initialize Swiss Ephemeris and calculate houses and planets with context manager
648
+ ephe_path = str(Path(__file__).parent.absolute() / "sweph")
649
+ with ephemeris_context(
650
+ ephe_path=ephe_path,
651
+ config=config,
652
+ lng=calc_data["lng"],
653
+ lat=calc_data["lat"],
654
+ alt=calc_data["altitude"],
655
+ ) as iflag:
656
+ calc_data["_iflag"] = iflag
657
+ # House system name (previously set in _setup_ephemeris)
658
+ calc_data["houses_system_name"] = swe.house_name(
659
+ config.houses_system_identifier.encode("ascii")
660
+ )
661
+ calculated_axial_cusps = AstrologicalSubjectFactory._calculate_houses(
662
+ calc_data, active_points_list
663
+ )
664
+ AstrologicalSubjectFactory._calculate_planets(
665
+ calc_data, active_points_list, calculated_axial_cusps
666
+ )
667
+ AstrologicalSubjectFactory._calculate_day_of_week(calc_data)
581
668
 
582
669
  # Calculate lunar phase (optional - only if requested and Sun and Moon are available)
583
670
  if calculate_lunar_phase and "moon" in calc_data and "sun" in calc_data:
584
671
  calc_data["lunar_phase"] = calculate_moon_phase(
585
- calc_data["moon"].abs_pos,
586
- calc_data["sun"].abs_pos
672
+ calc_data["moon"].abs_pos, # type: ignore[attr-defined,union-attr]
673
+ calc_data["sun"].abs_pos # type: ignore[attr-defined,union-attr]
587
674
  )
675
+ else:
676
+ calc_data["lunar_phase"] = None
588
677
 
589
678
  # Create and return the AstrologicalSubjectModel
590
679
  return AstrologicalSubjectModel(**calc_data)
@@ -606,8 +695,9 @@ class AstrologicalSubjectFactory:
606
695
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
607
696
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
608
697
  altitude: Optional[float] = None,
609
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
610
- calculate_lunar_phase: bool = True
698
+ active_points: Optional[List[AstrologicalPoint]] = None,
699
+ calculate_lunar_phase: bool = True,
700
+ suppress_geonames_warning: bool = False
611
701
  ) -> AstrologicalSubjectModel:
612
702
  """
613
703
  Create an astrological subject from an ISO formatted UTC timestamp.
@@ -645,8 +735,8 @@ class AstrologicalSubjectFactory:
645
735
  Defaults to 'Apparent Geocentric'.
646
736
  altitude (float, optional): Altitude in meters for topocentric calculations.
647
737
  Defaults to None (sea level).
648
- active_points (List[AstrologicalPoint], optional): Points to calculate.
649
- Defaults to DEFAULT_ACTIVE_POINTS.
738
+ active_points (Optional[List[AstrologicalPoint]], optional): Points to calculate.
739
+ If None, uses DEFAULT_ACTIVE_POINTS.
650
740
  calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
651
741
  Defaults to True.
652
742
 
@@ -689,7 +779,8 @@ class AstrologicalSubjectFactory:
689
779
  # Get location data if online mode is enabled
690
780
  if online:
691
781
  if geonames_username == DEFAULT_GEONAMES_USERNAME:
692
- logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
782
+ if not suppress_geonames_warning:
783
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
693
784
 
694
785
  geonames = FetchGeonames(
695
786
  city,
@@ -727,7 +818,8 @@ class AstrologicalSubjectFactory:
727
818
  perspective_type=perspective_type,
728
819
  altitude=altitude,
729
820
  active_points=active_points,
730
- calculate_lunar_phase=calculate_lunar_phase
821
+ calculate_lunar_phase=calculate_lunar_phase,
822
+ suppress_geonames_warning=suppress_geonames_warning
731
823
  )
732
824
 
733
825
  @classmethod
@@ -745,8 +837,9 @@ class AstrologicalSubjectFactory:
745
837
  sidereal_mode: Optional[SiderealMode] = None,
746
838
  houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
747
839
  perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
748
- active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
749
- calculate_lunar_phase: bool = True
840
+ active_points: Optional[List[AstrologicalPoint]] = None,
841
+ calculate_lunar_phase: bool = True,
842
+ suppress_geonames_warning: bool = False
750
843
  ) -> AstrologicalSubjectModel:
751
844
  """
752
845
  Create an astrological subject for the current moment in time.
@@ -780,8 +873,8 @@ class AstrologicalSubjectFactory:
780
873
  system for calculations. Defaults to 'P' (Placidus).
781
874
  perspective_type (PerspectiveType, optional): Calculation perspective.
782
875
  Defaults to 'Apparent Geocentric'.
783
- active_points (List[AstrologicalPoint], optional): Astrological points
784
- to calculate. Defaults to DEFAULT_ACTIVE_POINTS.
876
+ active_points (Optional[List[AstrologicalPoint]], optional): Astrological points
877
+ to calculate. If None, uses DEFAULT_ACTIVE_POINTS.
785
878
  calculate_lunar_phase (bool, optional): Whether to calculate lunar phase.
786
879
  Defaults to True.
787
880
 
@@ -845,11 +938,12 @@ class AstrologicalSubjectFactory:
845
938
  houses_system_identifier=houses_system_identifier,
846
939
  perspective_type=perspective_type,
847
940
  active_points=active_points,
848
- calculate_lunar_phase=calculate_lunar_phase
941
+ calculate_lunar_phase=calculate_lunar_phase,
942
+ suppress_geonames_warning=suppress_geonames_warning
849
943
  )
850
944
 
851
- @classmethod
852
- def _calculate_time_conversions(cls, data: Dict[str, Any], location: LocationData) -> None:
945
+ @staticmethod
946
+ def _calculate_time_conversions(data: Dict[str, Any], location: LocationData) -> None:
853
947
  """
854
948
  Calculate time conversions between local time, UTC, and Julian Day Number.
855
949
 
@@ -890,6 +984,11 @@ class AstrologicalSubjectFactory:
890
984
  "Ambiguous time error! The time falls during a DST transition. "
891
985
  "Please specify is_dst=True or is_dst=False to clarify."
892
986
  )
987
+ except pytz.exceptions.NonExistentTimeError:
988
+ raise KerykeionException(
989
+ "Non-existent time error! The time does not exist due to DST transition (spring forward). "
990
+ "Please specify a valid time."
991
+ )
893
992
 
894
993
  # Store formatted times
895
994
  utc_datetime = local_datetime.astimezone(pytz.utc)
@@ -899,71 +998,9 @@ class AstrologicalSubjectFactory:
899
998
  # Calculate Julian day
900
999
  data["julian_day"] = datetime_to_julian(utc_datetime)
901
1000
 
902
- @classmethod
903
- def _setup_ephemeris(cls, data: Dict[str, Any], config: ChartConfiguration) -> None:
904
- """
905
- Configure Swiss Ephemeris with appropriate calculation flags and settings.
906
-
907
- Sets up the Swiss Ephemeris library with the correct ephemeris data path,
908
- calculation flags for the specified perspective type, and sidereal mode
909
- configuration if applicable.
910
1001
 
911
- Args:
912
- data (Dict[str, Any]): Calculation data dictionary to store configuration.
913
- config (ChartConfiguration): Validated chart configuration settings.
914
-
915
- Side Effects:
916
- - Sets Swiss Ephemeris data path to bundled ephemeris files
917
- - Configures calculation flags (SWIEPH, SPEED, perspective flags)
918
- - Sets sidereal mode for sidereal zodiac calculations
919
- - Sets topocentric observer coordinates for topocentric perspective
920
- - Updates data dictionary with houses_system_name and _iflag
921
-
922
- Calculation Flags Set:
923
- - FLG_SWIEPH: Use Swiss Ephemeris data files
924
- - FLG_SPEED: Calculate planetary velocities
925
- - FLG_TRUEPOS: True geometric positions (True Geocentric)
926
- - FLG_HELCTR: Heliocentric coordinates (Heliocentric perspective)
927
- - FLG_TOPOCTR: Topocentric coordinates (Topocentric perspective)
928
- - FLG_SIDEREAL: Sidereal calculations (Sidereal zodiac)
929
-
930
- Note:
931
- The method assumes the Swiss Ephemeris data files are located in the
932
- 'sweph' subdirectory relative to this module. For topocentric calculations,
933
- observer coordinates must be set via longitude, latitude, and altitude.
934
- """
935
- # Set ephemeris path
936
- swe.set_ephe_path(str(Path(__file__).parent.absolute() / "sweph"))
937
-
938
- # Base flags
939
- iflag = swe.FLG_SWIEPH + swe.FLG_SPEED
940
-
941
- # Add perspective flags
942
- if config.perspective_type == "True Geocentric":
943
- iflag += swe.FLG_TRUEPOS
944
- elif config.perspective_type == "Heliocentric":
945
- iflag += swe.FLG_HELCTR
946
- elif config.perspective_type == "Topocentric":
947
- iflag += swe.FLG_TOPOCTR
948
- # Set topocentric coordinates
949
- swe.set_topo(data["lng"], data["lat"], data["altitude"] or 0)
950
-
951
- # Add sidereal flag if needed
952
- if config.zodiac_type == "Sidereal":
953
- iflag += swe.FLG_SIDEREAL
954
- # Set sidereal mode
955
- mode = f"SIDM_{config.sidereal_mode}"
956
- swe.set_sid_mode(getattr(swe, mode))
957
- logging.debug(f"Using sidereal mode: {mode}")
958
-
959
- # Save house system name and iflag for later use
960
- data["houses_system_name"] = swe.house_name(
961
- config.houses_system_identifier.encode('ascii')
962
- )
963
- data["_iflag"] = iflag
964
-
965
- @classmethod
966
- def _calculate_houses(cls, data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> None:
1002
+ @staticmethod
1003
+ def _calculate_houses(data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> List[AstrologicalPoint]:
967
1004
  """
968
1005
  Calculate house cusps and angular points (Ascendant, MC, etc.).
969
1006
 
@@ -1004,7 +1041,7 @@ class AstrologicalSubjectFactory:
1004
1041
  def should_calculate(point: AstrologicalPoint) -> bool:
1005
1042
  return not active_points or point in active_points
1006
1043
  # Track which axial cusps are actually calculated
1007
- calculated_axial_cusps = []
1044
+ calculated_axial_cusps: List[AstrologicalPoint] = []
1008
1045
 
1009
1046
  # Calculate houses based on zodiac type
1010
1047
  if data["zodiac_type"] == "Sidereal":
@@ -1077,9 +1114,10 @@ class AstrologicalSubjectFactory:
1077
1114
  data["imum_coeli"].retrograde = False
1078
1115
  calculated_axial_cusps.append("Imum_Coeli")
1079
1116
 
1080
- @classmethod
1117
+ return calculated_axial_cusps
1118
+
1119
+ @staticmethod
1081
1120
  def _calculate_single_planet(
1082
- cls,
1083
1121
  data: Dict[str, Any],
1084
1122
  planet_name: AstrologicalPoint,
1085
1123
  planet_id: int,
@@ -1087,7 +1125,7 @@ class AstrologicalSubjectFactory:
1087
1125
  iflag: int,
1088
1126
  houses_degree_ut: List[float],
1089
1127
  point_type: PointType,
1090
- calculated_planets: List[str],
1128
+ calculated_planets: List[AstrologicalPoint],
1091
1129
  active_points: List[AstrologicalPoint]
1092
1130
  ) -> None:
1093
1131
  """
@@ -1132,12 +1170,16 @@ class AstrologicalSubjectFactory:
1132
1170
  component being negative (element index 3).
1133
1171
  """
1134
1172
  try:
1135
- # Calculate planet position using Swiss Ephemeris
1173
+ # Calculate planet position using Swiss Ephemeris (ecliptic coordinates)
1136
1174
  planet_calc = swe.calc_ut(julian_day, planet_id, iflag)[0]
1137
1175
 
1176
+ # Get declination from equatorial coordinates
1177
+ planet_eq = swe.calc_ut(julian_day, planet_id, iflag | swe.FLG_EQUATORIAL)[0]
1178
+ declination = planet_eq[1] # Declination from equatorial coordinates
1179
+
1138
1180
  # Create Kerykeion point from degree
1139
1181
  data[planet_name.lower()] = get_kerykeion_point_from_degree(
1140
- planet_calc[0], planet_name, point_type=point_type
1182
+ planet_calc[0], planet_name, point_type=point_type, speed=planet_calc[3], declination=declination
1141
1183
  )
1142
1184
 
1143
1185
  # Calculate house position
@@ -1154,8 +1196,8 @@ class AstrologicalSubjectFactory:
1154
1196
  if planet_name in active_points:
1155
1197
  active_points.remove(planet_name)
1156
1198
 
1157
- @classmethod
1158
- def _calculate_planets(cls, data: Dict[str, Any], active_points: List[AstrologicalPoint]) -> None:
1199
+ @staticmethod
1200
+ def _calculate_planets(data: Dict[str, Any], active_points: List[AstrologicalPoint], calculated_axial_cusps: Optional[List[AstrologicalPoint]] = None) -> None:
1159
1201
  """
1160
1202
  Calculate positions for all requested celestial bodies and special points.
1161
1203
 
@@ -1248,7 +1290,7 @@ class AstrologicalSubjectFactory:
1248
1290
  houses_degree_ut = data["_houses_degree_ut"]
1249
1291
 
1250
1292
  # Track which planets are actually calculated
1251
- calculated_planets = []
1293
+ calculated_planets: List[AstrologicalPoint] = []
1252
1294
 
1253
1295
  # ==================
1254
1296
  # MAIN PLANETS
@@ -1256,75 +1298,87 @@ class AstrologicalSubjectFactory:
1256
1298
 
1257
1299
  # Calculate Sun
1258
1300
  if should_calculate("Sun"):
1259
- cls._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1301
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Sun", 0, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1260
1302
 
1261
1303
  # Calculate Moon
1262
1304
  if should_calculate("Moon"):
1263
- cls._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1305
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Moon", 1, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1264
1306
 
1265
1307
  # Calculate Mercury
1266
1308
  if should_calculate("Mercury"):
1267
- cls._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1309
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mercury", 2, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1268
1310
 
1269
1311
  # Calculate Venus
1270
1312
  if should_calculate("Venus"):
1271
- cls._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1313
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Venus", 3, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1272
1314
 
1273
1315
  # Calculate Mars
1274
1316
  if should_calculate("Mars"):
1275
- cls._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1317
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mars", 4, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1276
1318
 
1277
1319
  # Calculate Jupiter
1278
1320
  if should_calculate("Jupiter"):
1279
- cls._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1321
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Jupiter", 5, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1280
1322
 
1281
1323
  # Calculate Saturn
1282
1324
  if should_calculate("Saturn"):
1283
- cls._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1325
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Saturn", 6, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1284
1326
 
1285
1327
  # Calculate Uranus
1286
1328
  if should_calculate("Uranus"):
1287
- cls._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1329
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Uranus", 7, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1288
1330
 
1289
1331
  # Calculate Neptune
1290
1332
  if should_calculate("Neptune"):
1291
- cls._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1333
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Neptune", 8, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1292
1334
 
1293
1335
  # Calculate Pluto
1294
1336
  if should_calculate("Pluto"):
1295
- cls._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1337
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Pluto", 9, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1296
1338
 
1297
1339
  # ==================
1298
1340
  # LUNAR NODES
1299
1341
  # ==================
1300
1342
 
1301
- # Calculate Mean Lunar Node
1302
- if should_calculate("Mean_Node"):
1303
- cls._calculate_single_planet(data, "Mean_Node", 10, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1304
-
1305
- # Calculate True Lunar Node
1306
- if should_calculate("True_Node"):
1307
- cls._calculate_single_planet(data, "True_Node", 11, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1308
-
1309
- # Calculate Mean South Node (opposite to Mean North Node)
1310
- if should_calculate("Mean_South_Node") and "mean_node" in data:
1311
- mean_south_node_deg = math.fmod(data["mean_node"].abs_pos + 180, 360)
1312
- data["mean_south_node"] = get_kerykeion_point_from_degree(
1313
- mean_south_node_deg, "Mean_South_Node", point_type=point_type
1343
+ # Calculate Mean North Lunar Node
1344
+ if should_calculate("Mean_North_Lunar_Node"):
1345
+ AstrologicalSubjectFactory._calculate_single_planet(data, "Mean_North_Lunar_Node", 10, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1346
+ # Get correct declination using equatorial coordinates
1347
+ if "mean_north_lunar_node" in data:
1348
+ mean_north_lunar_node_eq = swe.calc_ut(julian_day, 10, iflag | swe.FLG_EQUATORIAL)[0]
1349
+ data["mean_north_lunar_node"].declination = mean_north_lunar_node_eq[1] # Declination from equatorial coordinates
1350
+
1351
+ # Calculate True North Lunar Node
1352
+ if should_calculate("True_North_Lunar_Node"):
1353
+ AstrologicalSubjectFactory._calculate_single_planet(data, "True_North_Lunar_Node", 11, julian_day, iflag, houses_degree_ut, point_type, calculated_planets, active_points)
1354
+ # Get correct declination using equatorial coordinates
1355
+ if "true_north_lunar_node" in data:
1356
+ true_north_lunar_node_eq = swe.calc_ut(julian_day, 11, iflag | swe.FLG_EQUATORIAL)[0]
1357
+ data["true_north_lunar_node"].declination = true_north_lunar_node_eq[1] # Declination from equatorial coordinates
1358
+
1359
+ # Calculate Mean South Lunar Node (opposite to Mean North Lunar Node)
1360
+ if should_calculate("Mean_South_Lunar_Node") and "mean_north_lunar_node" in data:
1361
+ mean_south_lunar_node_deg = math.fmod(data["mean_north_lunar_node"].abs_pos + 180, 360)
1362
+ data["mean_south_lunar_node"] = get_kerykeion_point_from_degree(
1363
+ mean_south_lunar_node_deg, "Mean_South_Lunar_Node", point_type=point_type,
1364
+ speed=-data["mean_north_lunar_node"].speed if data["mean_north_lunar_node"].speed is not None else None,
1365
+ declination=-data["mean_north_lunar_node"].declination if data["mean_north_lunar_node"].declination is not None else None
1314
1366
  )
1315
- data["mean_south_node"].house = get_planet_house(mean_south_node_deg, houses_degree_ut)
1316
- data["mean_south_node"].retrograde = data["mean_node"].retrograde
1317
- calculated_planets.append("Mean_South_Node")
1318
-
1319
- # Calculate True South Node (opposite to True North Node)
1320
- if should_calculate("True_South_Node") and "true_node" in data:
1321
- true_south_node_deg = math.fmod(data["true_node"].abs_pos + 180, 360)
1322
- data["true_south_node"] = get_kerykeion_point_from_degree(
1323
- true_south_node_deg, "True_South_Node", point_type=point_type
1367
+ data["mean_south_lunar_node"].house = get_planet_house(mean_south_lunar_node_deg, houses_degree_ut)
1368
+ data["mean_south_lunar_node"].retrograde = data["mean_north_lunar_node"].retrograde
1369
+ calculated_planets.append("Mean_South_Lunar_Node")
1370
+
1371
+ # Calculate True South Lunar Node (opposite to True North Lunar Node)
1372
+ if should_calculate("True_South_Lunar_Node") and "true_north_lunar_node" in data:
1373
+ true_south_lunar_node_deg = math.fmod(data["true_north_lunar_node"].abs_pos + 180, 360)
1374
+ data["true_south_lunar_node"] = get_kerykeion_point_from_degree(
1375
+ true_south_lunar_node_deg, "True_South_Lunar_Node", point_type=point_type,
1376
+ speed=-data["true_north_lunar_node"].speed if data["true_north_lunar_node"].speed is not None else None,
1377
+ declination=-data["true_north_lunar_node"].declination if data["true_north_lunar_node"].declination is not None else None
1324
1378
  )
1325
- data["true_south_node"].house = get_planet_house(true_south_node_deg, houses_degree_ut)
1326
- data["true_south_node"].retrograde = data["true_node"].retrograde
1327
- calculated_planets.append("True_South_Node")
1379
+ data["true_south_lunar_node"].house = get_planet_house(true_south_lunar_node_deg, houses_degree_ut)
1380
+ data["true_south_lunar_node"].retrograde = data["true_north_lunar_node"].retrograde
1381
+ calculated_planets.append("True_South_Lunar_Node")
1328
1382
 
1329
1383
  # ==================
1330
1384
  # LILITH POINTS
@@ -1332,14 +1386,14 @@ class AstrologicalSubjectFactory:
1332
1386
 
1333
1387
  # Calculate Mean Lilith (Mean Black Moon)
1334
1388
  if should_calculate("Mean_Lilith"):
1335
- cls._calculate_single_planet(
1389
+ AstrologicalSubjectFactory._calculate_single_planet(
1336
1390
  data, "Mean_Lilith", 12, julian_day, iflag, houses_degree_ut,
1337
1391
  point_type, calculated_planets, active_points
1338
1392
  )
1339
1393
 
1340
1394
  # Calculate True Lilith (Osculating Black Moon)
1341
1395
  if should_calculate("True_Lilith"):
1342
- cls._calculate_single_planet(
1396
+ AstrologicalSubjectFactory._calculate_single_planet(
1343
1397
  data, "True_Lilith", 13, julian_day, iflag, houses_degree_ut,
1344
1398
  point_type, calculated_planets, active_points
1345
1399
  )
@@ -1350,21 +1404,21 @@ class AstrologicalSubjectFactory:
1350
1404
 
1351
1405
  # Calculate Earth - useful for heliocentric charts
1352
1406
  if should_calculate("Earth"):
1353
- cls._calculate_single_planet(
1407
+ AstrologicalSubjectFactory._calculate_single_planet(
1354
1408
  data, "Earth", 14, julian_day, iflag, houses_degree_ut,
1355
1409
  point_type, calculated_planets, active_points
1356
1410
  )
1357
1411
 
1358
1412
  # Calculate Chiron
1359
1413
  if should_calculate("Chiron"):
1360
- cls._calculate_single_planet(
1414
+ AstrologicalSubjectFactory._calculate_single_planet(
1361
1415
  data, "Chiron", 15, julian_day, iflag, houses_degree_ut,
1362
1416
  point_type, calculated_planets, active_points
1363
1417
  )
1364
1418
 
1365
1419
  # Calculate Pholus
1366
1420
  if should_calculate("Pholus"):
1367
- cls._calculate_single_planet(
1421
+ AstrologicalSubjectFactory._calculate_single_planet(
1368
1422
  data, "Pholus", 16, julian_day, iflag, houses_degree_ut,
1369
1423
  point_type, calculated_planets, active_points
1370
1424
  )
@@ -1375,28 +1429,28 @@ class AstrologicalSubjectFactory:
1375
1429
 
1376
1430
  # Calculate Ceres
1377
1431
  if should_calculate("Ceres"):
1378
- cls._calculate_single_planet(
1432
+ AstrologicalSubjectFactory._calculate_single_planet(
1379
1433
  data, "Ceres", 17, julian_day, iflag, houses_degree_ut,
1380
1434
  point_type, calculated_planets, active_points
1381
1435
  )
1382
1436
 
1383
1437
  # Calculate Pallas
1384
1438
  if should_calculate("Pallas"):
1385
- cls._calculate_single_planet(
1439
+ AstrologicalSubjectFactory._calculate_single_planet(
1386
1440
  data, "Pallas", 18, julian_day, iflag, houses_degree_ut,
1387
1441
  point_type, calculated_planets, active_points
1388
1442
  )
1389
1443
 
1390
1444
  # Calculate Juno
1391
1445
  if should_calculate("Juno"):
1392
- cls._calculate_single_planet(
1446
+ AstrologicalSubjectFactory._calculate_single_planet(
1393
1447
  data, "Juno", 19, julian_day, iflag, houses_degree_ut,
1394
1448
  point_type, calculated_planets, active_points
1395
1449
  )
1396
1450
 
1397
1451
  # Calculate Vesta
1398
1452
  if should_calculate("Vesta"):
1399
- cls._calculate_single_planet(
1453
+ AstrologicalSubjectFactory._calculate_single_planet(
1400
1454
  data, "Vesta", 20, julian_day, iflag, houses_degree_ut,
1401
1455
  point_type, calculated_planets, active_points
1402
1456
  )
@@ -1409,7 +1463,7 @@ class AstrologicalSubjectFactory:
1409
1463
  if should_calculate("Eris"):
1410
1464
  try:
1411
1465
  eris_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136199, iflag)[0]
1412
- data["eris"] = get_kerykeion_point_from_degree(eris_calc[0], "Eris", point_type=point_type)
1466
+ data["eris"] = get_kerykeion_point_from_degree(eris_calc[0], "Eris", point_type=point_type, speed=eris_calc[3], declination=eris_calc[1])
1413
1467
  data["eris"].house = get_planet_house(eris_calc[0], houses_degree_ut)
1414
1468
  data["eris"].retrograde = eris_calc[3] < 0
1415
1469
  calculated_planets.append("Eris")
@@ -1421,7 +1475,7 @@ class AstrologicalSubjectFactory:
1421
1475
  if should_calculate("Sedna"):
1422
1476
  try:
1423
1477
  sedna_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90377, iflag)[0]
1424
- data["sedna"] = get_kerykeion_point_from_degree(sedna_calc[0], "Sedna", point_type=point_type)
1478
+ data["sedna"] = get_kerykeion_point_from_degree(sedna_calc[0], "Sedna", point_type=point_type, speed=sedna_calc[3], declination=sedna_calc[1])
1425
1479
  data["sedna"].house = get_planet_house(sedna_calc[0], houses_degree_ut)
1426
1480
  data["sedna"].retrograde = sedna_calc[3] < 0
1427
1481
  calculated_planets.append("Sedna")
@@ -1433,7 +1487,7 @@ class AstrologicalSubjectFactory:
1433
1487
  if should_calculate("Haumea"):
1434
1488
  try:
1435
1489
  haumea_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136108, iflag)[0]
1436
- data["haumea"] = get_kerykeion_point_from_degree(haumea_calc[0], "Haumea", point_type=point_type)
1490
+ data["haumea"] = get_kerykeion_point_from_degree(haumea_calc[0], "Haumea", point_type=point_type, speed=haumea_calc[3], declination=haumea_calc[1])
1437
1491
  data["haumea"].house = get_planet_house(haumea_calc[0], houses_degree_ut)
1438
1492
  data["haumea"].retrograde = haumea_calc[3] < 0
1439
1493
  calculated_planets.append("Haumea")
@@ -1445,7 +1499,7 @@ class AstrologicalSubjectFactory:
1445
1499
  if should_calculate("Makemake"):
1446
1500
  try:
1447
1501
  makemake_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 136472, iflag)[0]
1448
- data["makemake"] = get_kerykeion_point_from_degree(makemake_calc[0], "Makemake", point_type=point_type)
1502
+ data["makemake"] = get_kerykeion_point_from_degree(makemake_calc[0], "Makemake", point_type=point_type, speed=makemake_calc[3], declination=makemake_calc[1])
1449
1503
  data["makemake"].house = get_planet_house(makemake_calc[0], houses_degree_ut)
1450
1504
  data["makemake"].retrograde = makemake_calc[3] < 0
1451
1505
  calculated_planets.append("Makemake")
@@ -1457,7 +1511,7 @@ class AstrologicalSubjectFactory:
1457
1511
  if should_calculate("Ixion"):
1458
1512
  try:
1459
1513
  ixion_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 28978, iflag)[0]
1460
- data["ixion"] = get_kerykeion_point_from_degree(ixion_calc[0], "Ixion", point_type=point_type)
1514
+ data["ixion"] = get_kerykeion_point_from_degree(ixion_calc[0], "Ixion", point_type=point_type, speed=ixion_calc[3], declination=ixion_calc[1])
1461
1515
  data["ixion"].house = get_planet_house(ixion_calc[0], houses_degree_ut)
1462
1516
  data["ixion"].retrograde = ixion_calc[3] < 0
1463
1517
  calculated_planets.append("Ixion")
@@ -1469,7 +1523,7 @@ class AstrologicalSubjectFactory:
1469
1523
  if should_calculate("Orcus"):
1470
1524
  try:
1471
1525
  orcus_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 90482, iflag)[0]
1472
- data["orcus"] = get_kerykeion_point_from_degree(orcus_calc[0], "Orcus", point_type=point_type)
1526
+ data["orcus"] = get_kerykeion_point_from_degree(orcus_calc[0], "Orcus", point_type=point_type, speed=orcus_calc[3], declination=orcus_calc[1])
1473
1527
  data["orcus"].house = get_planet_house(orcus_calc[0], houses_degree_ut)
1474
1528
  data["orcus"].retrograde = orcus_calc[3] < 0
1475
1529
  calculated_planets.append("Orcus")
@@ -1481,7 +1535,7 @@ class AstrologicalSubjectFactory:
1481
1535
  if should_calculate("Quaoar"):
1482
1536
  try:
1483
1537
  quaoar_calc = swe.calc_ut(julian_day, swe.AST_OFFSET + 50000, iflag)[0]
1484
- data["quaoar"] = get_kerykeion_point_from_degree(quaoar_calc[0], "Quaoar", point_type=point_type)
1538
+ data["quaoar"] = get_kerykeion_point_from_degree(quaoar_calc[0], "Quaoar", point_type=point_type, speed=quaoar_calc[3], declination=quaoar_calc[1])
1485
1539
  data["quaoar"].house = get_planet_house(quaoar_calc[0], houses_degree_ut)
1486
1540
  data["quaoar"].retrograde = quaoar_calc[3] < 0
1487
1541
  calculated_planets.append("Quaoar")
@@ -1497,9 +1551,11 @@ class AstrologicalSubjectFactory:
1497
1551
  if should_calculate("Regulus"):
1498
1552
  try:
1499
1553
  star_name = "Regulus"
1500
- swe.fixstar_ut(star_name, julian_day, iflag)
1501
- regulus_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
1502
- data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type)
1554
+ pos = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1555
+ regulus_deg = pos[0]
1556
+ regulus_speed = pos[3] if len(pos) > 3 else 0.0 # Fixed stars have very slow speed
1557
+ regulus_dec = pos[1] if len(pos) > 1 else None # Declination
1558
+ data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type, speed=regulus_speed, declination=regulus_dec)
1503
1559
  data["regulus"].house = get_planet_house(regulus_deg, houses_degree_ut)
1504
1560
  data["regulus"].retrograde = False # Fixed stars are never retrograde
1505
1561
  calculated_planets.append("Regulus")
@@ -1511,9 +1567,11 @@ class AstrologicalSubjectFactory:
1511
1567
  if should_calculate("Spica"):
1512
1568
  try:
1513
1569
  star_name = "Spica"
1514
- swe.fixstar_ut(star_name, julian_day, iflag)
1515
- spica_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
1516
- data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type)
1570
+ pos = swe.fixstar_ut(star_name, julian_day, iflag)[0]
1571
+ spica_deg = pos[0]
1572
+ spica_speed = pos[3] if len(pos) > 3 else 0.0 # Fixed stars have very slow speed
1573
+ spica_dec = pos[1] if len(pos) > 1 else None # Declination
1574
+ data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type, speed=spica_speed, declination=spica_dec)
1517
1575
  data["spica"].house = get_planet_house(spica_deg, houses_degree_ut)
1518
1576
  data["spica"].retrograde = False # Fixed stars are never retrograde
1519
1577
  calculated_planets.append("Spica")
@@ -1528,21 +1586,41 @@ class AstrologicalSubjectFactory:
1528
1586
  # Calculate Pars Fortunae (Part of Fortune)
1529
1587
  if should_calculate("Pars_Fortunae"):
1530
1588
  # Auto-activate required points with notification
1531
- required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1532
- missing_points = [point for point in required_points if point not in active_points]
1589
+ pars_fortunae_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1590
+ missing_points = [point for point in pars_fortunae_required_points if point not in active_points]
1533
1591
  if missing_points:
1534
1592
  logging.info(f"Automatically adding required points for Pars_Fortunae: {missing_points}")
1535
1593
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1536
1594
  # Recalculate the missing points
1537
1595
  for point in missing_points:
1538
- if point == "Sun" and point not in data:
1596
+ if point == "Ascendant" and "ascendant" not in data:
1597
+ # Calculate Ascendant from houses data if needed
1598
+ if data["zodiac_type"] == "Sidereal":
1599
+ cusps, ascmc = swe.houses_ex(
1600
+ tjdut=data["julian_day"],
1601
+ lat=data["lat"],
1602
+ lon=data["lng"],
1603
+ hsys=str.encode(data["houses_system_identifier"]),
1604
+ flags=swe.FLG_SIDEREAL
1605
+ )
1606
+ else:
1607
+ cusps, ascmc = swe.houses(
1608
+ tjdut=data["julian_day"],
1609
+ lat=data["lat"],
1610
+ lon=data["lng"],
1611
+ hsys=str.encode(data["houses_system_identifier"])
1612
+ )
1613
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1614
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1615
+ data["ascendant"].retrograde = False
1616
+ elif point == "Sun" and "sun" not in data:
1539
1617
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1540
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1618
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1541
1619
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1542
1620
  data["sun"].retrograde = sun_calc[3] < 0
1543
- elif point == "Moon" and point not in data:
1621
+ elif point == "Moon" and "moon" not in data:
1544
1622
  moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1545
- data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type)
1623
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1546
1624
  data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1547
1625
  data["moon"].retrograde = moon_calc[3] < 0
1548
1626
 
@@ -1569,21 +1647,41 @@ class AstrologicalSubjectFactory:
1569
1647
  # Calculate Pars Spiritus (Part of Spirit)
1570
1648
  if should_calculate("Pars_Spiritus"):
1571
1649
  # Auto-activate required points with notification
1572
- required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1573
- missing_points = [point for point in required_points if point not in active_points]
1650
+ pars_spiritus_required_points: List[AstrologicalPoint] = ["Ascendant", "Sun", "Moon"]
1651
+ missing_points = [point for point in pars_spiritus_required_points if point not in active_points]
1574
1652
  if missing_points:
1575
1653
  logging.info(f"Automatically adding required points for Pars_Spiritus: {missing_points}")
1576
1654
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1577
1655
  # Recalculate the missing points
1578
1656
  for point in missing_points:
1579
- if point == "Sun" and point not in data:
1657
+ if point == "Ascendant" and "ascendant" not in data:
1658
+ # Calculate Ascendant from houses data if needed
1659
+ if data["zodiac_type"] == "Sidereal":
1660
+ cusps, ascmc = swe.houses_ex(
1661
+ tjdut=data["julian_day"],
1662
+ lat=data["lat"],
1663
+ lon=data["lng"],
1664
+ hsys=str.encode(data["houses_system_identifier"]),
1665
+ flags=swe.FLG_SIDEREAL
1666
+ )
1667
+ else:
1668
+ cusps, ascmc = swe.houses(
1669
+ tjdut=data["julian_day"],
1670
+ lat=data["lat"],
1671
+ lon=data["lng"],
1672
+ hsys=str.encode(data["houses_system_identifier"])
1673
+ )
1674
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1675
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1676
+ data["ascendant"].retrograde = False
1677
+ elif point == "Sun" and "sun" not in data:
1580
1678
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1581
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1679
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1582
1680
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1583
1681
  data["sun"].retrograde = sun_calc[3] < 0
1584
- elif point == "Moon" and point not in data:
1682
+ elif point == "Moon" and "moon" not in data:
1585
1683
  moon_calc = swe.calc_ut(julian_day, 1, iflag)[0]
1586
- data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type)
1684
+ data["moon"] = get_kerykeion_point_from_degree(moon_calc[0], "Moon", point_type=point_type, speed=moon_calc[3], declination=moon_calc[1])
1587
1685
  data["moon"].house = get_planet_house(moon_calc[0], houses_degree_ut)
1588
1686
  data["moon"].retrograde = moon_calc[3] < 0
1589
1687
 
@@ -1609,21 +1707,41 @@ class AstrologicalSubjectFactory:
1609
1707
  # Calculate Pars Amoris (Part of Eros/Love)
1610
1708
  if should_calculate("Pars_Amoris"):
1611
1709
  # Auto-activate required points with notification
1612
- required_points: List[AstrologicalPoint] = ["Ascendant", "Venus", "Sun"]
1613
- missing_points = [point for point in required_points if point not in active_points]
1710
+ pars_amoris_required_points: List[AstrologicalPoint] = ["Ascendant", "Venus", "Sun"]
1711
+ missing_points = [point for point in pars_amoris_required_points if point not in active_points]
1614
1712
  if missing_points:
1615
1713
  logging.info(f"Automatically adding required points for Pars_Amoris: {missing_points}")
1616
1714
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1617
1715
  # Recalculate the missing points
1618
1716
  for point in missing_points:
1619
- if point == "Sun" and point not in data:
1717
+ if point == "Ascendant" and "ascendant" not in data:
1718
+ # Calculate Ascendant from houses data if needed
1719
+ if data["zodiac_type"] == "Sidereal":
1720
+ cusps, ascmc = swe.houses_ex(
1721
+ tjdut=data["julian_day"],
1722
+ lat=data["lat"],
1723
+ lon=data["lng"],
1724
+ hsys=str.encode(data["houses_system_identifier"]),
1725
+ flags=swe.FLG_SIDEREAL
1726
+ )
1727
+ else:
1728
+ cusps, ascmc = swe.houses(
1729
+ tjdut=data["julian_day"],
1730
+ lat=data["lat"],
1731
+ lon=data["lng"],
1732
+ hsys=str.encode(data["houses_system_identifier"])
1733
+ )
1734
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1735
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1736
+ data["ascendant"].retrograde = False
1737
+ elif point == "Sun" and "sun" not in data:
1620
1738
  sun_calc = swe.calc_ut(julian_day, 0, iflag)[0]
1621
- data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type)
1739
+ data["sun"] = get_kerykeion_point_from_degree(sun_calc[0], "Sun", point_type=point_type, speed=sun_calc[3], declination=sun_calc[1])
1622
1740
  data["sun"].house = get_planet_house(sun_calc[0], houses_degree_ut)
1623
1741
  data["sun"].retrograde = sun_calc[3] < 0
1624
- elif point == "Venus" and point not in data:
1742
+ elif point == "Venus" and "venus" not in data:
1625
1743
  venus_calc = swe.calc_ut(julian_day, 3, iflag)[0]
1626
- data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type)
1744
+ data["venus"] = get_kerykeion_point_from_degree(venus_calc[0], "Venus", point_type=point_type, speed=venus_calc[3], declination=venus_calc[1])
1627
1745
  data["venus"].house = get_planet_house(venus_calc[0], houses_degree_ut)
1628
1746
  data["venus"].retrograde = venus_calc[3] < 0
1629
1747
 
@@ -1640,21 +1758,41 @@ class AstrologicalSubjectFactory:
1640
1758
  # Calculate Pars Fidei (Part of Faith)
1641
1759
  if should_calculate("Pars_Fidei"):
1642
1760
  # Auto-activate required points with notification
1643
- required_points: List[AstrologicalPoint] = ["Ascendant", "Jupiter", "Saturn"]
1644
- missing_points = [point for point in required_points if point not in active_points]
1761
+ pars_fidei_required_points: List[AstrologicalPoint] = ["Ascendant", "Jupiter", "Saturn"]
1762
+ missing_points = [point for point in pars_fidei_required_points if point not in active_points]
1645
1763
  if missing_points:
1646
1764
  logging.info(f"Automatically adding required points for Pars_Fidei: {missing_points}")
1647
1765
  active_points.extend(cast(List[AstrologicalPoint], missing_points))
1648
1766
  # Recalculate the missing points
1649
1767
  for point in missing_points:
1650
- if point == "Jupiter" and point not in data:
1768
+ if point == "Ascendant" and "ascendant" not in data:
1769
+ # Calculate Ascendant from houses data if needed
1770
+ if data["zodiac_type"] == "Sidereal":
1771
+ cusps, ascmc = swe.houses_ex(
1772
+ tjdut=data["julian_day"],
1773
+ lat=data["lat"],
1774
+ lon=data["lng"],
1775
+ hsys=str.encode(data["houses_system_identifier"]),
1776
+ flags=swe.FLG_SIDEREAL
1777
+ )
1778
+ else:
1779
+ cusps, ascmc = swe.houses(
1780
+ tjdut=data["julian_day"],
1781
+ lat=data["lat"],
1782
+ lon=data["lng"],
1783
+ hsys=str.encode(data["houses_system_identifier"])
1784
+ )
1785
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
1786
+ data["ascendant"].house = get_planet_house(ascmc[0], houses_degree_ut)
1787
+ data["ascendant"].retrograde = False
1788
+ elif point == "Jupiter" and "jupiter" not in data:
1651
1789
  jupiter_calc = swe.calc_ut(julian_day, 5, iflag)[0]
1652
- data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type)
1790
+ data["jupiter"] = get_kerykeion_point_from_degree(jupiter_calc[0], "Jupiter", point_type=point_type, speed=jupiter_calc[3], declination=jupiter_calc[1])
1653
1791
  data["jupiter"].house = get_planet_house(jupiter_calc[0], houses_degree_ut)
1654
1792
  data["jupiter"].retrograde = jupiter_calc[3] < 0
1655
- elif point == "Saturn" and point not in data:
1793
+ elif point == "Saturn" and "saturn" not in data:
1656
1794
  saturn_calc = swe.calc_ut(julian_day, 6, iflag)[0]
1657
- data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type)
1795
+ data["saturn"] = get_kerykeion_point_from_degree(saturn_calc[0], "Saturn", point_type=point_type, speed=saturn_calc[3], declination=saturn_calc[1])
1658
1796
  data["saturn"].house = get_planet_house(saturn_calc[0], houses_degree_ut)
1659
1797
  data["saturn"].retrograde = saturn_calc[3] < 0
1660
1798
 
@@ -1668,8 +1806,8 @@ class AstrologicalSubjectFactory:
1668
1806
  data["pars_fidei"].retrograde = False
1669
1807
  calculated_planets.append("Pars_Fidei")
1670
1808
 
1671
- # Calculate Vertex (a sort of auxiliary Descendant)
1672
- if should_calculate("Vertex"):
1809
+ # Calculate Vertex and/or Anti-Vertex
1810
+ if should_calculate("Vertex") or should_calculate("Anti_Vertex"):
1673
1811
  try:
1674
1812
  # Vertex is at ascmc[3] in Swiss Ephemeris
1675
1813
  if data["zodiac_type"] == "Sidereal":
@@ -1689,58 +1827,71 @@ class AstrologicalSubjectFactory:
1689
1827
  )
1690
1828
 
1691
1829
  vertex_deg = ascmc[3]
1692
- data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1693
- data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1694
- data["vertex"].retrograde = False
1695
- calculated_planets.append("Vertex")
1696
-
1697
- # Calculate Anti-Vertex (opposite to Vertex)
1698
- anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1699
- data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1700
- data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1701
- data["anti_vertex"].retrograde = False
1702
- calculated_planets.append("Anti_Vertex")
1830
+
1831
+ # Calculate Vertex if requested
1832
+ if should_calculate("Vertex"):
1833
+ data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1834
+ data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1835
+ data["vertex"].retrograde = False
1836
+ calculated_planets.append("Vertex")
1837
+
1838
+ # Calculate Anti-Vertex if requested
1839
+ if should_calculate("Anti_Vertex"):
1840
+ anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1841
+ data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1842
+ data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1843
+ data["anti_vertex"].retrograde = False
1844
+ calculated_planets.append("Anti_Vertex")
1845
+
1703
1846
  except Exception as e:
1704
- logging.warning("Could not calculate Vertex position, error: %s", e)
1705
- active_points.remove("Vertex")
1847
+ logging.warning("Could not calculate Vertex/Anti-Vertex position, error: %s", e)
1848
+ if "Vertex" in active_points:
1849
+ active_points.remove("Vertex")
1850
+ if "Anti_Vertex" in active_points:
1851
+ active_points.remove("Anti_Vertex")
1706
1852
 
1707
1853
  # Store only the planets that were actually calculated
1708
- data["active_points"] = calculated_planets
1854
+ all_calculated_points = calculated_planets.copy()
1855
+ if calculated_axial_cusps:
1856
+ all_calculated_points.extend(calculated_axial_cusps)
1857
+ data["active_points"] = all_calculated_points
1709
1858
 
1710
- @classmethod
1711
- def _calculate_day_of_week(cls, data: Dict[str, Any]) -> None:
1859
+ @staticmethod
1860
+ def _calculate_day_of_week(data: Dict[str, Any]) -> None:
1712
1861
  """
1713
1862
  Calculate the day of the week for the given astronomical event.
1714
1863
 
1715
- Determines the day of the week corresponding to the Julian Day Number
1716
- of the astrological event using Swiss Ephemeris calendar functions.
1864
+ Determines the day of the week corresponding to the local datetime
1865
+ using the standard library for consistency.
1717
1866
 
1718
1867
  Args:
1719
- data (Dict[str, Any]): Calculation data dictionary containing julian_day.
1720
- Updated with the calculated day_of_week string.
1868
+ data (Dict[str, Any]): Calculation data dictionary containing
1869
+ iso_formatted_local_datetime. Updated with the calculated day_of_week string.
1721
1870
 
1722
1871
  Side Effects:
1723
1872
  Updates data dictionary with:
1724
1873
  - day_of_week: Human-readable day name (e.g., "Monday", "Tuesday")
1725
-
1726
- Note:
1727
- The Swiss Ephemeris day_of_week function returns an integer where
1728
- 0=Monday, 1=Tuesday, ..., 6=Sunday. This is converted to readable
1729
- day names for user convenience.
1730
1874
  """
1731
- # Calculate the day of the week (0=Sunday, 1=Monday, ..., 6=Saturday)
1732
- day_of_week = swe.day_of_week(data["julian_day"])
1733
- # Map to human-readable names
1734
- days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
1735
- data["day_of_week"] = days_of_week[day_of_week]
1875
+ dt = datetime.fromisoformat(data["iso_formatted_local_datetime"])
1876
+ data["day_of_week"] = dt.strftime("%A")
1736
1877
 
1737
1878
  if __name__ == "__main__":
1879
+ from kerykeion.schemas.kr_literals import AstrologicalPoint
1880
+
1738
1881
  # Example usage
1739
- subject = AstrologicalSubjectFactory.from_current_time(name="Test Subject")
1882
+ new_active_points: List[AstrologicalPoint] = ['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_North_Lunar_Node', 'True_North_Lunar_Node', 'Mean_South_Lunar_Node', 'True_South_Lunar_Node', 'Chiron', 'Mean_Lilith', 'True_Lilith', 'Earth', 'Pholus', 'Ceres', 'Pallas', 'Juno', 'Vesta', 'Eris', 'Sedna', 'Haumea', 'Makemake', 'Ixion', 'Orcus', 'Quaoar', 'Regulus', 'Spica', 'Pars_Fortunae', 'Pars_Spiritus', 'Pars_Amoris', 'Pars_Fidei', 'Vertex', 'Anti_Vertex', 'Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']
1883
+ subject = AstrologicalSubjectFactory.from_current_time(name="Test Subject", active_points=new_active_points)
1740
1884
  print(subject.sun)
1741
1885
  print(subject.pars_amoris)
1742
1886
  print(subject.eris)
1743
1887
  print(subject.active_points)
1888
+ print(subject.pars_fidei)
1889
+ print("----")
1890
+ print(subject.anti_vertex)
1744
1891
 
1745
1892
  # Create JSON output
1746
1893
  json_string = subject.model_dump_json(exclude_none=True, indent=2)
1894
+
1895
+ # Write JSON to home
1896
+ with open(Path.home() / "kerykeion_subject_example.json", "w", encoding="utf-8") as f:
1897
+ f.write(json_string)