kerykeion 4.18.3__py3-none-any.whl → 5.1.9__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (76) hide show
  1. kerykeion/__init__.py +56 -11
  2. kerykeion/aspects/__init__.py +7 -4
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +86 -13
  5. kerykeion/astrological_subject_factory.py +1901 -0
  6. kerykeion/backword.py +820 -0
  7. kerykeion/chart_data_factory.py +552 -0
  8. kerykeion/charts/__init__.py +2 -2
  9. kerykeion/charts/chart_drawer.py +2794 -0
  10. kerykeion/charts/charts_utils.py +1066 -309
  11. kerykeion/charts/draw_planets.py +602 -351
  12. kerykeion/charts/templates/aspect_grid_only.xml +337 -193
  13. kerykeion/charts/templates/chart.xml +441 -240
  14. kerykeion/charts/templates/wheel_only.xml +365 -211
  15. kerykeion/charts/themes/black-and-white.css +148 -0
  16. kerykeion/charts/themes/classic.css +107 -76
  17. kerykeion/charts/themes/dark-high-contrast.css +145 -107
  18. kerykeion/charts/themes/dark.css +146 -107
  19. kerykeion/charts/themes/light.css +146 -103
  20. kerykeion/charts/themes/strawberry.css +158 -0
  21. kerykeion/composite_subject_factory.py +408 -0
  22. kerykeion/ephemeris_data_factory.py +443 -0
  23. kerykeion/fetch_geonames.py +81 -21
  24. kerykeion/house_comparison/__init__.py +6 -0
  25. kerykeion/house_comparison/house_comparison_factory.py +103 -0
  26. kerykeion/house_comparison/house_comparison_utils.py +126 -0
  27. kerykeion/kr_types/__init__.py +66 -6
  28. kerykeion/kr_types/chart_template_model.py +20 -0
  29. kerykeion/kr_types/kerykeion_exception.py +15 -9
  30. kerykeion/kr_types/kr_literals.py +14 -106
  31. kerykeion/kr_types/kr_models.py +14 -179
  32. kerykeion/kr_types/settings_models.py +15 -152
  33. kerykeion/planetary_return_factory.py +805 -0
  34. kerykeion/relationship_score_factory.py +301 -0
  35. kerykeion/report.py +750 -65
  36. kerykeion/schemas/__init__.py +106 -0
  37. kerykeion/schemas/chart_template_model.py +367 -0
  38. kerykeion/schemas/kerykeion_exception.py +20 -0
  39. kerykeion/schemas/kr_literals.py +181 -0
  40. kerykeion/schemas/kr_models.py +603 -0
  41. kerykeion/schemas/settings_models.py +188 -0
  42. kerykeion/settings/__init__.py +20 -1
  43. kerykeion/settings/chart_defaults.py +444 -0
  44. kerykeion/settings/config_constants.py +152 -0
  45. kerykeion/settings/kerykeion_settings.py +36 -61
  46. kerykeion/settings/translation_strings.py +1499 -0
  47. kerykeion/settings/translations.py +74 -0
  48. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  50. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  51. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  52. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  54. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  55. kerykeion/sweph/sefstars.txt +1602 -0
  56. kerykeion/transits_time_range_factory.py +302 -0
  57. kerykeion/utilities.py +626 -125
  58. kerykeion-5.1.9.dist-info/METADATA +1793 -0
  59. kerykeion-5.1.9.dist-info/RECORD +63 -0
  60. {kerykeion-4.18.3.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -1
  61. kerykeion/aspects/natal_aspects.py +0 -143
  62. kerykeion/aspects/synastry_aspects.py +0 -113
  63. kerykeion/astrological_subject.py +0 -818
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -894
  65. kerykeion/enums.py +0 -51
  66. kerykeion/ephemeris_data.py +0 -178
  67. kerykeion/kr_types/chart_types.py +0 -88
  68. kerykeion/relationship_score/__init__.py +0 -2
  69. kerykeion/relationship_score/relationship_score.py +0 -175
  70. kerykeion/relationship_score/relationship_score_factory.py +0 -275
  71. kerykeion/settings/kr.config.json +0 -721
  72. kerykeion-4.18.3.dist-info/LICENSE +0 -661
  73. kerykeion-4.18.3.dist-info/METADATA +0 -396
  74. kerykeion-4.18.3.dist-info/RECORD +0 -42
  75. kerykeion-4.18.3.dist-info/entry_points.txt +0 -3
  76. /LICENSE → /kerykeion-5.1.9.dist-info/licenses/LICENSE +0 -0
kerykeion/utilities.py CHANGED
@@ -1,67 +1,163 @@
1
- from kerykeion.kr_types import KerykeionPointModel, KerykeionException, ZodiacSignModel, AstrologicalSubjectModel
2
- from kerykeion.kr_types.kr_literals import LunarPhaseEmoji, LunarPhaseName, PointType, Planet, Houses
3
- from typing import Union, get_args, TYPE_CHECKING
4
- import logging
1
+ """
2
+ Author: Giacomo Battaglia
3
+ Copyright: (C) 2025 Kerykeion Project
4
+ License: AGPL-3.0
5
+ """
6
+
7
+ from kerykeion.schemas import (
8
+ KerykeionPointModel,
9
+ KerykeionException,
10
+ ZodiacSignModel,
11
+ AstrologicalSubjectModel,
12
+ LunarPhaseModel,
13
+ CompositeSubjectModel,
14
+ PlanetReturnModel,
15
+ ZodiacType,
16
+ )
17
+ from kerykeion.schemas.kr_literals import (
18
+ LunarPhaseEmoji,
19
+ LunarPhaseName,
20
+ PointType,
21
+ AstrologicalPoint,
22
+ Houses,
23
+ )
24
+ from typing import Union, Optional, get_args, cast
25
+ from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL, basicConfig, getLogger
5
26
  import math
27
+ import re
28
+ from datetime import datetime
6
29
 
7
- if TYPE_CHECKING:
8
- from kerykeion import AstrologicalSubject
9
-
10
-
11
- def get_number_from_name(name: Planet) -> int:
12
- """Utility function, gets planet id from the name."""
13
-
14
- if name == "Sun":
15
- return 0
16
- elif name == "Moon":
17
- return 1
18
- elif name == "Mercury":
19
- return 2
20
- elif name == "Venus":
21
- return 3
22
- elif name == "Mars":
23
- return 4
24
- elif name == "Jupiter":
25
- return 5
26
- elif name == "Saturn":
27
- return 6
28
- elif name == "Uranus":
29
- return 7
30
- elif name == "Neptune":
31
- return 8
32
- elif name == "Pluto":
33
- return 9
34
- elif name == "Mean_Node":
35
- return 10
36
- elif name == "True_Node":
37
- return 11
38
- elif name == "Chiron":
39
- return 15
40
- elif name == "Mean_Lilith":
41
- return 12
30
+
31
+ logger = getLogger(__name__)
32
+
33
+ def normalize_zodiac_type(value: str) -> ZodiacType:
34
+ """
35
+ Normalize a zodiac type string to its canonical representation.
36
+
37
+ Handles case-insensitive matching and legacy formats like "tropic" or "Tropic",
38
+ automatically converting them to the canonical forms "Tropical" or "Sidereal".
39
+
40
+ Args:
41
+ value: Input zodiac type string (case-insensitive).
42
+
43
+ Returns:
44
+ ZodiacType: Canonical zodiac type ("Tropical" or "Sidereal").
45
+
46
+ Raises:
47
+ ValueError: If `value` is not a recognized zodiac type.
48
+
49
+ Examples:
50
+ >>> normalize_zodiac_type("tropical")
51
+ 'Tropical'
52
+ >>> normalize_zodiac_type("Tropic")
53
+ 'Tropical'
54
+ >>> normalize_zodiac_type("SIDEREAL")
55
+ 'Sidereal'
56
+ """
57
+ # Normalize to lowercase for comparison
58
+ value_lower = value.lower()
59
+
60
+ # Map legacy and case-insensitive variants to canonical forms
61
+ if value_lower in ("tropical", "tropic"):
62
+ return cast(ZodiacType, "Tropical")
63
+ elif value_lower == "sidereal":
64
+ return cast(ZodiacType, "Sidereal")
42
65
  else:
43
- raise KerykeionException(f"Error in getting number from name! Name: {name}")
66
+ raise ValueError(
67
+ "'{value}' is not a valid zodiac type. Accepted values are: Tropical, Sidereal "
68
+ "(case-insensitive, 'tropic' also accepted as legacy).".format(value=value)
69
+ )
70
+
71
+ _POINT_NUMBER_MAP: dict[str, int] = {
72
+ "Sun": 0,
73
+ "Moon": 1,
74
+ "Mercury": 2,
75
+ "Venus": 3,
76
+ "Mars": 4,
77
+ "Jupiter": 5,
78
+ "Saturn": 6,
79
+ "Uranus": 7,
80
+ "Neptune": 8,
81
+ "Pluto": 9,
82
+ "Mean_North_Lunar_Node": 10,
83
+ "True_North_Lunar_Node": 11,
84
+ # Swiss Ephemeris has no dedicated IDs for the south nodes; we reserve high values.
85
+ "Mean_South_Lunar_Node": 1000,
86
+ "True_South_Lunar_Node": 1100,
87
+ "Chiron": 15,
88
+ "Mean_Lilith": 12,
89
+ "Ascendant": 9900,
90
+ "Descendant": 9901,
91
+ "Medium_Coeli": 9902,
92
+ "Imum_Coeli": 9903,
93
+ }
94
+
95
+ # Logging helpers
96
+ def setup_logging(level: str) -> None:
97
+ """Configure the root logger so demo scripts share the same formatting."""
98
+ normalized_level = (level or "").strip().lower()
99
+ level_map: dict[str, int] = {
100
+ "debug": DEBUG,
101
+ "info": INFO,
102
+ "warning": WARNING,
103
+ "error": ERROR,
104
+ "critical": CRITICAL,
105
+ }
44
106
 
107
+ selected_level = level_map.get(normalized_level, INFO)
108
+ basicConfig(
109
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
110
+ level=selected_level,
111
+ )
112
+ logger.setLevel(selected_level)
45
113
 
46
- def get_kerykeion_point_from_degree(
47
- degree: Union[int, float], name: Union[Planet, Houses], point_type: PointType
48
- ) -> KerykeionPointModel:
114
+
115
+
116
+
117
+ def get_number_from_name(name: AstrologicalPoint) -> int:
49
118
  """
50
- Returns a KerykeionPointModel object based on the given degree.
119
+ Convert an astrological point name to its corresponding numerical identifier.
51
120
 
52
121
  Args:
53
- degree (Union[int, float]): The degree of the celestial point.
54
- name (str): The name of the celestial point.
55
- point_type (PointType): The type of the celestial point.
122
+ name: The name of the astrological point
123
+
124
+ Returns:
125
+ The numerical identifier used in Swiss Ephemeris calculations
56
126
 
57
127
  Raises:
58
- KerykeionException: If the degree is not within the valid range (0-360).
128
+ KerykeionException: If the name is not recognized
129
+ """
130
+
131
+ try:
132
+ return _POINT_NUMBER_MAP[str(name)]
133
+ except KeyError as exc: # pragma: no cover - defensive branch
134
+ raise KerykeionException(f"Error in getting number from name! Name: {name}") from exc
135
+
136
+
137
+ def get_kerykeion_point_from_degree(
138
+ degree: Union[int, float], name: Union[AstrologicalPoint, Houses], point_type: PointType, speed: Optional[float] = None, declination: Optional[float] = None
139
+ ) -> KerykeionPointModel:
140
+ """
141
+ Create a KerykeionPointModel from a degree position.
142
+
143
+ Args:
144
+ degree: The degree position (0-360, negative values are converted to positive)
145
+ name: The name of the celestial point or house
146
+ point_type: The type classification of the point
147
+ speed: The velocity/speed of the celestial point in degrees per day (optional)
148
+ declination: The declination of the celestial point in degrees (optional)
59
149
 
60
150
  Returns:
61
- KerykeionPointModel: The model representing the celestial point.
151
+ A KerykeionPointModel with calculated zodiac sign, position, and properties
152
+
153
+ Raises:
154
+ KerykeionException: If the degree is >= 360 after normalization
62
155
  """
156
+ # If - single degree is given, convert it to a positive degree
157
+ if degree < 0:
158
+ degree = degree % 360
63
159
 
64
- if degree < 0 or degree >= 360:
160
+ if degree >= 360:
65
161
  raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
66
162
 
67
163
  ZODIAC_SIGNS = {
@@ -82,7 +178,6 @@ def get_kerykeion_point_from_degree(
82
178
  sign_index = int(degree // 30)
83
179
  sign_degree = degree % 30
84
180
  zodiac_sign = ZODIAC_SIGNS[sign_index]
85
-
86
181
  return KerykeionPointModel(
87
182
  name=name,
88
183
  quality=zodiac_sign.quality,
@@ -93,63 +188,45 @@ def get_kerykeion_point_from_degree(
93
188
  abs_pos=degree,
94
189
  emoji=zodiac_sign.emoji,
95
190
  point_type=point_type,
191
+ speed=speed,
192
+ declination=declination,
96
193
  )
97
194
 
98
- def setup_logging(level: str) -> None:
99
- """
100
- Setup logging for testing.
101
-
102
- Args:
103
- level: Log level as a string, options: debug, info, warning, error
104
- """
105
- logging_options: dict[str, int] = {
106
- "debug": logging.DEBUG,
107
- "info": logging.INFO,
108
- "warning": logging.WARNING,
109
- "error": logging.ERROR,
110
- "critical": logging.CRITICAL,
111
- }
112
- format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
113
- loglevel: int = logging_options.get(level, logging.INFO)
114
- logging.basicConfig(format=format, level=loglevel)
115
-
116
- def check_if_point_between(
117
- start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]
118
- ) -> bool:
119
- """
120
- Finds if a point is between two other in a circle.
121
-
122
- Args:
123
- - start_point: The first point
124
- - end_point: The second point
125
- - point: The point to check if it is between start_point and end_point
126
-
127
- Returns:
128
- - True if point is between start_point and end_point, False otherwise
129
- """
130
-
131
- p1_p2 = math.fmod(end_point - start_point + 360, 360)
132
- p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
133
-
134
- if (p1_p2 <= 180) != (p1_p3 > p1_p2):
195
+ # Angular helpers
196
+ def is_point_between(start_angle: Union[int, float], end_angle: Union[int, float], candidate: Union[int, float]) -> bool:
197
+ """Return True when ``candidate`` lies on the clockwise arc from ``start_angle`` to ``end_angle``."""
198
+
199
+ normalize = lambda value: value % 360
200
+
201
+ start = normalize(start_angle)
202
+ end = normalize(end_angle)
203
+ target = normalize(candidate)
204
+ span = (end - start) % 360
205
+ if span > 180:
206
+ raise KerykeionException(
207
+ f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
208
+ )
209
+ if target == start:
135
210
  return True
136
- else:
211
+ if target == end:
137
212
  return False
213
+ distance_from_start = (target - start) % 360
214
+ return distance_from_start < span
138
215
 
139
-
140
- def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
216
+ # House helpers
217
+ def get_planet_house(planet_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
141
218
  """
142
- Determines the house in which a planet is located based on its position in degrees.
219
+ Determine which house contains a planet based on its degree position.
143
220
 
144
221
  Args:
145
- planet_position_degree (Union[int, float]): The position of the planet in degrees.
146
- houses_degree_ut_list (list): A list of the houses in degrees (0-360).
222
+ planet_degree: The planet's position in degrees (0-360)
223
+ houses_degree_ut_list: List of house cusp degrees
147
224
 
148
225
  Returns:
149
- str: The house in which the planet is located.
226
+ The house name containing the planet
150
227
 
151
228
  Raises:
152
- ValueError: If the planet's position does not fall within any house range.
229
+ ValueError: If the planet's position doesn't fall within any house range
153
230
  """
154
231
 
155
232
  house_names = get_args(Houses)
@@ -158,26 +235,30 @@ def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut
158
235
  for i in range(len(house_names)):
159
236
  start_degree = houses_degree_ut_list[i]
160
237
  end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
161
- if check_if_point_between(start_degree, end_degree, planet_position_degree):
238
+
239
+ if is_point_between(start_degree, end_degree, planet_degree):
162
240
  return house_names[i]
163
241
 
164
242
  # If no house is found, raise an error
165
- raise ValueError(f"Error in house calculation, planet: {planet_position_degree}, houses: {houses_degree_ut_list}")
243
+ raise ValueError(f"Error in house calculation, planet: {planet_degree}, houses: {houses_degree_ut_list}")
166
244
 
167
245
 
168
246
  def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
169
247
  """
170
- Returns the emoji of the moon phase.
171
-
248
+ Get the emoji representation of a lunar phase.
249
+
172
250
  Args:
173
- - phase: The phase of the moon (0-28)
174
-
251
+ phase: The lunar phase number (0-28)
252
+
175
253
  Returns:
176
- - The emoji of the moon phase
254
+ The corresponding emoji for the lunar phase
255
+
256
+ Raises:
257
+ KerykeionException: If phase is outside valid range
177
258
  """
178
259
 
179
260
  lunar_phase_emojis = get_args(LunarPhaseEmoji)
180
-
261
+
181
262
  if phase == 1:
182
263
  result = lunar_phase_emojis[0]
183
264
  elif phase < 7:
@@ -200,23 +281,26 @@ def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
200
281
 
201
282
  return result
202
283
 
284
+
203
285
  def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
204
286
  """
205
- Returns the name of the moon phase.
206
-
287
+ Get the name of a lunar phase from its numerical value.
288
+
207
289
  Args:
208
- - phase: The phase of the moon (0-28)
209
-
290
+ phase: The lunar phase number (0-28)
291
+
210
292
  Returns:
211
- - The name of the moon phase
293
+ The corresponding name for the lunar phase
294
+
295
+ Raises:
296
+ KerykeionException: If phase is outside valid range
212
297
  """
213
298
  lunar_phase_names = get_args(LunarPhaseName)
214
299
 
215
-
216
300
  if phase == 1:
217
301
  result = lunar_phase_names[0]
218
302
  elif phase < 7:
219
- result = lunar_phase_names[1]
303
+ result = lunar_phase_names[1]
220
304
  elif 7 <= phase <= 9:
221
305
  result = lunar_phase_names[2]
222
306
  elif phase < 14:
@@ -232,44 +316,461 @@ def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
232
316
 
233
317
  else:
234
318
  raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
235
-
319
+
236
320
  return result
237
321
 
238
322
 
239
323
  def check_and_adjust_polar_latitude(latitude: float) -> float:
240
324
  """
241
- Utility function to check if the location is in the polar circle.
242
- If it is, it sets the latitude to 66 or -66 degrees.
325
+ Adjust latitude values for polar regions to prevent calculation errors.
326
+
327
+ Latitudes beyond ±66° are clamped to ±66° for house calculations.
328
+
329
+ Args:
330
+ latitude: The original latitude value
331
+
332
+ Returns:
333
+ The adjusted latitude value, clamped between -66° and 66°
243
334
  """
244
335
  if latitude > 66.0:
245
336
  latitude = 66.0
246
- logging.info("Polar circle override for houses, using 66 degrees")
337
+ logger.info("Latitude capped at 66° to keep house calculations stable.")
247
338
 
248
339
  elif latitude < -66.0:
249
340
  latitude = -66.0
250
- logging.info("Polar circle override for houses, using -66 degrees")
341
+ logger.info("Latitude capped at -66° to keep house calculations stable.")
251
342
 
252
343
  return latitude
253
344
 
254
345
 
255
- def get_houses_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
346
+ def get_houses_list(
347
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
348
+ ) -> list[KerykeionPointModel]:
256
349
  """
257
- Return the names of the houses in the order of the houses.
350
+ Get a list of house objects in order from the subject.
351
+
352
+ Args:
353
+ subject: The astrological subject containing house data
354
+
355
+ Returns:
356
+ List of KerykeionPointModel objects representing the houses
258
357
  """
259
358
  houses_absolute_position_list = []
260
359
  for house in subject.houses_names_list:
261
- houses_absolute_position_list.append(subject[house.lower()])
360
+ houses_absolute_position_list.append(subject[house.lower()])
262
361
 
263
362
  return houses_absolute_position_list
264
363
 
265
364
 
266
- def get_available_planets_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
365
+ def get_available_astrological_points_list(
366
+ subject: AstrologicalSubjectModel
367
+ ) -> list[KerykeionPointModel]:
267
368
  """
268
- Return the names of the planets in the order of the planets.
269
- The names can be used to access the planets from the AstrologicalSubject object with the __getitem__ method or the [] operator.
369
+ Get a list of active astrological point objects from the subject.
370
+
371
+ Args:
372
+ subject: The astrological subject containing point data
373
+
374
+ Returns:
375
+ List of KerykeionPointModel objects for all active points
270
376
  """
271
377
  planets_absolute_position_list = []
272
- for planet in subject.planets_names_list:
273
- planets_absolute_position_list.append(subject[planet.lower()])
378
+ for planet in subject.active_points:
379
+ planets_absolute_position_list.append(subject[planet.lower()])
380
+
381
+ return planets_absolute_position_list
382
+
383
+
384
+ def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
385
+ """
386
+ Calculate the circular mean of two angular positions.
274
387
 
275
- return planets_absolute_position_list
388
+ This method correctly handles positions that cross the 0°/360° boundary,
389
+ avoiding errors that occur with simple arithmetic means.
390
+
391
+ Args:
392
+ first_position: First angular position in degrees (0-360)
393
+ second_position: Second angular position in degrees (0-360)
394
+
395
+ Returns:
396
+ The circular mean position in degrees (0-360)
397
+ """
398
+ x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
399
+ y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
400
+ mean_position = math.degrees(math.atan2(y, x))
401
+
402
+ # Ensure the result is within 0-360°
403
+ if mean_position < 0:
404
+ mean_position += 360
405
+
406
+ return mean_position
407
+
408
+
409
+ def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
410
+ """
411
+ Calculate lunar phase information from Sun and Moon positions.
412
+
413
+ Args:
414
+ moon_abs_pos: Absolute position of the Moon in degrees
415
+ sun_abs_pos: Absolute position of the Sun in degrees
416
+
417
+ Returns:
418
+ LunarPhaseModel containing phase data, emoji, and name
419
+ """
420
+ # Calculate the anti-clockwise degrees between the sun and moon
421
+ degrees_between = (moon_abs_pos - sun_abs_pos) % 360
422
+
423
+ # Calculate the moon phase (1-28) based on the degrees between the sun and moon
424
+ step = 360.0 / 28.0
425
+ moon_phase = int(degrees_between // step) + 1
426
+
427
+ return LunarPhaseModel(
428
+ degrees_between_s_m=degrees_between,
429
+ moon_phase=moon_phase,
430
+ moon_emoji=get_moon_emoji_from_phase_int(moon_phase),
431
+ moon_phase_name=get_moon_phase_name_from_phase_int(moon_phase)
432
+ )
433
+
434
+
435
+ def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
436
+ """
437
+ Sort degrees in circular clockwise progression starting from the first element.
438
+
439
+ Args:
440
+ degrees: List of numeric degree values
441
+
442
+ Returns:
443
+ List sorted by clockwise distance from the first element
444
+
445
+ Raises:
446
+ ValueError: If the list is empty or contains non-numeric values
447
+ """
448
+ # Input validation
449
+ if not degrees:
450
+ raise ValueError("Input list cannot be empty")
451
+
452
+ if not all(isinstance(degree, (int, float)) for degree in degrees):
453
+ invalid = next(d for d in degrees if not isinstance(d, (int, float)))
454
+ raise ValueError(f"All elements must be numeric, found: {invalid} of type {type(invalid).__name__}")
455
+
456
+ # If list has 0 or 1 element, return it as is
457
+ if len(degrees) <= 1:
458
+ return degrees.copy()
459
+
460
+ # Save the first element as the reference
461
+ reference = degrees[0]
462
+
463
+ # Define a function to calculate clockwise distance from reference
464
+ def clockwise_distance(angle: Union[int, float]) -> Union[int, float]:
465
+ # Normalize angles to 0-360 range
466
+ ref_norm = reference % 360
467
+ angle_norm = angle % 360
468
+
469
+ # Calculate clockwise distance
470
+ distance = angle_norm - ref_norm
471
+ if distance < 0:
472
+ distance += 360
473
+
474
+ return distance
475
+
476
+ # Sort the rest of the elements based on circular distance
477
+ remaining = degrees[1:]
478
+ sorted_remaining = sorted(remaining, key=clockwise_distance)
479
+
480
+ # Return the reference followed by the sorted remaining elements
481
+ return [reference] + sorted_remaining
482
+
483
+
484
+ def inline_css_variables_in_svg(svg_content: str) -> str:
485
+ """
486
+ Replace CSS custom properties (variables) with their values in SVG content.
487
+
488
+ Extracts CSS variables from style blocks, replaces var() references with actual values,
489
+ and removes all style blocks from the SVG.
490
+
491
+ Args:
492
+ svg_content: The original SVG string with CSS variables
493
+
494
+ Returns:
495
+ Modified SVG with CSS variables inlined and style blocks removed
496
+ """
497
+ # Find and extract CSS custom properties from style tags
498
+ css_variable_map = {}
499
+ style_tag_pattern = re.compile(r"<style.*?>(.*?)</style>", re.DOTALL)
500
+ style_blocks = style_tag_pattern.findall(svg_content)
501
+
502
+ # Parse all CSS custom properties from style blocks
503
+ for style_block in style_blocks:
504
+ # Match patterns like --color-primary: #ff0000;
505
+ css_variable_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
506
+ for match in css_variable_pattern.finditer(style_block):
507
+ variable_name = match.group(1)
508
+ variable_value = match.group(2).strip()
509
+ css_variable_map[f"--{variable_name}"] = variable_value
510
+
511
+ # Remove all style blocks from the SVG
512
+ svg_without_style_blocks = style_tag_pattern.sub("", svg_content)
513
+
514
+ # Function to replace var() references with their actual values
515
+ def replace_css_variable_reference(match):
516
+ """
517
+ Replace CSS variable references with their actual values.
518
+
519
+ Args:
520
+ match: Regular expression match object containing variable name and optional fallback.
521
+
522
+ Returns:
523
+ str: The resolved CSS variable value or fallback value.
524
+ """
525
+ variable_name = match.group(1).strip()
526
+ fallback_value = match.group(2) if match.group(2) else None
527
+
528
+ if variable_name in css_variable_map:
529
+ return css_variable_map[variable_name]
530
+ elif fallback_value:
531
+ return fallback_value.strip(", ")
532
+ else:
533
+ return "" # If variable not found and no fallback provided
534
+
535
+ # Pattern to match var(--name) or var(--name, fallback)
536
+ variable_usage_pattern = re.compile(r"var\(\s*(--([\w-]+))\s*(,\s*([^)]+))?\s*\)")
537
+
538
+ # Repeatedly replace all var() references until none remain
539
+ # This handles nested variables or variables that reference other variables
540
+ processed_svg = svg_without_style_blocks
541
+ while variable_usage_pattern.search(processed_svg):
542
+ processed_svg = variable_usage_pattern.sub(lambda m: replace_css_variable_reference(m), processed_svg)
543
+
544
+ return processed_svg
545
+
546
+
547
+ def datetime_to_julian(dt: datetime) -> float:
548
+ """
549
+ Convert a Python datetime object to Julian Day Number.
550
+
551
+ Args:
552
+ dt: The datetime object to convert
553
+
554
+ Returns:
555
+ The corresponding Julian Day Number (JD) as a float
556
+ """
557
+ # Extract year, month and day
558
+ year = dt.year
559
+ month = dt.month
560
+ day = dt.day
561
+
562
+ # Adjust month and year according to the conversion formula
563
+ if month <= 2:
564
+ year -= 1
565
+ month += 12
566
+
567
+ # Calculate century and year in century
568
+ a = year // 100
569
+ b = 2 - a + (a // 4)
570
+
571
+ # Calculate the Julian day
572
+ jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + b - 1524.5
573
+
574
+ # Add the time portion
575
+ hour = dt.hour
576
+ minute = dt.minute
577
+ second = dt.second
578
+ microsecond = dt.microsecond
579
+
580
+ jd += (hour + minute / 60 + second / 3600 + microsecond / 3600000000) / 24
581
+
582
+ return jd
583
+
584
+
585
+ def julian_to_datetime(jd):
586
+ """
587
+ Convert a Julian Day Number to a Python datetime object.
588
+
589
+ Args:
590
+ jd: Julian Day Number as a float
591
+
592
+ Returns:
593
+ The corresponding datetime object
594
+ """
595
+ # Add 0.5 to the Julian day to adjust for noon-based Julian day
596
+ jd_plus = jd + 0.5
597
+
598
+ # Integer and fractional parts
599
+ Z = int(jd_plus)
600
+ F = jd_plus - Z
601
+
602
+ # Calculate alpha
603
+ if Z < 2299161:
604
+ A = Z # Julian calendar
605
+ else:
606
+ alpha = int((Z - 1867216.25) / 36524.25)
607
+ A = Z + 1 + alpha - int(alpha / 4) # Gregorian calendar
608
+
609
+ # Calculate B
610
+ B = A + 1524
611
+
612
+ # Calculate C
613
+ C = int((B - 122.1) / 365.25)
614
+
615
+ # Calculate D
616
+ D = int(365.25 * C)
617
+
618
+ # Calculate E
619
+ E = int((B - D) / 30.6001)
620
+
621
+ # Calculate day and month
622
+ day = B - D - int(30.6001 * E) + F
623
+
624
+ # Integer part of day
625
+ day_int = int(day)
626
+
627
+ # Fractional part converted to hours, minutes, seconds, microseconds
628
+ day_frac = day - day_int
629
+ hours = int(day_frac * 24)
630
+ minutes = int((day_frac * 24 - hours) * 60)
631
+ seconds = int((day_frac * 24 * 60 - hours * 60 - minutes) * 60)
632
+ microseconds = int(((day_frac * 24 * 60 - hours * 60 - minutes) * 60 - seconds) * 1000000)
633
+
634
+ # Calculate month
635
+ if E < 14:
636
+ month = E - 1
637
+ else:
638
+ month = E - 13
639
+
640
+ # Calculate year
641
+ if month > 2:
642
+ year = C - 4716
643
+ else:
644
+ year = C - 4715
645
+
646
+ # Create and return datetime object
647
+ return datetime(year, month, day_int, hours, minutes, seconds, microseconds)
648
+
649
+
650
+ def get_house_name(house_number: int) -> Houses:
651
+ """
652
+ Convert a house number to its corresponding house name.
653
+
654
+ Args:
655
+ house_number: House number (1-12)
656
+
657
+ Returns:
658
+ The house name
659
+
660
+ Raises:
661
+ ValueError: If house_number is not in range 1-12
662
+ """
663
+ house_names: dict[int, Houses] = {
664
+ 1: "First_House",
665
+ 2: "Second_House",
666
+ 3: "Third_House",
667
+ 4: "Fourth_House",
668
+ 5: "Fifth_House",
669
+ 6: "Sixth_House",
670
+ 7: "Seventh_House",
671
+ 8: "Eighth_House",
672
+ 9: "Ninth_House",
673
+ 10: "Tenth_House",
674
+ 11: "Eleventh_House",
675
+ 12: "Twelfth_House",
676
+ }
677
+
678
+ name = house_names.get(house_number, None)
679
+ if name is None:
680
+ raise ValueError(f"Invalid house number: {house_number}")
681
+
682
+ return name
683
+
684
+
685
+ def get_house_number(house_name: Houses) -> int:
686
+ """
687
+ Convert a house name to its corresponding house number.
688
+
689
+ Args:
690
+ house_name: The house name
691
+
692
+ Returns:
693
+ House number (1-12)
694
+
695
+ Raises:
696
+ ValueError: If house_name is not recognized
697
+ """
698
+ house_numbers: dict[Houses, int] = {
699
+ "First_House": 1,
700
+ "Second_House": 2,
701
+ "Third_House": 3,
702
+ "Fourth_House": 4,
703
+ "Fifth_House": 5,
704
+ "Sixth_House": 6,
705
+ "Seventh_House": 7,
706
+ "Eighth_House": 8,
707
+ "Ninth_House": 9,
708
+ "Tenth_House": 10,
709
+ "Eleventh_House": 11,
710
+ "Twelfth_House": 12,
711
+ }
712
+
713
+ number = house_numbers.get(house_name, None)
714
+ if number is None:
715
+ raise ValueError(f"Invalid house name: {house_name}")
716
+
717
+ return number
718
+
719
+
720
+ def find_common_active_points(first_points: list[AstrologicalPoint], second_points: list[AstrologicalPoint]) -> list[AstrologicalPoint]:
721
+ """
722
+ Find astrological points that appear in both input lists.
723
+
724
+ Args:
725
+ first_points: First list of astrological points
726
+ second_points: Second list of astrological points
727
+
728
+ Returns:
729
+ List of points common to both input lists (without duplicates)
730
+ """
731
+ common_points = list(set(first_points) & set(second_points))
732
+
733
+ return common_points
734
+
735
+
736
+ def distribute_percentages_to_100(values: dict[str, float]) -> dict[str, int]:
737
+ """
738
+ Distribute percentages so they sum to exactly 100.
739
+
740
+ This function uses a largest remainder method to ensure that
741
+ the percentage total equals 100 even after rounding.
742
+
743
+ Args:
744
+ values: Dictionary with keys and their raw percentage values
745
+
746
+ Returns:
747
+ Dictionary with the same keys and integer percentages that sum to 100
748
+ """
749
+ if not values:
750
+ return {}
751
+
752
+ total = sum(values.values())
753
+ if total == 0:
754
+ return {key: 0 for key in values.keys()}
755
+
756
+ # Calculate base percentages
757
+ percentages = {key: value * 100 / total for key, value in values.items()}
758
+
759
+ # Get integer parts and remainders
760
+ integer_parts = {key: int(value) for key, value in percentages.items()}
761
+ remainders = {key: percentages[key] - integer_parts[key] for key in percentages.keys()}
762
+
763
+ # Calculate how many we need to add to reach 100
764
+ current_sum = sum(integer_parts.values())
765
+ needed = 100 - current_sum
766
+
767
+ # Sort by remainder (largest first) and add 1 to the largest remainders
768
+ sorted_by_remainder = sorted(remainders.items(), key=lambda x: x[1], reverse=True)
769
+
770
+ result = integer_parts.copy()
771
+ for i in range(needed):
772
+ if i < len(sorted_by_remainder):
773
+ key = sorted_by_remainder[i][0]
774
+ result[key] += 1
775
+
776
+ return result