kerykeion 5.0.1__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.

kerykeion/utilities.py CHANGED
@@ -12,15 +12,108 @@ from kerykeion.schemas import (
12
12
  LunarPhaseModel,
13
13
  CompositeSubjectModel,
14
14
  PlanetReturnModel,
15
+ ZodiacType,
15
16
  )
16
- from kerykeion.schemas.kr_literals import LunarPhaseEmoji, LunarPhaseName, PointType, AstrologicalPoint, Houses
17
- from typing import Union, Optional, get_args
18
- import logging
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
19
26
  import math
20
27
  import re
21
28
  from datetime import datetime
22
29
 
23
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")
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
+
24
117
  def get_number_from_name(name: AstrologicalPoint) -> int:
25
118
  """
26
119
  Convert an astrological point name to its corresponding numerical identifier.
@@ -35,49 +128,10 @@ def get_number_from_name(name: AstrologicalPoint) -> int:
35
128
  KerykeionException: If the name is not recognized
36
129
  """
37
130
 
38
- if name == "Sun":
39
- return 0
40
- elif name == "Moon":
41
- return 1
42
- elif name == "Mercury":
43
- return 2
44
- elif name == "Venus":
45
- return 3
46
- elif name == "Mars":
47
- return 4
48
- elif name == "Jupiter":
49
- return 5
50
- elif name == "Saturn":
51
- return 6
52
- elif name == "Uranus":
53
- return 7
54
- elif name == "Neptune":
55
- return 8
56
- elif name == "Pluto":
57
- return 9
58
- elif name == "Mean_North_Lunar_Node":
59
- return 10
60
- elif name == "True_North_Lunar_Node":
61
- return 11
62
- # Note: Swiss ephemeris library has no constants for south nodes. We're using integers >= 1000 for them.
63
- elif name == "Mean_South_Lunar_Node":
64
- return 1000
65
- elif name == "True_South_Lunar_Node":
66
- return 1100
67
- elif name == "Chiron":
68
- return 15
69
- elif name == "Mean_Lilith":
70
- return 12
71
- elif name == "Ascendant": # TODO: Is this needed?
72
- return 9900
73
- elif name == "Descendant": # TODO: Is this needed?
74
- return 9901
75
- elif name == "Medium_Coeli": # TODO: Is this needed?
76
- return 9902
77
- elif name == "Imum_Coeli": # TODO: Is this needed?
78
- return 9903
79
- else:
80
- raise KerykeionException(f"Error in getting number from name! Name: {name}")
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
81
135
 
82
136
 
83
137
  def get_kerykeion_point_from_degree(
@@ -124,7 +178,6 @@ def get_kerykeion_point_from_degree(
124
178
  sign_index = int(degree // 30)
125
179
  sign_degree = degree % 30
126
180
  zodiac_sign = ZODIAC_SIGNS[sign_index]
127
-
128
181
  return KerykeionPointModel(
129
182
  name=name,
130
183
  quality=zodiac_sign.quality,
@@ -139,87 +192,34 @@ def get_kerykeion_point_from_degree(
139
192
  declination=declination,
140
193
  )
141
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``."""
142
198
 
143
- def setup_logging(level: str) -> None:
144
- """
145
- Configure logging for the application.
146
-
147
- Args:
148
- level: Log level as string (debug, info, warning, error, critical)
149
- """
150
- logging_options: dict[str, int] = {
151
- "debug": logging.DEBUG,
152
- "info": logging.INFO,
153
- "warning": logging.WARNING,
154
- "error": logging.ERROR,
155
- "critical": logging.CRITICAL,
156
- }
157
- format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
158
- loglevel: int = logging_options.get(level, logging.INFO)
159
- logging.basicConfig(format=format, level=loglevel)
160
-
199
+ normalize = lambda value: value % 360
161
200
 
162
- def is_point_between(
163
- start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]
164
- ) -> bool:
165
- """
166
- Determine if a point lies between two other points on a circle.
167
-
168
- Special rules:
169
- - If evaluated_point equals start_point, returns True
170
- - If evaluated_point equals end_point, returns False
171
- - The arc between start_point and end_point must not exceed 180°
172
-
173
- Args:
174
- start_point: The starting point on the circle
175
- end_point: The ending point on the circle
176
- evaluated_point: The point to evaluate
177
-
178
- Returns:
179
- True if evaluated_point is between start_point and end_point, False otherwise
180
-
181
- Raises:
182
- KerykeionException: If the angular difference exceeds 180°
183
- """
184
-
185
- # Normalize angles to [0, 360)
186
- start_point = start_point % 360
187
- end_point = end_point % 360
188
- evaluated_point = evaluated_point % 360
189
-
190
- # Compute angular difference
191
- angular_difference = math.fmod(end_point - start_point + 360, 360)
192
-
193
- # Ensure the range is not greater than 180°. Otherwise, it is not truly defined what
194
- # being located in between two points on a circle actually means.
195
- 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:
196
206
  raise KerykeionException(
197
- 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}"
198
208
  )
199
-
200
- # Handle explicitly when evaluated_point == start_point. Note: It may happen for mathematical
201
- # reasons that evaluated_point and start_point deviate very slightly from each other, but
202
- # should really be same value. This case is captured later below by the term 0 <= p1_p3.
203
- if evaluated_point == start_point:
209
+ if target == start:
204
210
  return True
205
-
206
- # Handle explicitly when evaluated_point == end_point
207
- if evaluated_point == end_point:
211
+ if target == end:
208
212
  return False
213
+ distance_from_start = (target - start) % 360
214
+ return distance_from_start < span
209
215
 
210
- # Compute angular differences for evaluation
211
- p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
212
-
213
- # Check if point lies in the interval
214
- return (0 <= p1_p3) and (p1_p3 < angular_difference)
215
-
216
-
217
- 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:
218
218
  """
219
219
  Determine which house contains a planet based on its degree position.
220
220
 
221
221
  Args:
222
- planet_position_degree: The planet's position in degrees (0-360)
222
+ planet_degree: The planet's position in degrees (0-360)
223
223
  houses_degree_ut_list: List of house cusp degrees
224
224
 
225
225
  Returns:
@@ -236,11 +236,11 @@ def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut
236
236
  start_degree = houses_degree_ut_list[i]
237
237
  end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
238
238
 
239
- if is_point_between(start_degree, end_degree, planet_position_degree):
239
+ if is_point_between(start_degree, end_degree, planet_degree):
240
240
  return house_names[i]
241
241
 
242
242
  # If no house is found, raise an error
243
- 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}")
244
244
 
245
245
 
246
246
  def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
@@ -334,11 +334,11 @@ def check_and_adjust_polar_latitude(latitude: float) -> float:
334
334
  """
335
335
  if latitude > 66.0:
336
336
  latitude = 66.0
337
- logging.info("Polar circle override for houses, using 66 degrees")
337
+ logger.info("Latitude capped at 66° to keep house calculations stable.")
338
338
 
339
339
  elif latitude < -66.0:
340
340
  latitude = -66.0
341
- logging.info("Polar circle override for houses, using -66 degrees")
341
+ logger.info("Latitude capped at -66° to keep house calculations stable.")
342
342
 
343
343
  return latitude
344
344