kerykeion 3.1.1__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 (75) hide show
  1. kerykeion/__init__.py +58 -141
  2. kerykeion/aspects/__init__.py +14 -0
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +164 -0
  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 +5 -0
  9. kerykeion/charts/chart_drawer.py +2794 -0
  10. kerykeion/charts/charts_utils.py +1840 -0
  11. kerykeion/charts/draw_planets.py +658 -0
  12. kerykeion/charts/templates/aspect_grid_only.xml +596 -0
  13. kerykeion/charts/templates/chart.xml +741 -0
  14. kerykeion/charts/templates/wheel_only.xml +653 -0
  15. kerykeion/charts/themes/black-and-white.css +148 -0
  16. kerykeion/charts/themes/classic.css +113 -0
  17. kerykeion/charts/themes/dark-high-contrast.css +159 -0
  18. kerykeion/charts/themes/dark.css +160 -0
  19. kerykeion/charts/themes/light.css +160 -0
  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 +105 -61
  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 +70 -0
  28. kerykeion/kr_types/chart_template_model.py +20 -0
  29. kerykeion/kr_types/kerykeion_exception.py +20 -0
  30. kerykeion/kr_types/kr_literals.py +20 -0
  31. kerykeion/kr_types/kr_models.py +20 -0
  32. kerykeion/kr_types/settings_models.py +20 -0
  33. kerykeion/planetary_return_factory.py +805 -0
  34. kerykeion/relationship_score_factory.py +301 -0
  35. kerykeion/report.py +779 -0
  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 -0
  43. kerykeion/settings/chart_defaults.py +444 -0
  44. kerykeion/settings/config_constants.py +152 -0
  45. kerykeion/settings/kerykeion_settings.py +51 -0
  46. kerykeion/settings/translation_strings.py +1499 -0
  47. kerykeion/settings/translations.py +74 -0
  48. kerykeion/sweph/README.md +3 -0
  49. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  50. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  51. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  52. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  53. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  54. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  55. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  56. kerykeion/sweph/seas_18.se1 +0 -0
  57. kerykeion/sweph/sefstars.txt +1602 -0
  58. kerykeion/transits_time_range_factory.py +302 -0
  59. kerykeion/utilities.py +762 -130
  60. kerykeion-5.1.9.dist-info/METADATA +1793 -0
  61. kerykeion-5.1.9.dist-info/RECORD +63 -0
  62. {kerykeion-3.1.1.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -2
  63. kerykeion-5.1.9.dist-info/licenses/LICENSE +661 -0
  64. kerykeion/aspects.py +0 -331
  65. kerykeion/charts/charts_svg.py +0 -1607
  66. kerykeion/charts/templates/basic.xml +0 -285
  67. kerykeion/charts/templates/extended.xml +0 -294
  68. kerykeion/kr.config.json +0 -464
  69. kerykeion/main.py +0 -595
  70. kerykeion/print_all_data.py +0 -44
  71. kerykeion/relationship_score.py +0 -219
  72. kerykeion/types.py +0 -190
  73. kerykeion-3.1.1.dist-info/METADATA +0 -204
  74. kerykeion-3.1.1.dist-info/RECORD +0 -17
  75. kerykeion-3.1.1.dist-info/top_level.txt +0 -1
kerykeion/utilities.py CHANGED
@@ -1,144 +1,776 @@
1
- import jsonpickle
2
- import json
3
-
4
- from kerykeion.types import KerykeionPoint, KerykeionException
5
- from pathlib import Path
6
- from typing import Union, Literal
7
-
8
-
9
- def get_number_from_name(name: str) -> int:
10
- """Utility function, gets planet id from the name."""
11
- name = name.lower()
12
-
13
- if name == "sun":
14
- return 0
15
- elif name == "moon":
16
- return 1
17
- elif name == "mercury":
18
- return 2
19
- elif name == "venus":
20
- return 3
21
- elif name == "mars":
22
- return 4
23
- elif name == "jupiter":
24
- return 5
25
- elif name == "saturn":
26
- return 6
27
- elif name == "uranus":
28
- return 7
29
- elif name == "neptune":
30
- return 8
31
- elif name == "pluto":
32
- return 9
33
- elif name == "mean_node":
34
- return 10 # change!
35
- elif name == "true_node":
36
- return 11
37
- else:
38
- return int(name)
39
-
40
-
41
- def calculate_position(degree: Union[int, float], number_name: str, point_type: Literal["Planet", "House"]) -> KerykeionPoint:
42
- """Utility function to create a dictionary deviding
43
- the houses or the planets list."""
44
-
45
- if degree < 30:
46
- dictionary = {"name": number_name, "quality": "Cardinal", "element":
47
- "Fire", "sign": "Ari", "sign_num": 0, "position": degree, "abs_pos": degree,
48
- "emoji": "♈️", "point_type": point_type}
49
-
50
- elif degree < 60:
51
- result = degree - 30
52
- dictionary = {"name": number_name, "quality": "Fixed", "element":
53
- "Earth", "sign": "Tau", "sign_num": 1, "position": result, "abs_pos": degree,
54
- "emoji": "♉️", "point_type": point_type}
55
- elif degree < 90:
56
- result = degree - 60
57
- dictionary = {"name": number_name, "quality": "Mutable", "element":
58
- "Air", "sign": "Gem", "sign_num": 2, "position": result, "abs_pos": degree,
59
- "emoji": "♊️", "point_type": point_type}
60
- elif degree < 120:
61
- result = degree - 90
62
- dictionary = {"name": number_name, "quality": "Cardinal", "element":
63
- "Water", "sign": "Can", "sign_num": 3, "position": result, "abs_pos": degree,
64
- "emoji": "♋️", "point_type": point_type}
65
- elif degree < 150:
66
- result = degree - 120
67
- dictionary = {"name": number_name, "quality": "Fixed", "element":
68
- "Fire", "sign": "Leo", "sign_num": 4, "position": result, "abs_pos": degree,
69
- "emoji": "♌️", "point_type": point_type}
70
- elif degree < 180:
71
- result = degree - 150
72
- dictionary = {"name": number_name, "quality": "Mutable", "element":
73
- "Earth", "sign": "Vir", "sign_num": 5, "position": result, "abs_pos": degree,
74
- "emoji": "♍️", "point_type": point_type}
75
- elif degree < 210:
76
- result = degree - 180
77
- dictionary = {"name": number_name, "quality": "Cardinal", "element":
78
- "Air", "sign": "Lib", "sign_num": 6, "position": result, "abs_pos": degree,
79
- "emoji": "♎️", "point_type": point_type}
80
- elif degree < 240:
81
- result = degree - 210
82
- dictionary = {"name": number_name, "quality": "Fixed", "element":
83
- "Water", "sign": "Sco", "sign_num": 7, "position": result, "abs_pos": degree,
84
- "emoji": "♏️", "point_type": point_type}
85
- elif degree < 270:
86
- result = degree - 240
87
- dictionary = {"name": number_name, "quality": "Mutable", "element":
88
- "Fire", "sign": "Sag", "sign_num": 8, "position": result, "abs_pos": degree,
89
- "emoji": "♐️", "point_type": point_type}
90
- elif degree < 300:
91
- result = degree - 270
92
- dictionary = {"name": number_name, "quality": "Cardinal", "element":
93
- "Earth", "sign": "Cap", "sign_num": 9, "position": result, "abs_pos": degree,
94
- "emoji": "♑️", "point_type": point_type}
95
- elif degree < 330:
96
- result = degree - 300
97
- dictionary = {"name": number_name, "quality": "Fixed", "element":
98
- "Air", "sign": "Aqu", "sign_num": 10, "position": result, "abs_pos": degree,
99
- "emoji": "♒️", "point_type": point_type}
100
- elif degree < 360:
101
- result = degree - 330
102
- dictionary = {"name": number_name, "quality": "Mutable", "element":
103
- "Water", "sign": "Pis", "sign_num": 11, "position": result, "abs_pos": degree,
104
- "emoji": "♓️", "point_type": point_type}
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
26
+ import math
27
+ import re
28
+ from datetime import datetime
29
+
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")
105
65
  else:
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
+ }
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)
113
+
114
+
115
+
116
+
117
+ def get_number_from_name(name: AstrologicalPoint) -> int:
118
+ """
119
+ Convert an astrological point name to its corresponding numerical identifier.
120
+
121
+ Args:
122
+ name: The name of the astrological point
123
+
124
+ Returns:
125
+ The numerical identifier used in Swiss Ephemeris calculations
126
+
127
+ Raises:
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)
149
+
150
+ Returns:
151
+ A KerykeionPointModel with calculated zodiac sign, position, and properties
152
+
153
+ Raises:
154
+ KerykeionException: If the degree is >= 360 after normalization
155
+ """
156
+ # If - single degree is given, convert it to a positive degree
157
+ if degree < 0:
158
+ degree = degree % 360
159
+
160
+ if degree >= 360:
161
+ raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
162
+
163
+ ZODIAC_SIGNS = {
164
+ 0: ZodiacSignModel(sign="Ari", quality="Cardinal", element="Fire", emoji="♈️", sign_num=0),
165
+ 1: ZodiacSignModel(sign="Tau", quality="Fixed", element="Earth", emoji="♉️", sign_num=1),
166
+ 2: ZodiacSignModel(sign="Gem", quality="Mutable", element="Air", emoji="♊️", sign_num=2),
167
+ 3: ZodiacSignModel(sign="Can", quality="Cardinal", element="Water", emoji="♋️", sign_num=3),
168
+ 4: ZodiacSignModel(sign="Leo", quality="Fixed", element="Fire", emoji="♌️", sign_num=4),
169
+ 5: ZodiacSignModel(sign="Vir", quality="Mutable", element="Earth", emoji="♍️", sign_num=5),
170
+ 6: ZodiacSignModel(sign="Lib", quality="Cardinal", element="Air", emoji="♎️", sign_num=6),
171
+ 7: ZodiacSignModel(sign="Sco", quality="Fixed", element="Water", emoji="♏️", sign_num=7),
172
+ 8: ZodiacSignModel(sign="Sag", quality="Mutable", element="Fire", emoji="♐️", sign_num=8),
173
+ 9: ZodiacSignModel(sign="Cap", quality="Cardinal", element="Earth", emoji="♑️", sign_num=9),
174
+ 10: ZodiacSignModel(sign="Aqu", quality="Fixed", element="Air", emoji="♒️", sign_num=10),
175
+ 11: ZodiacSignModel(sign="Pis", quality="Mutable", element="Water", emoji="♓️", sign_num=11),
176
+ }
177
+
178
+ sign_index = int(degree // 30)
179
+ sign_degree = degree % 30
180
+ zodiac_sign = ZODIAC_SIGNS[sign_index]
181
+ return KerykeionPointModel(
182
+ name=name,
183
+ quality=zodiac_sign.quality,
184
+ element=zodiac_sign.element,
185
+ sign=zodiac_sign.sign,
186
+ sign_num=zodiac_sign.sign_num,
187
+ position=sign_degree,
188
+ abs_pos=degree,
189
+ emoji=zodiac_sign.emoji,
190
+ point_type=point_type,
191
+ speed=speed,
192
+ declination=declination,
193
+ )
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``."""
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:
106
206
  raise KerykeionException(
107
- f'Error in calculating positions! Degrees: {degree}')
207
+ f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
208
+ )
209
+ if target == start:
210
+ return True
211
+ if target == end:
212
+ return False
213
+ distance_from_start = (target - start) % 360
214
+ return distance_from_start < span
215
+
216
+ # House helpers
217
+ def get_planet_house(planet_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
218
+ """
219
+ Determine which house contains a planet based on its degree position.
220
+
221
+ Args:
222
+ planet_degree: The planet's position in degrees (0-360)
223
+ houses_degree_ut_list: List of house cusp degrees
224
+
225
+ Returns:
226
+ The house name containing the planet
227
+
228
+ Raises:
229
+ ValueError: If the planet's position doesn't fall within any house range
230
+ """
231
+
232
+ house_names = get_args(Houses)
233
+
234
+ # Iterate through the house boundaries to find the correct house
235
+ for i in range(len(house_names)):
236
+ start_degree = houses_degree_ut_list[i]
237
+ end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
238
+
239
+ if is_point_between(start_degree, end_degree, planet_degree):
240
+ return house_names[i]
241
+
242
+ # If no house is found, raise an error
243
+ raise ValueError(f"Error in house calculation, planet: {planet_degree}, houses: {houses_degree_ut_list}")
244
+
245
+
246
+ def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
247
+ """
248
+ Get the emoji representation of a lunar phase.
249
+
250
+ Args:
251
+ phase: The lunar phase number (0-28)
252
+
253
+ Returns:
254
+ The corresponding emoji for the lunar phase
255
+
256
+ Raises:
257
+ KerykeionException: If phase is outside valid range
258
+ """
259
+
260
+ lunar_phase_emojis = get_args(LunarPhaseEmoji)
261
+
262
+ if phase == 1:
263
+ result = lunar_phase_emojis[0]
264
+ elif phase < 7:
265
+ result = lunar_phase_emojis[1]
266
+ elif 7 <= phase <= 9:
267
+ result = lunar_phase_emojis[2]
268
+ elif phase < 14:
269
+ result = lunar_phase_emojis[3]
270
+ elif phase == 14:
271
+ result = lunar_phase_emojis[4]
272
+ elif phase < 20:
273
+ result = lunar_phase_emojis[5]
274
+ elif 20 <= phase <= 22:
275
+ result = lunar_phase_emojis[6]
276
+ elif phase <= 28:
277
+ result = lunar_phase_emojis[7]
278
+
279
+ else:
280
+ raise KerykeionException(f"Error in moon emoji calculation! Phase: {phase}")
281
+
282
+ return result
283
+
284
+
285
+ def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
286
+ """
287
+ Get the name of a lunar phase from its numerical value.
288
+
289
+ Args:
290
+ phase: The lunar phase number (0-28)
291
+
292
+ Returns:
293
+ The corresponding name for the lunar phase
294
+
295
+ Raises:
296
+ KerykeionException: If phase is outside valid range
297
+ """
298
+ lunar_phase_names = get_args(LunarPhaseName)
299
+
300
+ if phase == 1:
301
+ result = lunar_phase_names[0]
302
+ elif phase < 7:
303
+ result = lunar_phase_names[1]
304
+ elif 7 <= phase <= 9:
305
+ result = lunar_phase_names[2]
306
+ elif phase < 14:
307
+ result = lunar_phase_names[3]
308
+ elif phase == 14:
309
+ result = lunar_phase_names[4]
310
+ elif phase < 20:
311
+ result = lunar_phase_names[5]
312
+ elif 20 <= phase <= 22:
313
+ result = lunar_phase_names[6]
314
+ elif phase <= 28:
315
+ result = lunar_phase_names[7]
316
+
317
+ else:
318
+ raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
319
+
320
+ return result
321
+
322
+
323
+ def check_and_adjust_polar_latitude(latitude: float) -> float:
324
+ """
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°
334
+ """
335
+ if latitude > 66.0:
336
+ latitude = 66.0
337
+ logger.info("Latitude capped at 66° to keep house calculations stable.")
338
+
339
+ elif latitude < -66.0:
340
+ latitude = -66.0
341
+ logger.info("Latitude capped at -66° to keep house calculations stable.")
342
+
343
+ return latitude
344
+
345
+
346
+ def get_houses_list(
347
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
348
+ ) -> list[KerykeionPointModel]:
349
+ """
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
357
+ """
358
+ houses_absolute_position_list = []
359
+ for house in subject.houses_names_list:
360
+ houses_absolute_position_list.append(subject[house.lower()])
361
+
362
+ return houses_absolute_position_list
363
+
364
+
365
+ def get_available_astrological_points_list(
366
+ subject: AstrologicalSubjectModel
367
+ ) -> list[KerykeionPointModel]:
368
+ """
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
376
+ """
377
+ planets_absolute_position_list = []
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.
387
+
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
108
405
 
109
- return KerykeionPoint(**dictionary)
406
+ return mean_position
110
407
 
111
408
 
112
- def dangerous_json_dump(subject, dump=True, new_output_directory=None):
409
+ def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
113
410
  """
114
- Dumps the Kerykeion object to a json file located in the home folder.
115
- This json file allows the object to be recreated with jsonpickle.
116
- It's dangerous since it contains local system information.
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):
117
516
  """
517
+ Replace CSS variable references with their actual values.
118
518
 
119
- OUTPUT_DIR = Path.home()
519
+ Args:
520
+ match: Regular expression match object containing variable name and optional fallback.
120
521
 
121
- try:
122
- subject.sun
123
- except:
124
- subject.__get_all()
125
-
126
- if new_output_directory:
127
- output_directory_path = Path(new_output_directory)
128
- json_dir = new_output_directory / \
129
- f"{subject.name}_kerykeion.json"
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
130
605
  else:
131
- json_dir = f"{subject.name}_kerykeion.json"
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)
132
617
 
133
- json_string = jsonpickle.encode(subject)
618
+ # Calculate E
619
+ E = int((B - D) / 30.6001)
134
620
 
135
- if dump:
136
- json_string = json.loads(json_string.replace(
137
- "'", '"')) # type: ignore TODO: Fix this
621
+ # Calculate day and month
622
+ day = B - D - int(30.6001 * E) + F
138
623
 
139
- with open(json_dir, "w", encoding="utf-8") as file:
140
- json.dump(json_string, file, indent=4, sort_keys=True)
141
- subject.__logger.info(f"JSON file dumped in {json_dir}.")
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
142
643
  else:
143
- pass
144
- return json_string
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