kerykeion 5.0.0a9__py3-none-any.whl → 5.1.8__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (79) hide show
  1. kerykeion/__init__.py +50 -9
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +78 -11
  5. kerykeion/astrological_subject_factory.py +1032 -275
  6. kerykeion/backword.py +820 -0
  7. kerykeion/chart_data_factory.py +552 -0
  8. kerykeion/charts/chart_drawer.py +2661 -0
  9. kerykeion/charts/charts_utils.py +652 -399
  10. kerykeion/charts/draw_planets.py +603 -353
  11. kerykeion/charts/templates/aspect_grid_only.xml +326 -198
  12. kerykeion/charts/templates/chart.xml +306 -256
  13. kerykeion/charts/templates/wheel_only.xml +330 -200
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +11 -0
  16. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  17. kerykeion/charts/themes/dark.css +11 -0
  18. kerykeion/charts/themes/light.css +11 -0
  19. kerykeion/charts/themes/strawberry.css +10 -0
  20. kerykeion/composite_subject_factory.py +232 -13
  21. kerykeion/ephemeris_data_factory.py +443 -0
  22. kerykeion/fetch_geonames.py +78 -21
  23. kerykeion/house_comparison/__init__.py +4 -1
  24. kerykeion/house_comparison/house_comparison_factory.py +52 -19
  25. kerykeion/house_comparison/house_comparison_utils.py +37 -9
  26. kerykeion/kr_types/__init__.py +66 -6
  27. kerykeion/kr_types/chart_template_model.py +20 -0
  28. kerykeion/kr_types/kerykeion_exception.py +15 -9
  29. kerykeion/kr_types/kr_literals.py +14 -160
  30. kerykeion/kr_types/kr_models.py +14 -291
  31. kerykeion/kr_types/settings_models.py +15 -167
  32. kerykeion/planetary_return_factory.py +545 -40
  33. kerykeion/relationship_score_factory.py +137 -63
  34. kerykeion/report.py +749 -64
  35. kerykeion/schemas/__init__.py +106 -0
  36. kerykeion/schemas/chart_template_model.py +367 -0
  37. kerykeion/schemas/kerykeion_exception.py +20 -0
  38. kerykeion/schemas/kr_literals.py +181 -0
  39. kerykeion/schemas/kr_models.py +603 -0
  40. kerykeion/schemas/settings_models.py +188 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +88 -12
  44. kerykeion/settings/kerykeion_settings.py +32 -75
  45. kerykeion/settings/translation_strings.py +1499 -0
  46. kerykeion/settings/translations.py +74 -0
  47. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  48. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  50. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  51. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  52. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  54. kerykeion/sweph/sefstars.txt +1602 -0
  55. kerykeion/transits_time_range_factory.py +302 -0
  56. kerykeion/utilities.py +289 -204
  57. kerykeion-5.1.8.dist-info/METADATA +1793 -0
  58. kerykeion-5.1.8.dist-info/RECORD +63 -0
  59. kerykeion/aspects/natal_aspects.py +0 -181
  60. kerykeion/aspects/synastry_aspects.py +0 -141
  61. kerykeion/aspects/transits_time_range.py +0 -41
  62. kerykeion/charts/draw_planets_v2.py +0 -649
  63. kerykeion/charts/draw_planets_v3.py +0 -679
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -2038
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -238
  67. kerykeion/house_comparison/house_comparison_models.py +0 -38
  68. kerykeion/kr_types/chart_types.py +0 -106
  69. kerykeion/settings/kr.config.json +0 -1304
  70. kerykeion/settings/legacy/__init__.py +0 -0
  71. kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
  72. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
  73. kerykeion/settings/legacy/legacy_color_settings.py +0 -42
  74. kerykeion/transits_time_range.py +0 -128
  75. kerykeion-5.0.0a9.dist-info/METADATA +0 -636
  76. kerykeion-5.0.0a9.dist-info/RECORD +0 -55
  77. kerykeion-5.0.0a9.dist-info/entry_points.txt +0 -2
  78. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/WHEEL +0 -0
  79. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/licenses/LICENSE +0 -0
kerykeion/utilities.py CHANGED
@@ -1,4 +1,10 @@
1
- from kerykeion.kr_types import (
1
+ """
2
+ Author: Giacomo Battaglia
3
+ Copyright: (C) 2025 Kerykeion Project
4
+ License: AGPL-3.0
5
+ """
6
+
7
+ from kerykeion.schemas import (
2
8
  KerykeionPointModel,
3
9
  KerykeionException,
4
10
  ZodiacSignModel,
@@ -6,82 +12,146 @@ from kerykeion.kr_types import (
6
12
  LunarPhaseModel,
7
13
  CompositeSubjectModel,
8
14
  PlanetReturnModel,
15
+ ZodiacType,
16
+ )
17
+ from kerykeion.schemas.kr_literals import (
18
+ LunarPhaseEmoji,
19
+ LunarPhaseName,
20
+ PointType,
21
+ AstrologicalPoint,
22
+ Houses,
9
23
  )
10
- from kerykeion.kr_types.kr_literals import LunarPhaseEmoji, LunarPhaseName, PointType, AstrologicalPoint, Houses
11
- from typing import Union, get_args, TYPE_CHECKING
12
- import logging
24
+ from typing import Union, Optional, get_args, cast
25
+ from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL, basicConfig, getLogger
13
26
  import math
14
27
  import re
15
28
  from datetime import datetime
16
29
 
17
- if TYPE_CHECKING:
18
- from kerykeion import AstrologicalSubjectFactory
19
30
 
31
+ logger = getLogger(__name__)
20
32
 
21
- def get_number_from_name(name: AstrologicalPoint) -> int:
22
- """Utility function, gets planet id from the name."""
23
-
24
- if name == "Sun":
25
- return 0
26
- elif name == "Moon":
27
- return 1
28
- elif name == "Mercury":
29
- return 2
30
- elif name == "Venus":
31
- return 3
32
- elif name == "Mars":
33
- return 4
34
- elif name == "Jupiter":
35
- return 5
36
- elif name == "Saturn":
37
- return 6
38
- elif name == "Uranus":
39
- return 7
40
- elif name == "Neptune":
41
- return 8
42
- elif name == "Pluto":
43
- return 9
44
- elif name == "Mean_Node":
45
- return 10
46
- elif name == "True_Node":
47
- return 11
48
- # Note: Swiss ephemeris library has no constants for south nodes. We're using integers >= 1000 for them.
49
- elif name == "Mean_South_Node":
50
- return 1000
51
- elif name == "True_South_Node":
52
- return 1100
53
- elif name == "Chiron":
54
- return 15
55
- elif name == "Mean_Lilith":
56
- return 12
57
- elif name == "Ascendant": # TODO: Is this needed?
58
- return 9900
59
- elif name == "Descendant": # TODO: Is this needed?
60
- return 9901
61
- elif name == "Medium_Coeli": # TODO: Is this needed?
62
- return 9902
63
- elif name == "Imum_Coeli": # TODO: Is this needed?
64
- return 9903
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")
65
65
  else:
66
- 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
+ }
67
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)
68
113
 
69
- def get_kerykeion_point_from_degree(
70
- degree: Union[int, float], name: Union[AstrologicalPoint, Houses], point_type: PointType
71
- ) -> KerykeionPointModel:
114
+
115
+
116
+
117
+ def get_number_from_name(name: AstrologicalPoint) -> int:
72
118
  """
73
- Returns a KerykeionPointModel object based on the given degree.
119
+ Convert an astrological point name to its corresponding numerical identifier.
74
120
 
75
121
  Args:
76
- degree (Union[int, float]): The degree of the celestial point.
77
- name (str): The name of the celestial point.
78
- 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
79
126
 
80
127
  Raises:
81
- 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)
82
149
 
83
150
  Returns:
84
- 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
85
155
  """
86
156
  # If - single degree is given, convert it to a positive degree
87
157
  if degree < 0:
@@ -108,7 +178,6 @@ def get_kerykeion_point_from_degree(
108
178
  sign_index = int(degree // 30)
109
179
  sign_degree = degree % 30
110
180
  zodiac_sign = ZODIAC_SIGNS[sign_index]
111
-
112
181
  return KerykeionPointModel(
113
182
  name=name,
114
183
  quality=zodiac_sign.quality,
@@ -119,91 +188,45 @@ def get_kerykeion_point_from_degree(
119
188
  abs_pos=degree,
120
189
  emoji=zodiac_sign.emoji,
121
190
  point_type=point_type,
191
+ speed=speed,
192
+ declination=declination,
122
193
  )
123
194
 
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``."""
124
198
 
125
- def setup_logging(level: str) -> None:
126
- """
127
- Setup logging for testing.
128
-
129
- Args:
130
- level: Log level as a string, options: debug, info, warning, error
131
- """
132
- logging_options: dict[str, int] = {
133
- "debug": logging.DEBUG,
134
- "info": logging.INFO,
135
- "warning": logging.WARNING,
136
- "error": logging.ERROR,
137
- "critical": logging.CRITICAL,
138
- }
139
- format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
140
- loglevel: int = logging_options.get(level, logging.INFO)
141
- logging.basicConfig(format=format, level=loglevel)
142
-
143
-
144
- def is_point_between(
145
- start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]
146
- ) -> bool:
147
- """
148
- Determines if a point is between two others on a circle, with additional rules:
149
- - If evaluated_point == start_point, it is considered between.
150
- - If evaluated_point == end_point, it is NOT considered between.
151
- - The range between start_point and end_point must not exceed 180°.
152
-
153
- Args:
154
- - start_point: The first point on the circle.
155
- - end_point: The second point on the circle.
156
- - evaluated_point: The point to check.
157
-
158
- Returns:
159
- - True if evaluated_point is between start_point and end_point, False otherwise.
160
- """
161
-
162
- # Normalize angles to [0, 360)
163
- start_point = start_point % 360
164
- end_point = end_point % 360
165
- evaluated_point = evaluated_point % 360
166
-
167
- # Compute angular difference
168
- angular_difference = math.fmod(end_point - start_point + 360, 360)
199
+ normalize = lambda value: value % 360
169
200
 
170
- # Ensure the range is not greater than 180°. Otherwise, it is not truly defined what
171
- # being located in between two points on a circle actually means.
172
- if angular_difference > 180:
201
+ start = normalize(start_angle)
202
+ end = normalize(end_angle)
203
+ target = normalize(candidate)
204
+ span = (end - start) % 360
205
+ if span > 180:
173
206
  raise KerykeionException(
174
- f"The angle between start and end point is not allowed to exceed 180°, yet is: {angular_difference}"
207
+ f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
175
208
  )
176
-
177
- # Handle explicitly when evaluated_point == start_point. Note: It may happen for mathematical
178
- # reasons that evaluated_point and start_point deviate very slightly from each other, but
179
- # should really be same value. This case is captured later below by the term 0 <= p1_p3.
180
- if evaluated_point == start_point:
209
+ if target == start:
181
210
  return True
182
-
183
- # Handle explicitly when evaluated_point == end_point
184
- if evaluated_point == end_point:
211
+ if target == end:
185
212
  return False
213
+ distance_from_start = (target - start) % 360
214
+ return distance_from_start < span
186
215
 
187
- # Compute angular differences for evaluation
188
- p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
189
-
190
- # Check if point lies in the interval
191
- return (0 <= p1_p3) and (p1_p3 < angular_difference)
192
-
193
-
194
- 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:
195
218
  """
196
- 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.
197
220
 
198
221
  Args:
199
- planet_position_degree (Union[int, float]): The position of the planet in degrees.
200
- 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
201
224
 
202
225
  Returns:
203
- str: The house in which the planet is located.
226
+ The house name containing the planet
204
227
 
205
228
  Raises:
206
- 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
207
230
  """
208
231
 
209
232
  house_names = get_args(Houses)
@@ -213,22 +236,25 @@ def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut
213
236
  start_degree = houses_degree_ut_list[i]
214
237
  end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
215
238
 
216
- if is_point_between(start_degree, end_degree, planet_position_degree):
239
+ if is_point_between(start_degree, end_degree, planet_degree):
217
240
  return house_names[i]
218
241
 
219
242
  # If no house is found, raise an error
220
- 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}")
221
244
 
222
245
 
223
246
  def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
224
247
  """
225
- Returns the emoji of the moon phase.
248
+ Get the emoji representation of a lunar phase.
226
249
 
227
250
  Args:
228
- - phase: The phase of the moon (0-28)
251
+ phase: The lunar phase number (0-28)
229
252
 
230
253
  Returns:
231
- - 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
232
258
  """
233
259
 
234
260
  lunar_phase_emojis = get_args(LunarPhaseEmoji)
@@ -258,13 +284,16 @@ def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
258
284
 
259
285
  def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
260
286
  """
261
- Returns the name of the moon phase.
287
+ Get the name of a lunar phase from its numerical value.
262
288
 
263
289
  Args:
264
- - phase: The phase of the moon (0-28)
290
+ phase: The lunar phase number (0-28)
265
291
 
266
292
  Returns:
267
- - 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
268
297
  """
269
298
  lunar_phase_names = get_args(LunarPhaseName)
270
299
 
@@ -293,16 +322,23 @@ def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
293
322
 
294
323
  def check_and_adjust_polar_latitude(latitude: float) -> float:
295
324
  """
296
- Utility function to check if the location is in the polar circle.
297
- 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°
298
334
  """
299
335
  if latitude > 66.0:
300
336
  latitude = 66.0
301
- logging.info("Polar circle override for houses, using 66 degrees")
337
+ logger.info("Latitude capped at 66° to keep house calculations stable.")
302
338
 
303
339
  elif latitude < -66.0:
304
340
  latitude = -66.0
305
- logging.info("Polar circle override for houses, using -66 degrees")
341
+ logger.info("Latitude capped at -66° to keep house calculations stable.")
306
342
 
307
343
  return latitude
308
344
 
@@ -311,7 +347,13 @@ def get_houses_list(
311
347
  subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
312
348
  ) -> list[KerykeionPointModel]:
313
349
  """
314
- 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
315
357
  """
316
358
  houses_absolute_position_list = []
317
359
  for house in subject.houses_names_list:
@@ -324,8 +366,13 @@ def get_available_astrological_points_list(
324
366
  subject: AstrologicalSubjectModel
325
367
  ) -> list[KerykeionPointModel]:
326
368
  """
327
- Return the names of the planets in the order of the planets.
328
- 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
329
376
  """
330
377
  planets_absolute_position_list = []
331
378
  for planet in subject.active_points:
@@ -336,17 +383,17 @@ def get_available_astrological_points_list(
336
383
 
337
384
  def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
338
385
  """
339
- Computes the circular mean of two astrological positions (e.g., house cusps, planets).
386
+ Calculate the circular mean of two angular positions.
340
387
 
341
- This function ensures that positions crossing Aries (360°) are correctly averaged,
342
- avoiding errors that occur with simple linear means.
388
+ This method correctly handles positions that cross the 0°/360° boundary,
389
+ avoiding errors that occur with simple arithmetic means.
343
390
 
344
391
  Args:
345
- position1 (Union[int, float]): First position in degrees (0-360).
346
- position2 (Union[int, float]): Second position in degrees (0-360).
392
+ first_position: First angular position in degrees (0-360)
393
+ second_position: Second angular position in degrees (0-360)
347
394
 
348
395
  Returns:
349
- float: The circular mean position in degrees (0-360).
396
+ The circular mean position in degrees (0-360)
350
397
  """
351
398
  x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
352
399
  y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
@@ -361,18 +408,15 @@ def circular_mean(first_position: Union[int, float], second_position: Union[int,
361
408
 
362
409
  def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
363
410
  """
364
- Calculate the lunar phase based on the positions of the moon and sun.
411
+ Calculate lunar phase information from Sun and Moon positions.
365
412
 
366
413
  Args:
367
- - moon_abs_pos (float): The absolute position of the moon.
368
- - sun_abs_pos (float): The absolute position of the sun.
414
+ moon_abs_pos: Absolute position of the Moon in degrees
415
+ sun_abs_pos: Absolute position of the Sun in degrees
369
416
 
370
417
  Returns:
371
- - dict: A dictionary containing the lunar phase information.
418
+ LunarPhaseModel containing phase data, emoji, and name
372
419
  """
373
- # Initialize moon_phase and sun_phase to None in case of an error
374
- moon_phase, sun_phase = None, None
375
-
376
420
  # Calculate the anti-clockwise degrees between the sun and moon
377
421
  degrees_between = (moon_abs_pos - sun_abs_pos) % 360
378
422
 
@@ -380,42 +424,23 @@ def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseM
380
424
  step = 360.0 / 28.0
381
425
  moon_phase = int(degrees_between // step) + 1
382
426
 
383
- # Define the sun phase steps
384
- sunstep = [
385
- 0, 30, 40, 50, 60, 70, 80, 90, 120, 130, 140, 150, 160, 170, 180,
386
- 210, 220, 230, 240, 250, 260, 270, 300, 310, 320, 330, 340, 350
387
- ]
388
-
389
- # Calculate the sun phase (1-28) based on the degrees between the sun and moon
390
- for x in range(len(sunstep)):
391
- low = sunstep[x]
392
- high = sunstep[x + 1] if x < len(sunstep) - 1 else 360
393
- if low <= degrees_between < high:
394
- sun_phase = x + 1
395
- break
396
-
397
- # Create a dictionary with the lunar phase information
398
- lunar_phase_dictionary = {
399
- "degrees_between_s_m": degrees_between,
400
- "moon_phase": moon_phase,
401
- "sun_phase": sun_phase,
402
- "moon_emoji": get_moon_emoji_from_phase_int(moon_phase),
403
- "moon_phase_name": get_moon_phase_name_from_phase_int(moon_phase),
404
- }
405
-
406
- return LunarPhaseModel(**lunar_phase_dictionary)
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
+ )
407
433
 
408
434
 
409
435
  def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
410
436
  """
411
- Sort a list of degrees in a circular manner, starting from the first element
412
- and progressing clockwise around the circle.
437
+ Sort degrees in circular clockwise progression starting from the first element.
413
438
 
414
439
  Args:
415
- degrees: A list of numeric values representing degrees
440
+ degrees: List of numeric degree values
416
441
 
417
442
  Returns:
418
- A list sorted based on circular clockwise progression from the first element
443
+ List sorted by clockwise distance from the first element
419
444
 
420
445
  Raises:
421
446
  ValueError: If the list is empty or contains non-numeric values
@@ -458,14 +483,16 @@ def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
458
483
 
459
484
  def inline_css_variables_in_svg(svg_content: str) -> str:
460
485
  """
461
- Process an SVG string to inline all CSS custom properties.
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.
462
490
 
463
491
  Args:
464
- svg_content (str): The original SVG string with CSS variables
492
+ svg_content: The original SVG string with CSS variables
465
493
 
466
494
  Returns:
467
- str: The modified SVG with all CSS variables replaced by their values
468
- and all style blocks removed
495
+ Modified SVG with CSS variables inlined and style blocks removed
469
496
  """
470
497
  # Find and extract CSS custom properties from style tags
471
498
  css_variable_map = {}
@@ -486,6 +513,15 @@ def inline_css_variables_in_svg(svg_content: str) -> str:
486
513
 
487
514
  # Function to replace var() references with their actual values
488
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
+ """
489
525
  variable_name = match.group(1).strip()
490
526
  fallback_value = match.group(2) if match.group(2) else None
491
527
 
@@ -510,13 +546,13 @@ def inline_css_variables_in_svg(svg_content: str) -> str:
510
546
 
511
547
  def datetime_to_julian(dt: datetime) -> float:
512
548
  """
513
- Converts a Python datetime object to Julian day.
549
+ Convert a Python datetime object to Julian Day Number.
514
550
 
515
551
  Args:
516
- dt: A datetime object
552
+ dt: The datetime object to convert
517
553
 
518
554
  Returns:
519
- float: The corresponding Julian day (JD)
555
+ The corresponding Julian Day Number (JD) as a float
520
556
  """
521
557
  # Extract year, month and day
522
558
  year = dt.year
@@ -548,13 +584,13 @@ def datetime_to_julian(dt: datetime) -> float:
548
584
 
549
585
  def julian_to_datetime(jd):
550
586
  """
551
- Converts a Julian day to a Python datetime object.
587
+ Convert a Julian Day Number to a Python datetime object.
552
588
 
553
589
  Args:
554
- jd: Julian day number (float)
590
+ jd: Julian Day Number as a float
555
591
 
556
592
  Returns:
557
- datetime: The corresponding datetime object
593
+ The corresponding datetime object
558
594
  """
559
595
  # Add 0.5 to the Julian day to adjust for noon-based Julian day
560
596
  jd_plus = jd + 0.5
@@ -613,13 +649,16 @@ def julian_to_datetime(jd):
613
649
 
614
650
  def get_house_name(house_number: int) -> Houses:
615
651
  """
616
- Returns the name of the house based on its number.
652
+ Convert a house number to its corresponding house name.
617
653
 
618
654
  Args:
619
655
  house_number: House number (1-12)
620
656
 
621
657
  Returns:
622
- Name of the house
658
+ The house name
659
+
660
+ Raises:
661
+ ValueError: If house_number is not in range 1-12
623
662
  """
624
663
  house_names: dict[int, Houses] = {
625
664
  1: "First_House",
@@ -645,13 +684,16 @@ def get_house_name(house_number: int) -> Houses:
645
684
 
646
685
  def get_house_number(house_name: Houses) -> int:
647
686
  """
648
- Returns the number of the house based on its name.
687
+ Convert a house name to its corresponding house number.
649
688
 
650
689
  Args:
651
- house_name: Name of the house
690
+ house_name: The house name
652
691
 
653
692
  Returns:
654
693
  House number (1-12)
694
+
695
+ Raises:
696
+ ValueError: If house_name is not recognized
655
697
  """
656
698
  house_numbers: dict[Houses, int] = {
657
699
  "First_House": 1,
@@ -677,15 +719,58 @@ def get_house_number(house_name: Houses) -> int:
677
719
 
678
720
  def find_common_active_points(first_points: list[AstrologicalPoint], second_points: list[AstrologicalPoint]) -> list[AstrologicalPoint]:
679
721
  """
680
- Find only the elements that are present in both lists.
722
+ Find astrological points that appear in both input lists.
681
723
 
682
724
  Args:
683
- first_points: List of astrological points
684
- second_points: List of astrological points
725
+ first_points: First list of astrological points
726
+ second_points: Second list of astrological points
685
727
 
686
728
  Returns:
687
- List of elements common to both lists (without duplicates, order not guaranteed).
729
+ List of points common to both input lists (without duplicates)
688
730
  """
689
731
  common_points = list(set(first_points) & set(second_points))
690
732
 
691
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