kerykeion 4.26.2__py3-none-any.whl → 5.0.0a2__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 (49) hide show
  1. kerykeion/__init__.py +9 -7
  2. kerykeion/aspects/aspects_utils.py +14 -8
  3. kerykeion/aspects/natal_aspects.py +26 -17
  4. kerykeion/aspects/synastry_aspects.py +32 -15
  5. kerykeion/aspects/transits_time_range.py +2 -2
  6. kerykeion/astrological_subject_factory.py +1132 -0
  7. kerykeion/charts/charts_utils.py +676 -146
  8. kerykeion/charts/draw_planets.py +9 -8
  9. kerykeion/charts/draw_planets_v2.py +639 -0
  10. kerykeion/charts/kerykeion_chart_svg.py +1334 -601
  11. kerykeion/charts/templates/chart.xml +184 -78
  12. kerykeion/charts/templates/wheel_only.xml +13 -12
  13. kerykeion/charts/themes/classic.css +91 -76
  14. kerykeion/charts/themes/dark-high-contrast.css +129 -107
  15. kerykeion/charts/themes/dark.css +130 -107
  16. kerykeion/charts/themes/light.css +130 -103
  17. kerykeion/charts/themes/strawberry.css +143 -0
  18. kerykeion/composite_subject_factory.py +26 -43
  19. kerykeion/ephemeris_data.py +6 -10
  20. kerykeion/house_comparison/__init__.py +3 -0
  21. kerykeion/house_comparison/house_comparison_factory.py +70 -0
  22. kerykeion/house_comparison/house_comparison_models.py +38 -0
  23. kerykeion/house_comparison/house_comparison_utils.py +98 -0
  24. kerykeion/kr_types/chart_types.py +13 -5
  25. kerykeion/kr_types/kr_literals.py +34 -6
  26. kerykeion/kr_types/kr_models.py +122 -160
  27. kerykeion/kr_types/settings_models.py +107 -143
  28. kerykeion/planetary_return_factory.py +299 -0
  29. kerykeion/{relationship_score/relationship_score_factory.py → relationship_score_factory.py} +10 -13
  30. kerykeion/report.py +4 -4
  31. kerykeion/settings/config_constants.py +35 -6
  32. kerykeion/settings/kerykeion_settings.py +1 -0
  33. kerykeion/settings/kr.config.json +1301 -1255
  34. kerykeion/settings/legacy/__init__.py +0 -0
  35. kerykeion/settings/legacy/legacy_celestial_points_settings.py +299 -0
  36. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +71 -0
  37. kerykeion/settings/legacy/legacy_color_settings.py +42 -0
  38. kerykeion/transits_time_range.py +13 -9
  39. kerykeion/utilities.py +228 -31
  40. {kerykeion-4.26.2.dist-info → kerykeion-5.0.0a2.dist-info}/METADATA +119 -107
  41. kerykeion-5.0.0a2.dist-info/RECORD +54 -0
  42. {kerykeion-4.26.2.dist-info → kerykeion-5.0.0a2.dist-info}/WHEEL +1 -1
  43. kerykeion/astrological_subject.py +0 -841
  44. kerykeion/relationship_score/__init__.py +0 -2
  45. kerykeion/relationship_score/relationship_score.py +0 -175
  46. kerykeion-4.26.2.dist-info/LICENSE +0 -661
  47. kerykeion-4.26.2.dist-info/RECORD +0 -46
  48. /LICENSE → /kerykeion-5.0.0a2.dist-info/LICENSE +0 -0
  49. {kerykeion-4.26.2.dist-info → kerykeion-5.0.0a2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1132 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ This is part of Kerykeion (C) 2025 Giacomo Battaglia
4
+ """
5
+
6
+ import pytz
7
+ import swisseph as swe
8
+ import logging
9
+ import warnings
10
+ import math
11
+ from datetime import datetime
12
+ 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
15
+ from dataclasses import dataclass, field
16
+ from typing import Callable
17
+
18
+
19
+ from kerykeion.fetch_geonames import FetchGeonames
20
+ from kerykeion.kr_types import (
21
+ KerykeionException,
22
+ ZodiacType,
23
+ AstrologicalSubjectModel,
24
+ LunarPhaseModel,
25
+ KerykeionPointModel,
26
+ PointType,
27
+ SiderealMode,
28
+ HousesSystemIdentifier,
29
+ PerspectiveType,
30
+ AstrologicalPoint,
31
+ Houses,
32
+ )
33
+ from kerykeion.utilities import (
34
+ get_number_from_name,
35
+ get_kerykeion_point_from_degree,
36
+ get_planet_house,
37
+ check_and_adjust_polar_latitude,
38
+ calculate_moon_phase,
39
+ datetime_to_julian,
40
+ get_house_number
41
+ )
42
+ from kerykeion.settings.config_constants import DEFAULT_ACTIVE_POINTS
43
+
44
+ # Default configuration values
45
+ DEFAULT_GEONAMES_USERNAME = "century.boy"
46
+ DEFAULT_SIDEREAL_MODE: SiderealMode = "FAGAN_BRADLEY"
47
+ DEFAULT_HOUSES_SYSTEM_IDENTIFIER: HousesSystemIdentifier = "P"
48
+ DEFAULT_ZODIAC_TYPE: ZodiacType = "Tropic"
49
+ DEFAULT_PERSPECTIVE_TYPE: PerspectiveType = "Apparent Geocentric"
50
+ DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS = 30
51
+
52
+ # Warning messages
53
+ GEONAMES_DEFAULT_USERNAME_WARNING = (
54
+ "\n********\n"
55
+ "NO GEONAMES USERNAME SET!\n"
56
+ "Using the default geonames username is not recommended, please set a custom one!\n"
57
+ "You can get one for free here:\n"
58
+ "https://www.geonames.org/login\n"
59
+ "Keep in mind that the default username is limited to 2000 requests per hour and is shared with everyone else using this library.\n"
60
+ "********"
61
+ )
62
+
63
+ NOW = datetime.now()
64
+
65
+
66
+ @dataclass
67
+ class ChartConfiguration:
68
+ """Configuration settings for astrological chart calculations"""
69
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE
70
+ sidereal_mode: Optional[SiderealMode] = None
71
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER
72
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE
73
+
74
+ def validate(self) -> None:
75
+ """Validate configuration settings"""
76
+ # 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
+ )
81
+
82
+ # 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!")
85
+
86
+ if self.zodiac_type == "Sidereal":
87
+ if not self.sidereal_mode:
88
+ self.sidereal_mode = DEFAULT_SIDEREAL_MODE
89
+ logging.info("No sidereal mode set, using default FAGAN_BRADLEY")
90
+ elif self.sidereal_mode not in get_args(SiderealMode):
91
+ raise KerykeionException(
92
+ f"'{self.sidereal_mode}' is not a valid sidereal mode! Available modes are: {get_args(SiderealMode)}"
93
+ )
94
+
95
+ # Validate houses system
96
+ if self.houses_system_identifier not in get_args(HousesSystemIdentifier):
97
+ raise KerykeionException(
98
+ f"'{self.houses_system_identifier}' is not a valid house system! Available systems are: {get_args(HousesSystemIdentifier)}"
99
+ )
100
+
101
+ # Validate perspective type
102
+ if self.perspective_type not in get_args(PerspectiveType):
103
+ raise KerykeionException(
104
+ f"'{self.perspective_type}' is not a valid chart perspective! Available perspectives are: {get_args(PerspectiveType)}"
105
+ )
106
+
107
+
108
+ @dataclass
109
+ class LocationData:
110
+ """Information about a geographical location"""
111
+ city: str = "Greenwich"
112
+ nation: str = "GB"
113
+ lat: float = 51.5074
114
+ lng: float = 0.0
115
+ tz_str: str = "Etc/GMT"
116
+ altitude: Optional[float] = None
117
+
118
+ # Storage for city data fetched from geonames
119
+ city_data: Dict[str, str] = field(default_factory=dict)
120
+
121
+ def fetch_from_geonames(self, username: str, cache_expire_after_days: int) -> None:
122
+ """Fetch location data from geonames API"""
123
+ logging.info(f"Fetching timezone/coordinates for {self.city}, {self.nation} from geonames")
124
+
125
+ geonames = FetchGeonames(
126
+ self.city,
127
+ self.nation,
128
+ username=username,
129
+ cache_expire_after_days=cache_expire_after_days
130
+ )
131
+
132
+ self.city_data = geonames.get_serialized_data()
133
+
134
+ # Validate data
135
+ required_fields = ["countryCode", "timezonestr", "lat", "lng"]
136
+ missing_fields = [field for field in required_fields if field not in self.city_data]
137
+
138
+ if missing_fields:
139
+ raise KerykeionException(
140
+ f"Missing data from geonames: {', '.join(missing_fields)}. "
141
+ "Check your connection or try a different location."
142
+ )
143
+
144
+ # Update location data
145
+ self.nation = self.city_data["countryCode"]
146
+ self.lng = float(self.city_data["lng"])
147
+ self.lat = float(self.city_data["lat"])
148
+ self.tz_str = self.city_data["timezonestr"]
149
+
150
+ def prepare_for_calculation(self) -> None:
151
+ """Prepare location data for astrological calculations"""
152
+ # Adjust latitude for polar regions
153
+ self.lat = check_and_adjust_polar_latitude(self.lat)
154
+
155
+
156
+ class AstrologicalSubjectFactory:
157
+ """
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.
163
+ """
164
+
165
+ @classmethod
166
+ def from_birth_data(
167
+ cls,
168
+ 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,
174
+ city: Optional[str] = None,
175
+ nation: Optional[str] = None,
176
+ lng: Optional[float] = None,
177
+ lat: Optional[float] = None,
178
+ tz_str: Optional[str] = None,
179
+ geonames_username: Optional[str] = None,
180
+ online: bool = True,
181
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
182
+ sidereal_mode: Optional[SiderealMode] = None,
183
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
184
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
185
+ cache_expire_after_days: int = DEFAULT_GEONAMES_CACHE_EXPIRE_AFTER_DAYS,
186
+ is_dst: Optional[bool] = None,
187
+ altitude: Optional[float] = None,
188
+ active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS,
189
+ *,
190
+ seconds: int = 0,
191
+
192
+ ) -> AstrologicalSubjectModel:
193
+ """
194
+ Create an astrological subject from standard birth/event details.
195
+
196
+ Args:
197
+ name: Subject name
198
+ year, month, day, hour, minute, seconds: Time components
199
+ city: Location name
200
+ nation: Country code
201
+ lng, lat: Coordinates (optional if online=True)
202
+ tz_str: Timezone string (optional if online=True)
203
+ geonames_username: Username for geonames API
204
+ online: Whether to fetch location data online
205
+ zodiac_type: Type of zodiac (Tropical or Sidereal)
206
+ sidereal_mode: Mode for sidereal calculations
207
+ houses_system_identifier: House system for calculations
208
+ perspective_type: Perspective type for calculations
209
+ cache_expire_after_days: Cache duration for geonames data
210
+ is_dst: Daylight saving time flag
211
+ altitude: Location altitude for topocentric calculations
212
+ active_points: Set of points to calculate (optimization)
213
+
214
+ Returns:
215
+ An AstrologicalSubjectModel with calculated data
216
+ """
217
+ logging.debug("Starting Kerykeion calculation")
218
+
219
+ if "Sun" not in active_points:
220
+ logging.info("Automatically adding 'Sun' to active points")
221
+ active_points.append("Sun")
222
+
223
+ if "Moon" not in active_points:
224
+ logging.info("Automatically adding 'Moon' to active points")
225
+ active_points.append("Moon")
226
+
227
+ if "Ascendant" not in active_points:
228
+ logging.info("Automatically adding 'Ascendant' to active points")
229
+ active_points.append("Ascendant")
230
+
231
+ if "Medium_Coeli" not in active_points:
232
+ logging.info("Automatically adding 'Medium_Coeli' to active points")
233
+ active_points.append("Medium_Coeli")
234
+
235
+ if "Mercury" not in active_points:
236
+ logging.info("Automatically adding 'Mercury' to active points")
237
+ active_points.append("Mercury")
238
+
239
+ if "Venus" not in active_points:
240
+ logging.info("Automatically adding 'Venus' to active points")
241
+ active_points.append("Venus")
242
+
243
+ if "Mars" not in active_points:
244
+ logging.info("Automatically adding 'Mars' to active points")
245
+ active_points.append("Mars")
246
+
247
+ if "Jupiter" not in active_points:
248
+ logging.info("Automatically adding 'Jupiter' to active points")
249
+ active_points.append("Jupiter")
250
+
251
+ if "Saturn" not in active_points:
252
+ logging.info("Automatically adding 'Saturn' to active points")
253
+ active_points.append("Saturn")
254
+
255
+ # Create a calculation data container
256
+ calc_data = {}
257
+
258
+ # Basic identity
259
+ calc_data["name"] = name
260
+ calc_data["json_dir"] = str(Path.home())
261
+
262
+ # Initialize configuration
263
+ config = ChartConfiguration(
264
+ zodiac_type=zodiac_type,
265
+ sidereal_mode=sidereal_mode,
266
+ houses_system_identifier=houses_system_identifier,
267
+ perspective_type=perspective_type,
268
+ )
269
+ config.validate()
270
+
271
+ # Add configuration data to calculation data
272
+ calc_data["zodiac_type"] = config.zodiac_type
273
+ calc_data["sidereal_mode"] = config.sidereal_mode
274
+ calc_data["houses_system_identifier"] = config.houses_system_identifier
275
+ calc_data["perspective_type"] = config.perspective_type
276
+
277
+ # Set up geonames username if needed
278
+ if geonames_username is None and online and (not lat or not lng or not tz_str):
279
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
280
+ geonames_username = DEFAULT_GEONAMES_USERNAME
281
+
282
+ # Initialize location data
283
+ location = LocationData(
284
+ city=city or "Greenwich",
285
+ nation=nation or "GB",
286
+ lat=lat if lat is not None else 51.5074,
287
+ lng=lng if lng is not None else 0.0,
288
+ tz_str=tz_str or "Etc/GMT",
289
+ altitude=altitude
290
+ )
291
+
292
+ # If offline mode is requested but required data is missing, raise error
293
+ if not online and (not tz_str or not lat or not lng):
294
+ raise KerykeionException(
295
+ "For offline mode, you must provide timezone (tz_str) and coordinates (lat, lng)"
296
+ )
297
+
298
+ # Fetch location data if needed
299
+ if online and (not tz_str or not lat or not lng):
300
+ location.fetch_from_geonames(
301
+ username=geonames_username or DEFAULT_GEONAMES_USERNAME,
302
+ cache_expire_after_days=cache_expire_after_days
303
+ )
304
+
305
+ # Prepare location for calculations
306
+ location.prepare_for_calculation()
307
+
308
+ # Add location data to calculation data
309
+ calc_data["city"] = location.city
310
+ calc_data["nation"] = location.nation
311
+ calc_data["lat"] = location.lat
312
+ calc_data["lng"] = location.lng
313
+ calc_data["tz_str"] = location.tz_str
314
+ calc_data["altitude"] = location.altitude
315
+
316
+ # Store calculation parameters
317
+ calc_data["year"] = year
318
+ calc_data["month"] = month
319
+ calc_data["day"] = day
320
+ calc_data["hour"] = hour
321
+ calc_data["minute"] = minute
322
+ calc_data["seconds"] = seconds
323
+ calc_data["is_dst"] = is_dst
324
+ calc_data["active_points"] = active_points
325
+
326
+ # Calculate time conversions
327
+ cls._calculate_time_conversions(calc_data, location)
328
+
329
+ # Initialize Swiss Ephemeris and calculate houses and planets
330
+ cls._setup_ephemeris(calc_data, config)
331
+ cls._calculate_houses(calc_data, active_points)
332
+ cls._calculate_planets(calc_data, active_points)
333
+ cls._calculate_day_of_week(calc_data)
334
+
335
+ # Calculate lunar phase
336
+ calc_data["lunar_phase"] = calculate_moon_phase(
337
+ calc_data["moon"].abs_pos,
338
+ calc_data["sun"].abs_pos
339
+ )
340
+
341
+ # Create and return the AstrologicalSubjectModel
342
+ return AstrologicalSubjectModel(**calc_data)
343
+
344
+ @classmethod
345
+ def from_iso_utc_time(
346
+ cls,
347
+ name: str,
348
+ iso_utc_time: str,
349
+ city: str = "Greenwich",
350
+ nation: str = "GB",
351
+ tz_str: str = "Etc/GMT",
352
+ online: bool = False,
353
+ lng: float = 0.0,
354
+ lat: float = 51.5074,
355
+ geonames_username: str = DEFAULT_GEONAMES_USERNAME,
356
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
357
+ sidereal_mode: Optional[SiderealMode] = None,
358
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
359
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
360
+ altitude: Optional[float] = None,
361
+ active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
362
+ ) -> AstrologicalSubjectModel:
363
+ """
364
+ Create an astrological subject from an ISO formatted UTC time.
365
+
366
+ Args:
367
+ name: Subject name
368
+ iso_utc_time: ISO formatted UTC time string
369
+ city: Location name
370
+ nation: Country code
371
+ tz_str: Timezone string
372
+ online: Whether to fetch location data online
373
+ lng, lat: Coordinates
374
+ geonames_username: Username for geonames API
375
+ zodiac_type: Type of zodiac
376
+ sidereal_mode: Mode for sidereal calculations
377
+ houses_system_identifier: House system
378
+ perspective_type: Perspective for calculations
379
+ altitude: Location altitude
380
+ active_points: Set of points to calculate
381
+
382
+ Returns:
383
+ AstrologicalSubjectModel instance
384
+ """
385
+ # Parse the ISO time
386
+ dt = datetime.fromisoformat(iso_utc_time.replace('Z', '+00:00'))
387
+
388
+ # Get location data if online mode is enabled
389
+ if online:
390
+ if geonames_username == DEFAULT_GEONAMES_USERNAME:
391
+ logging.warning(GEONAMES_DEFAULT_USERNAME_WARNING)
392
+
393
+ geonames = FetchGeonames(
394
+ city,
395
+ nation,
396
+ username=geonames_username,
397
+ )
398
+
399
+ city_data = geonames.get_serialized_data()
400
+ lng = float(city_data["lng"])
401
+ lat = float(city_data["lat"])
402
+
403
+ # Convert UTC to local time
404
+ local_time = pytz.timezone(tz_str)
405
+ local_datetime = dt.astimezone(local_time)
406
+
407
+ # Create the subject with local time
408
+ return cls.from_birth_data(
409
+ name=name,
410
+ year=local_datetime.year,
411
+ month=local_datetime.month,
412
+ day=local_datetime.day,
413
+ hour=local_datetime.hour,
414
+ minute=local_datetime.minute,
415
+ seconds=local_datetime.second,
416
+ city=city,
417
+ nation=nation,
418
+ lng=lng,
419
+ lat=lat,
420
+ tz_str=tz_str,
421
+ online=False, # Already fetched data if needed
422
+ geonames_username=geonames_username,
423
+ zodiac_type=zodiac_type,
424
+ sidereal_mode=sidereal_mode,
425
+ houses_system_identifier=houses_system_identifier,
426
+ perspective_type=perspective_type,
427
+ altitude=altitude,
428
+ active_points=active_points
429
+ )
430
+
431
+ @classmethod
432
+ def from_current_time(
433
+ cls,
434
+ name: str = "Now",
435
+ city: Optional[str] = None,
436
+ nation: Optional[str] = None,
437
+ lng: Optional[float] = None,
438
+ lat: Optional[float] = None,
439
+ tz_str: Optional[str] = None,
440
+ geonames_username: Optional[str] = None,
441
+ online: bool = True,
442
+ zodiac_type: ZodiacType = DEFAULT_ZODIAC_TYPE,
443
+ sidereal_mode: Optional[SiderealMode] = None,
444
+ houses_system_identifier: HousesSystemIdentifier = DEFAULT_HOUSES_SYSTEM_IDENTIFIER,
445
+ perspective_type: PerspectiveType = DEFAULT_PERSPECTIVE_TYPE,
446
+ active_points: List[AstrologicalPoint] = DEFAULT_ACTIVE_POINTS
447
+ ) -> AstrologicalSubjectModel:
448
+ """
449
+ Create an astrological subject for the current time.
450
+
451
+ Args:
452
+ name: Subject name
453
+ city: Location name
454
+ nation: Country code
455
+ lng, lat: Coordinates
456
+ tz_str: Timezone string
457
+ geonames_username: Username for geonames API
458
+ online: Whether to fetch location data online
459
+ zodiac_type: Type of zodiac
460
+ sidereal_mode: Mode for sidereal calculations
461
+ houses_system_identifier: House system
462
+ perspective_type: Perspective for calculations
463
+ active_points: Set of points to calculate
464
+
465
+ Returns:
466
+ AstrologicalSubjectModel for current time
467
+ """
468
+ now = datetime.now()
469
+
470
+ return cls.from_birth_data(
471
+ name=name,
472
+ year=now.year,
473
+ month=now.month,
474
+ day=now.day,
475
+ hour=now.hour,
476
+ minute=now.minute,
477
+ seconds=now.second,
478
+ city=city,
479
+ nation=nation,
480
+ lng=lng,
481
+ lat=lat,
482
+ tz_str=tz_str,
483
+ geonames_username=geonames_username,
484
+ online=online,
485
+ zodiac_type=zodiac_type,
486
+ sidereal_mode=sidereal_mode,
487
+ houses_system_identifier=houses_system_identifier,
488
+ perspective_type=perspective_type,
489
+ active_points=active_points
490
+ )
491
+
492
+ @classmethod
493
+ def _calculate_time_conversions(cls, data: Dict[str, Any], location: LocationData) -> None:
494
+ """Calculate time conversions between local time, UTC and Julian day"""
495
+ # Convert local time to UTC
496
+ local_timezone = pytz.timezone(location.tz_str)
497
+ naive_datetime = datetime(
498
+ data["year"], data["month"], data["day"],
499
+ data["hour"], data["minute"], data["seconds"]
500
+ )
501
+
502
+ try:
503
+ local_datetime = local_timezone.localize(naive_datetime, is_dst=data.get("is_dst"))
504
+ except pytz.exceptions.AmbiguousTimeError:
505
+ raise KerykeionException(
506
+ "Ambiguous time error! The time falls during a DST transition. "
507
+ "Please specify is_dst=True or is_dst=False to clarify."
508
+ )
509
+
510
+ # Store formatted times
511
+ utc_datetime = local_datetime.astimezone(pytz.utc)
512
+ data["iso_formatted_utc_datetime"] = utc_datetime.isoformat()
513
+ data["iso_formatted_local_datetime"] = local_datetime.isoformat()
514
+
515
+ # Calculate Julian day
516
+ data["julian_day"] = datetime_to_julian(utc_datetime)
517
+
518
+ @classmethod
519
+ def _setup_ephemeris(cls, data: Dict[str, Any], config: ChartConfiguration) -> None:
520
+ """Set up Swiss Ephemeris with appropriate flags"""
521
+ # Set ephemeris path
522
+ swe.set_ephe_path(str(Path(__file__).parent.absolute() / "sweph"))
523
+
524
+ # Base flags
525
+ iflag = swe.FLG_SWIEPH + swe.FLG_SPEED
526
+
527
+ # Add perspective flags
528
+ if config.perspective_type == "True Geocentric":
529
+ iflag += swe.FLG_TRUEPOS
530
+ elif config.perspective_type == "Heliocentric":
531
+ iflag += swe.FLG_HELCTR
532
+ elif config.perspective_type == "Topocentric":
533
+ iflag += swe.FLG_TOPOCTR
534
+ # Set topocentric coordinates
535
+ swe.set_topo(data["lng"], data["lat"], data["altitude"] or 0)
536
+
537
+ # Add sidereal flag if needed
538
+ if config.zodiac_type == "Sidereal":
539
+ iflag += swe.FLG_SIDEREAL
540
+ # Set sidereal mode
541
+ mode = f"SIDM_{config.sidereal_mode}"
542
+ swe.set_sid_mode(getattr(swe, mode))
543
+ logging.debug(f"Using sidereal mode: {mode}")
544
+
545
+ # Save house system name and iflag for later use
546
+ data["houses_system_name"] = swe.house_name(
547
+ config.houses_system_identifier.encode('ascii')
548
+ )
549
+ data["_iflag"] = iflag
550
+
551
+ @classmethod
552
+ def _calculate_houses(cls, data: Dict[str, Any], active_points: Optional[List[AstrologicalPoint]]) -> None:
553
+ """Calculate house cusps and axis points"""
554
+ # Skip calculation if point is not in active_points
555
+ should_calculate: Callable[[AstrologicalPoint], bool] = lambda point: not active_points or point in active_points
556
+ # Track which axial cusps are actually calculated
557
+ calculated_axial_cusps = []
558
+
559
+ # Calculate houses based on zodiac type
560
+ if data["zodiac_type"] == "Sidereal":
561
+ cusps, ascmc = swe.houses_ex(
562
+ tjdut=data["julian_day"],
563
+ lat=data["lat"],
564
+ lon=data["lng"],
565
+ hsys=str.encode(data["houses_system_identifier"]),
566
+ flags=swe.FLG_SIDEREAL
567
+ )
568
+ else: # Tropical zodiac
569
+ cusps, ascmc = swe.houses(
570
+ tjdut=data["julian_day"],
571
+ lat=data["lat"],
572
+ lon=data["lng"],
573
+ hsys=str.encode(data["houses_system_identifier"])
574
+ )
575
+
576
+ # Store house degrees
577
+ data["_houses_degree_ut"] = cusps
578
+
579
+ # Create house objects
580
+ point_type: PointType = "House"
581
+ data["first_house"] = get_kerykeion_point_from_degree(cusps[0], "First_House", point_type=point_type)
582
+ data["second_house"] = get_kerykeion_point_from_degree(cusps[1], "Second_House", point_type=point_type)
583
+ data["third_house"] = get_kerykeion_point_from_degree(cusps[2], "Third_House", point_type=point_type)
584
+ data["fourth_house"] = get_kerykeion_point_from_degree(cusps[3], "Fourth_House", point_type=point_type)
585
+ data["fifth_house"] = get_kerykeion_point_from_degree(cusps[4], "Fifth_House", point_type=point_type)
586
+ data["sixth_house"] = get_kerykeion_point_from_degree(cusps[5], "Sixth_House", point_type=point_type)
587
+ data["seventh_house"] = get_kerykeion_point_from_degree(cusps[6], "Seventh_House", point_type=point_type)
588
+ data["eighth_house"] = get_kerykeion_point_from_degree(cusps[7], "Eighth_House", point_type=point_type)
589
+ data["ninth_house"] = get_kerykeion_point_from_degree(cusps[8], "Ninth_House", point_type=point_type)
590
+ data["tenth_house"] = get_kerykeion_point_from_degree(cusps[9], "Tenth_House", point_type=point_type)
591
+ data["eleventh_house"] = get_kerykeion_point_from_degree(cusps[10], "Eleventh_House", point_type=point_type)
592
+ data["twelfth_house"] = get_kerykeion_point_from_degree(cusps[11], "Twelfth_House", point_type=point_type)
593
+
594
+ # Store house names
595
+ data["houses_names_list"] = list(get_args(Houses))
596
+
597
+ # Calculate axis points
598
+ point_type = "AstrologicalPoint"
599
+
600
+ # Calculate Ascendant if needed
601
+ if should_calculate("Ascendant"):
602
+ data["ascendant"] = get_kerykeion_point_from_degree(ascmc[0], "Ascendant", point_type=point_type)
603
+ data["ascendant"].house = get_planet_house(data["ascendant"].abs_pos, data["_houses_degree_ut"])
604
+ data["ascendant"].retrograde = False
605
+ calculated_axial_cusps.append("Ascendant")
606
+
607
+ # Calculate Medium Coeli if needed
608
+ if should_calculate("Medium_Coeli"):
609
+ data["medium_coeli"] = get_kerykeion_point_from_degree(ascmc[1], "Medium_Coeli", point_type=point_type)
610
+ data["medium_coeli"].house = get_planet_house(data["medium_coeli"].abs_pos, data["_houses_degree_ut"])
611
+ data["medium_coeli"].retrograde = False
612
+ calculated_axial_cusps.append("Medium_Coeli")
613
+
614
+ # Calculate Descendant if needed
615
+ if should_calculate("Descendant"):
616
+ dsc_deg = math.fmod(ascmc[0] + 180, 360)
617
+ data["descendant"] = get_kerykeion_point_from_degree(dsc_deg, "Descendant", point_type=point_type)
618
+ data["descendant"].house = get_planet_house(data["descendant"].abs_pos, data["_houses_degree_ut"])
619
+ data["descendant"].retrograde = False
620
+ calculated_axial_cusps.append("Descendant")
621
+
622
+ # Calculate Imum Coeli if needed
623
+ if should_calculate("Imum_Coeli"):
624
+ ic_deg = math.fmod(ascmc[1] + 180, 360)
625
+ data["imum_coeli"] = get_kerykeion_point_from_degree(ic_deg, "Imum_Coeli", point_type=point_type)
626
+ data["imum_coeli"].house = get_planet_house(data["imum_coeli"].abs_pos, data["_houses_degree_ut"])
627
+ data["imum_coeli"].retrograde = False
628
+ calculated_axial_cusps.append("Imum_Coeli")
629
+
630
+ @classmethod
631
+ def _calculate_planets(cls, data: Dict[str, Any], active_points: List[AstrologicalPoint]) -> None:
632
+ """Calculate planetary positions and related information"""
633
+ # Skip calculation if point is not in active_points
634
+ should_calculate: Callable[[AstrologicalPoint], bool] = lambda point: not active_points or point in active_points
635
+
636
+ point_type: PointType = "AstrologicalPoint"
637
+ julian_day = data["julian_day"]
638
+ iflag = data["_iflag"]
639
+ houses_degree_ut = data["_houses_degree_ut"]
640
+
641
+ # Track which planets are actually calculated
642
+ calculated_planets = []
643
+
644
+ # ==================
645
+ # MAIN PLANETS
646
+ # ==================
647
+
648
+ # Calculate Sun
649
+ if should_calculate("Sun"):
650
+ sun_deg = swe.calc_ut(julian_day, 0, iflag)[0][0]
651
+ data["sun"] = get_kerykeion_point_from_degree(sun_deg, "Sun", point_type=point_type)
652
+ data["sun"].house = get_planet_house(sun_deg, houses_degree_ut)
653
+ data["sun"].retrograde = swe.calc_ut(julian_day, 0, iflag)[0][3] < 0
654
+ calculated_planets.append("Sun")
655
+
656
+ # Calculate Moon
657
+ if should_calculate("Moon"):
658
+ moon_deg = swe.calc_ut(julian_day, 1, iflag)[0][0]
659
+ data["moon"] = get_kerykeion_point_from_degree(moon_deg, "Moon", point_type=point_type)
660
+ data["moon"].house = get_planet_house(moon_deg, houses_degree_ut)
661
+ data["moon"].retrograde = swe.calc_ut(julian_day, 1, iflag)[0][3] < 0
662
+ calculated_planets.append("Moon")
663
+
664
+ # Calculate Mercury
665
+ if should_calculate("Mercury"):
666
+ mercury_deg = swe.calc_ut(julian_day, 2, iflag)[0][0]
667
+ data["mercury"] = get_kerykeion_point_from_degree(mercury_deg, "Mercury", point_type=point_type)
668
+ data["mercury"].house = get_planet_house(mercury_deg, houses_degree_ut)
669
+ data["mercury"].retrograde = swe.calc_ut(julian_day, 2, iflag)[0][3] < 0
670
+ calculated_planets.append("Mercury")
671
+
672
+ # Calculate Venus
673
+ if should_calculate("Venus"):
674
+ venus_deg = swe.calc_ut(julian_day, 3, iflag)[0][0]
675
+ data["venus"] = get_kerykeion_point_from_degree(venus_deg, "Venus", point_type=point_type)
676
+ data["venus"].house = get_planet_house(venus_deg, houses_degree_ut)
677
+ data["venus"].retrograde = swe.calc_ut(julian_day, 3, iflag)[0][3] < 0
678
+ calculated_planets.append("Venus")
679
+
680
+ # Calculate Mars
681
+ if should_calculate("Mars"):
682
+ mars_deg = swe.calc_ut(julian_day, 4, iflag)[0][0]
683
+ data["mars"] = get_kerykeion_point_from_degree(mars_deg, "Mars", point_type=point_type)
684
+ data["mars"].house = get_planet_house(mars_deg, houses_degree_ut)
685
+ data["mars"].retrograde = swe.calc_ut(julian_day, 4, iflag)[0][3] < 0
686
+ calculated_planets.append("Mars")
687
+
688
+ # Calculate Jupiter
689
+ if should_calculate("Jupiter"):
690
+ jupiter_deg = swe.calc_ut(julian_day, 5, iflag)[0][0]
691
+ data["jupiter"] = get_kerykeion_point_from_degree(jupiter_deg, "Jupiter", point_type=point_type)
692
+ data["jupiter"].house = get_planet_house(jupiter_deg, houses_degree_ut)
693
+ data["jupiter"].retrograde = swe.calc_ut(julian_day, 5, iflag)[0][3] < 0
694
+ calculated_planets.append("Jupiter")
695
+
696
+ # Calculate Saturn
697
+ if should_calculate("Saturn"):
698
+ saturn_deg = swe.calc_ut(julian_day, 6, iflag)[0][0]
699
+ data["saturn"] = get_kerykeion_point_from_degree(saturn_deg, "Saturn", point_type=point_type)
700
+ data["saturn"].house = get_planet_house(saturn_deg, houses_degree_ut)
701
+ data["saturn"].retrograde = swe.calc_ut(julian_day, 6, iflag)[0][3] < 0
702
+ calculated_planets.append("Saturn")
703
+
704
+ # Calculate Uranus
705
+ if should_calculate("Uranus"):
706
+ uranus_deg = swe.calc_ut(julian_day, 7, iflag)[0][0]
707
+ data["uranus"] = get_kerykeion_point_from_degree(uranus_deg, "Uranus", point_type=point_type)
708
+ data["uranus"].house = get_planet_house(uranus_deg, houses_degree_ut)
709
+ data["uranus"].retrograde = swe.calc_ut(julian_day, 7, iflag)[0][3] < 0
710
+ calculated_planets.append("Uranus")
711
+
712
+ # Calculate Neptune
713
+ if should_calculate("Neptune"):
714
+ neptune_deg = swe.calc_ut(julian_day, 8, iflag)[0][0]
715
+ data["neptune"] = get_kerykeion_point_from_degree(neptune_deg, "Neptune", point_type=point_type)
716
+ data["neptune"].house = get_planet_house(neptune_deg, houses_degree_ut)
717
+ data["neptune"].retrograde = swe.calc_ut(julian_day, 8, iflag)[0][3] < 0
718
+ calculated_planets.append("Neptune")
719
+
720
+ # Calculate Pluto
721
+ if should_calculate("Pluto"):
722
+ pluto_deg = swe.calc_ut(julian_day, 9, iflag)[0][0]
723
+ data["pluto"] = get_kerykeion_point_from_degree(pluto_deg, "Pluto", point_type=point_type)
724
+ data["pluto"].house = get_planet_house(pluto_deg, houses_degree_ut)
725
+ data["pluto"].retrograde = swe.calc_ut(julian_day, 9, iflag)[0][3] < 0
726
+ calculated_planets.append("Pluto")
727
+
728
+ # ==================
729
+ # LUNAR NODES
730
+ # ==================
731
+
732
+ # Calculate Mean Lunar Node
733
+ if should_calculate("Mean_Node"):
734
+ mean_node_deg = swe.calc_ut(julian_day, 10, iflag)[0][0]
735
+ data["mean_node"] = get_kerykeion_point_from_degree(mean_node_deg, "Mean_Node", point_type=point_type)
736
+ data["mean_node"].house = get_planet_house(mean_node_deg, houses_degree_ut)
737
+ data["mean_node"].retrograde = swe.calc_ut(julian_day, 10, iflag)[0][3] < 0
738
+ calculated_planets.append("Mean_Node")
739
+
740
+ # Calculate True Lunar Node
741
+ if should_calculate("True_Node"):
742
+ true_node_deg = swe.calc_ut(julian_day, 11, iflag)[0][0]
743
+ data["true_node"] = get_kerykeion_point_from_degree(true_node_deg, "True_Node", point_type=point_type)
744
+ data["true_node"].house = get_planet_house(true_node_deg, houses_degree_ut)
745
+ data["true_node"].retrograde = swe.calc_ut(julian_day, 11, iflag)[0][3] < 0
746
+ calculated_planets.append("True_Node")
747
+
748
+ # Calculate Mean South Node (opposite to Mean North Node)
749
+ if should_calculate("Mean_South_Node") and "mean_node" in data:
750
+ mean_south_node_deg = math.fmod(data["mean_node"].abs_pos + 180, 360)
751
+ data["mean_south_node"] = get_kerykeion_point_from_degree(
752
+ mean_south_node_deg, "Mean_South_Node", point_type=point_type
753
+ )
754
+ data["mean_south_node"].house = get_planet_house(mean_south_node_deg, houses_degree_ut)
755
+ data["mean_south_node"].retrograde = data["mean_node"].retrograde
756
+ calculated_planets.append("Mean_South_Node")
757
+
758
+ # Calculate True South Node (opposite to True North Node)
759
+ if should_calculate("True_South_Node") and "true_node" in data:
760
+ true_south_node_deg = math.fmod(data["true_node"].abs_pos + 180, 360)
761
+ data["true_south_node"] = get_kerykeion_point_from_degree(
762
+ true_south_node_deg, "True_South_Node", point_type=point_type
763
+ )
764
+ data["true_south_node"].house = get_planet_house(true_south_node_deg, houses_degree_ut)
765
+ data["true_south_node"].retrograde = data["true_node"].retrograde
766
+ calculated_planets.append("True_South_Node")
767
+
768
+ # ==================
769
+ # LILITH POINTS
770
+ # ==================
771
+
772
+ # Calculate Mean Lilith (Mean Black Moon)
773
+ if should_calculate("Mean_Lilith"):
774
+ try:
775
+ mean_lilith_deg = swe.calc_ut(julian_day, 12, iflag)[0][0]
776
+ data["mean_lilith"] = get_kerykeion_point_from_degree(mean_lilith_deg, "Mean_Lilith", point_type=point_type)
777
+ data["mean_lilith"].house = get_planet_house(mean_lilith_deg, houses_degree_ut)
778
+ data["mean_lilith"].retrograde = swe.calc_ut(julian_day, 12, iflag)[0][3] < 0
779
+ calculated_planets.append("Mean_Lilith")
780
+ except Exception as e:
781
+ logging.error(f"Error calculating Mean Lilith: {e}")
782
+ active_points.remove("Mean_Lilith")
783
+
784
+
785
+ # Calculate True Lilith (Osculating Black Moon)
786
+ if should_calculate("True_Lilith"):
787
+ try:
788
+ true_lilith_deg = swe.calc_ut(julian_day, 13, iflag)[0][0]
789
+ data["true_lilith"] = get_kerykeion_point_from_degree(true_lilith_deg, "True_Lilith", point_type=point_type)
790
+ data["true_lilith"].house = get_planet_house(true_lilith_deg, houses_degree_ut)
791
+ data["true_lilith"].retrograde = swe.calc_ut(julian_day, 13, iflag)[0][3] < 0
792
+ calculated_planets.append("True_Lilith")
793
+ except Exception as e:
794
+ logging.error(f"Error calculating True Lilith: {e}")
795
+ active_points.remove("True_Lilith")
796
+
797
+ # ==================
798
+ # SPECIAL POINTS
799
+ # ==================
800
+
801
+ # Calculate Earth - useful for heliocentric charts
802
+ if should_calculate("Earth"):
803
+ try:
804
+ earth_deg = swe.calc_ut(julian_day, 14, iflag)[0][0]
805
+ data["earth"] = get_kerykeion_point_from_degree(earth_deg, "Earth", point_type=point_type)
806
+ data["earth"].house = get_planet_house(earth_deg, houses_degree_ut)
807
+ data["earth"].retrograde = swe.calc_ut(julian_day, 14, iflag)[0][3] < 0
808
+ calculated_planets.append("Earth")
809
+ except Exception as e:
810
+ logging.error(f"Error calculating Earth position: {e}")
811
+ active_points.remove("Earth")
812
+
813
+ # Calculate Chiron
814
+ if should_calculate("Chiron"):
815
+ try:
816
+ chiron_deg = swe.calc_ut(julian_day, 15, iflag)[0][0]
817
+ data["chiron"] = get_kerykeion_point_from_degree(chiron_deg, "Chiron", point_type=point_type)
818
+ data["chiron"].house = get_planet_house(chiron_deg, houses_degree_ut)
819
+ data["chiron"].retrograde = swe.calc_ut(julian_day, 15, iflag)[0][3] < 0
820
+ calculated_planets.append("Chiron")
821
+ except Exception as e:
822
+ logging.error(f"Error calculating Chiron position: {e}")
823
+ active_points.remove("Chiron")
824
+
825
+ # Calculate Pholus
826
+ if should_calculate("Pholus"):
827
+ try:
828
+ pholus_deg = swe.calc_ut(julian_day, 16, iflag)[0][0]
829
+ data["pholus"] = get_kerykeion_point_from_degree(pholus_deg, "Pholus", point_type=point_type)
830
+ data["pholus"].house = get_planet_house(pholus_deg, houses_degree_ut)
831
+ data["pholus"].retrograde = swe.calc_ut(julian_day, 16, iflag)[0][3] < 0
832
+ calculated_planets.append("Pholus")
833
+ except Exception as e:
834
+ logging.error(f"Error calculating Pholus position: {e}")
835
+ active_points.remove("Pholus")
836
+
837
+ # ==================
838
+ # ASTEROIDS
839
+ # ==================
840
+
841
+ # Calculate Ceres
842
+ if should_calculate("Ceres"):
843
+ try:
844
+ ceres_deg = swe.calc_ut(julian_day, 17, iflag)[0][0]
845
+ data["ceres"] = get_kerykeion_point_from_degree(ceres_deg, "Ceres", point_type=point_type)
846
+ data["ceres"].house = get_planet_house(ceres_deg, houses_degree_ut)
847
+ data["ceres"].retrograde = swe.calc_ut(julian_day, 17, iflag)[0][3] < 0
848
+ calculated_planets.append("Ceres")
849
+ except Exception as e:
850
+ logging.error(f"Error calculating Ceres position: {e}")
851
+ active_points.remove("Ceres")
852
+
853
+ # Calculate Pallas
854
+ if should_calculate("Pallas"):
855
+ try:
856
+ pallas_deg = swe.calc_ut(julian_day, 18, iflag)[0][0]
857
+ data["pallas"] = get_kerykeion_point_from_degree(pallas_deg, "Pallas", point_type=point_type)
858
+ data["pallas"].house = get_planet_house(pallas_deg, houses_degree_ut)
859
+ data["pallas"].retrograde = swe.calc_ut(julian_day, 18, iflag)[0][3] < 0
860
+ calculated_planets.append("Pallas")
861
+ except Exception as e:
862
+ logging.error(f"Error calculating Pallas position: {e}")
863
+ active_points.remove("Pallas")
864
+
865
+ # Calculate Juno
866
+ if should_calculate("Juno"):
867
+ try:
868
+ juno_deg = swe.calc_ut(julian_day, 19, iflag)[0][0]
869
+ data["juno"] = get_kerykeion_point_from_degree(juno_deg, "Juno", point_type=point_type)
870
+ data["juno"].house = get_planet_house(juno_deg, houses_degree_ut)
871
+ data["juno"].retrograde = swe.calc_ut(julian_day, 19, iflag)[0][3] < 0
872
+ calculated_planets.append("Juno")
873
+ except Exception as e:
874
+ logging.error(f"Error calculating Juno position: {e}")
875
+ active_points.remove("Juno")
876
+
877
+ # Calculate Vesta
878
+ if should_calculate("Vesta"):
879
+ try:
880
+ vesta_deg = swe.calc_ut(julian_day, 20, iflag)[0][0]
881
+ data["vesta"] = get_kerykeion_point_from_degree(vesta_deg, "Vesta", point_type=point_type)
882
+ data["vesta"].house = get_planet_house(vesta_deg, houses_degree_ut)
883
+ data["vesta"].retrograde = swe.calc_ut(julian_day, 20, iflag)[0][3] < 0
884
+ calculated_planets.append("Vesta")
885
+ except Exception as e:
886
+ logging.error(f"Error calculating Vesta position: {e}")
887
+ active_points.remove("Vesta")
888
+
889
+ # ==================
890
+ # TRANS-NEPTUNIAN OBJECTS
891
+ # ==================
892
+
893
+ # Calculate Eris
894
+ if should_calculate("Eris"):
895
+ try:
896
+ eris_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 136199, iflag)[0][0]
897
+ data["eris"] = get_kerykeion_point_from_degree(eris_deg, "Eris", point_type=point_type)
898
+ data["eris"].house = get_planet_house(eris_deg, houses_degree_ut)
899
+ data["eris"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 136199, iflag)[0][3] < 0
900
+ calculated_planets.append("Eris")
901
+ except Exception as e:
902
+ logging.warning(f"Could not calculate Eris position: {e}")
903
+ active_points.remove("Eris") # Remove if not calculated
904
+
905
+ # Calculate Sedna
906
+ if should_calculate("Sedna"):
907
+ try:
908
+ sedna_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 90377, iflag)[0][0]
909
+ data["sedna"] = get_kerykeion_point_from_degree(sedna_deg, "Sedna", point_type=point_type)
910
+ data["sedna"].house = get_planet_house(sedna_deg, houses_degree_ut)
911
+ data["sedna"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 90377, iflag)[0][3] < 0
912
+ calculated_planets.append("Sedna")
913
+ except Exception as e:
914
+ logging.warning(f"Could not calculate Sedna position: {e}")
915
+ active_points.remove("Sedna")
916
+
917
+ # Calculate Haumea
918
+ if should_calculate("Haumea"):
919
+ try:
920
+ haumea_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 136108, iflag)[0][0]
921
+ data["haumea"] = get_kerykeion_point_from_degree(haumea_deg, "Haumea", point_type=point_type)
922
+ data["haumea"].house = get_planet_house(haumea_deg, houses_degree_ut)
923
+ data["haumea"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 136108, iflag)[0][3] < 0
924
+ calculated_planets.append("Haumea")
925
+ except Exception as e:
926
+ logging.warning(f"Could not calculate Haumea position: {e}")
927
+ active_points.remove("Haumea") # Remove if not calculated
928
+
929
+ # Calculate Makemake
930
+ if should_calculate("Makemake"):
931
+ try:
932
+ makemake_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 136472, iflag)[0][0]
933
+ data["makemake"] = get_kerykeion_point_from_degree(makemake_deg, "Makemake", point_type=point_type)
934
+ data["makemake"].house = get_planet_house(makemake_deg, houses_degree_ut)
935
+ data["makemake"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 136472, iflag)[0][3] < 0
936
+ calculated_planets.append("Makemake")
937
+ except Exception as e:
938
+ logging.warning(f"Could not calculate Makemake position: {e}")
939
+ active_points.remove("Makemake") # Remove if not calculated
940
+
941
+ # Calculate Ixion
942
+ if should_calculate("Ixion"):
943
+ try:
944
+ ixion_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 28978, iflag)[0][0]
945
+ data["ixion"] = get_kerykeion_point_from_degree(ixion_deg, "Ixion", point_type=point_type)
946
+ data["ixion"].house = get_planet_house(ixion_deg, houses_degree_ut)
947
+ data["ixion"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 28978, iflag)[0][3] < 0
948
+ calculated_planets.append("Ixion")
949
+ except Exception as e:
950
+ logging.warning(f"Could not calculate Ixion position: {e}")
951
+ active_points.remove("Ixion") # Remove if not calculated
952
+
953
+ # Calculate Orcus
954
+ if should_calculate("Orcus"):
955
+ try:
956
+ orcus_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 90482, iflag)[0][0]
957
+ data["orcus"] = get_kerykeion_point_from_degree(orcus_deg, "Orcus", point_type=point_type)
958
+ data["orcus"].house = get_planet_house(orcus_deg, houses_degree_ut)
959
+ data["orcus"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 90482, iflag)[0][3] < 0
960
+ calculated_planets.append("Orcus")
961
+ except Exception as e:
962
+ logging.warning(f"Could not calculate Orcus position: {e}")
963
+ active_points.remove("Orcus") # Remove if not calculated
964
+
965
+ # Calculate Quaoar
966
+ if should_calculate("Quaoar"):
967
+ try:
968
+ quaoar_deg = swe.calc_ut(julian_day, swe.AST_OFFSET + 50000, iflag)[0][0]
969
+ data["quaoar"] = get_kerykeion_point_from_degree(quaoar_deg, "Quaoar", point_type=point_type)
970
+ data["quaoar"].house = get_planet_house(quaoar_deg, houses_degree_ut)
971
+ data["quaoar"].retrograde = swe.calc_ut(julian_day, swe.AST_OFFSET + 50000, iflag)[0][3] < 0
972
+ calculated_planets.append("Quaoar")
973
+ except Exception as e:
974
+ logging.warning(f"Could not calculate Quaoar position: {e}")
975
+ active_points.remove("Quaoar") # Remove if not calculated
976
+
977
+ # ==================
978
+ # FIXED STARS
979
+ # ==================
980
+
981
+ # Calculate Regulus (example fixed star)
982
+ if should_calculate("Regulus"):
983
+ try:
984
+ star_name = b"Regulus"
985
+ swe.fixstar_ut(star_name, julian_day, iflag)
986
+ regulus_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
987
+ data["regulus"] = get_kerykeion_point_from_degree(regulus_deg, "Regulus", point_type=point_type)
988
+ data["regulus"].house = get_planet_house(regulus_deg, houses_degree_ut)
989
+ data["regulus"].retrograde = False # Fixed stars are never retrograde
990
+ calculated_planets.append("Regulus")
991
+ except Exception as e:
992
+ logging.warning(f"Could not calculate Regulus position: {e}")
993
+ active_points.remove("Regulus") # Remove if not calculated
994
+
995
+ # Calculate Spica (example fixed star)
996
+ if should_calculate("Spica"):
997
+ try:
998
+ star_name = b"Spica"
999
+ swe.fixstar_ut(star_name, julian_day, iflag)
1000
+ spica_deg = swe.fixstar_ut(star_name, julian_day, iflag)[0][0]
1001
+ data["spica"] = get_kerykeion_point_from_degree(spica_deg, "Spica", point_type=point_type)
1002
+ data["spica"].house = get_planet_house(spica_deg, houses_degree_ut)
1003
+ data["spica"].retrograde = False # Fixed stars are never retrograde
1004
+ calculated_planets.append("Spica")
1005
+ except Exception as e:
1006
+ logging.warning(f"Could not calculate Spica position: {e}")
1007
+ active_points.remove("Spica") # Remove if not calculated
1008
+
1009
+ # ==================
1010
+ # ARABIC PARTS / LOTS
1011
+ # ==================
1012
+
1013
+ # Calculate Pars Fortunae (Part of Fortune)
1014
+ if should_calculate("Pars_Fortunae"):
1015
+ # Check if required points are available
1016
+ if all(k in data for k in ["ascendant", "sun", "moon"]):
1017
+ # Different calculation for day and night charts
1018
+ # Day birth (Sun above horizon): ASC + Moon - Sun
1019
+ # Night birth (Sun below horizon): ASC + Sun - Moon
1020
+ is_day_chart = get_house_number(data["sun"].house) < 7 # Houses 1-6 are above horizon
1021
+
1022
+ if is_day_chart:
1023
+ fortune_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1024
+ else:
1025
+ fortune_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1026
+
1027
+ data["pars_fortunae"] = get_kerykeion_point_from_degree(fortune_deg, "Pars_Fortunae", point_type=point_type)
1028
+ data["pars_fortunae"].house = get_planet_house(fortune_deg, houses_degree_ut)
1029
+ data["pars_fortunae"].retrograde = False # Parts are never retrograde
1030
+ calculated_planets.append("Pars_Fortunae")
1031
+
1032
+ # Calculate Pars Spiritus (Part of Spirit)
1033
+ if should_calculate("Pars_Spiritus"):
1034
+ # Check if required points are available
1035
+ if all(k in data for k in ["ascendant", "sun", "moon"]):
1036
+ # Day birth: ASC + Sun - Moon
1037
+ # Night birth: ASC + Moon - Sun
1038
+ is_day_chart = get_house_number(data["sun"].house) < 7
1039
+
1040
+ if is_day_chart:
1041
+ spirit_deg = math.fmod(data["ascendant"].abs_pos + data["sun"].abs_pos - data["moon"].abs_pos, 360)
1042
+ else:
1043
+ spirit_deg = math.fmod(data["ascendant"].abs_pos + data["moon"].abs_pos - data["sun"].abs_pos, 360)
1044
+
1045
+ data["pars_spiritus"] = get_kerykeion_point_from_degree(spirit_deg, "Pars_Spiritus", point_type=point_type)
1046
+ data["pars_spiritus"].house = get_planet_house(spirit_deg, houses_degree_ut)
1047
+ data["pars_spiritus"].retrograde = False
1048
+ calculated_planets.append("Pars_Spiritus")
1049
+
1050
+ # Calculate Pars Amoris (Part of Eros/Love)
1051
+ if should_calculate("Pars_Amoris"):
1052
+ # Check if required points are available
1053
+ if all(k in data for k in ["ascendant", "venus"]):
1054
+ # ASC + Venus - Sun
1055
+ if "sun" in data:
1056
+ amoris_deg = math.fmod(data["ascendant"].abs_pos + data["venus"].abs_pos - data["sun"].abs_pos, 360)
1057
+
1058
+ data["pars_amoris"] = get_kerykeion_point_from_degree(amoris_deg, "Pars_Amoris", point_type=point_type)
1059
+ data["pars_amoris"].house = get_planet_house(amoris_deg, houses_degree_ut)
1060
+ data["pars_amoris"].retrograde = False
1061
+ calculated_planets.append("Pars_Amoris")
1062
+
1063
+ # Calculate Pars Fidei (Part of Faith)
1064
+ if should_calculate("Pars_Fidei"):
1065
+ # Check if required points are available
1066
+ if all(k in data for k in ["ascendant", "jupiter", "saturn"]):
1067
+ # ASC + Jupiter - Saturn
1068
+ fidei_deg = math.fmod(data["ascendant"].abs_pos + data["jupiter"].abs_pos - data["saturn"].abs_pos, 360)
1069
+
1070
+ data["pars_fidei"] = get_kerykeion_point_from_degree(fidei_deg, "Pars_Fidei", point_type=point_type)
1071
+ data["pars_fidei"].house = get_planet_house(fidei_deg, houses_degree_ut)
1072
+ data["pars_fidei"].retrograde = False
1073
+ calculated_planets.append("Pars_Fidei")
1074
+
1075
+ # Calculate Vertex (a sort of auxiliary Descendant)
1076
+ if should_calculate("Vertex"):
1077
+ try:
1078
+ # Vertex is at ascmc[3] in Swiss Ephemeris
1079
+ if data["zodiac_type"] == "Sidereal":
1080
+ _, ascmc = swe.houses_ex(
1081
+ tjdut=data["julian_day"],
1082
+ lat=data["lat"],
1083
+ lon=data["lng"],
1084
+ hsys=str.encode("V"), # Vertex works best with Vehlow system
1085
+ flags=swe.FLG_SIDEREAL
1086
+ )
1087
+ else:
1088
+ _, ascmc = swe.houses(
1089
+ tjdut=data["julian_day"],
1090
+ lat=data["lat"],
1091
+ lon=data["lng"],
1092
+ hsys=str.encode("V")
1093
+ )
1094
+
1095
+ vertex_deg = ascmc[3]
1096
+ data["vertex"] = get_kerykeion_point_from_degree(vertex_deg, "Vertex", point_type=point_type)
1097
+ data["vertex"].house = get_planet_house(vertex_deg, houses_degree_ut)
1098
+ data["vertex"].retrograde = False
1099
+ calculated_planets.append("Vertex")
1100
+
1101
+ # Calculate Anti-Vertex (opposite to Vertex)
1102
+ anti_vertex_deg = math.fmod(vertex_deg + 180, 360)
1103
+ data["anti_vertex"] = get_kerykeion_point_from_degree(anti_vertex_deg, "Anti_Vertex", point_type=point_type)
1104
+ data["anti_vertex"].house = get_planet_house(anti_vertex_deg, houses_degree_ut)
1105
+ data["anti_vertex"].retrograde = False
1106
+ calculated_planets.append("Anti_Vertex")
1107
+ except Exception as e:
1108
+ logging.warning("Could not calculate Vertex position, error: %s", e)
1109
+ active_points.remove("Vertex")
1110
+
1111
+ # Store only the planets that were actually calculated
1112
+ data["active_points"] = calculated_planets
1113
+
1114
+ @classmethod
1115
+ def _calculate_day_of_week(cls, data: Dict[str, Any]) -> None:
1116
+ """Calculate the day of the week for the given Julian Day"""
1117
+ # Calculate the day of the week (0=Sunday, 1=Monday, ..., 6=Saturday)
1118
+ day_of_week = swe.day_of_week(data["julian_day"])
1119
+ # Map to human-readable names
1120
+ days_of_week = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
1121
+ data["day_of_week"] = days_of_week[day_of_week]
1122
+
1123
+ if __name__ == "__main__":
1124
+ # Example usage
1125
+ subject = AstrologicalSubjectFactory.from_current_time(name="Test Subject")
1126
+ print(subject.sun)
1127
+ print(subject.pars_amoris)
1128
+ print(subject.eris)
1129
+ print(subject.active_points)
1130
+
1131
+ # Create JSON output
1132
+ json_string = subject.model_dump_json(exclude_none=True, indent=2)