kerykeion.utilities

  1from kerykeion.kr_types import KerykeionPointModel, KerykeionException, ZodiacSignModel, AstrologicalSubjectModel, LunarPhaseModel
  2from kerykeion.kr_types.kr_literals import LunarPhaseEmoji, LunarPhaseName, PointType, Planet, Houses, AxialCusps
  3from typing import Union, get_args, TYPE_CHECKING
  4import logging
  5import math
  6import re
  7
  8if TYPE_CHECKING:
  9    from kerykeion import AstrologicalSubject
 10
 11
 12def get_number_from_name(name: Planet) -> int:
 13    """Utility function, gets planet id from the name."""
 14
 15    if name == "Sun":
 16        return 0
 17    elif name == "Moon":
 18        return 1
 19    elif name == "Mercury":
 20        return 2
 21    elif name == "Venus":
 22        return 3
 23    elif name == "Mars":
 24        return 4
 25    elif name == "Jupiter":
 26        return 5
 27    elif name == "Saturn":
 28        return 6
 29    elif name == "Uranus":
 30        return 7
 31    elif name == "Neptune":
 32        return 8
 33    elif name == "Pluto":
 34        return 9
 35    elif name == "Mean_Node":
 36        return 10
 37    elif name == "True_Node":
 38        return 11
 39    # Note: Swiss ephemeris library has no constants for south nodes. We're using integers >= 1000 for them.
 40    elif name == "Mean_South_Node":
 41        return 1000
 42    elif name == "True_South_Node":
 43        return 1100
 44    elif name == "Chiron":
 45        return 15
 46    elif name == "Mean_Lilith":
 47        return 12
 48    elif name == "Ascendant": # TODO: Is this needed?
 49        return 9900
 50    elif name == "Descendant": # TODO: Is this needed?
 51        return 9901
 52    elif name == "Medium_Coeli": # TODO: Is this needed?
 53        return 9902
 54    elif name == "Imum_Coeli": # TODO: Is this needed?
 55        return 9903
 56    else:
 57        raise KerykeionException(f"Error in getting number from name! Name: {name}")
 58
 59
 60def get_kerykeion_point_from_degree(
 61    degree: Union[int, float], name: Union[Planet, Houses, AxialCusps], point_type: PointType
 62) -> KerykeionPointModel:
 63    """
 64    Returns a KerykeionPointModel object based on the given degree.
 65
 66    Args:
 67        degree (Union[int, float]): The degree of the celestial point.
 68        name (str): The name of the celestial point.
 69        point_type (PointType): The type of the celestial point.
 70
 71    Raises:
 72        KerykeionException: If the degree is not within the valid range (0-360).
 73
 74    Returns:
 75        KerykeionPointModel: The model representing the celestial point.
 76    """
 77
 78    if degree < 0 or degree >= 360:
 79        raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
 80
 81    ZODIAC_SIGNS = {
 82        0: ZodiacSignModel(sign="Ari", quality="Cardinal", element="Fire", emoji="♈️", sign_num=0),
 83        1: ZodiacSignModel(sign="Tau", quality="Fixed", element="Earth", emoji="♉️", sign_num=1),
 84        2: ZodiacSignModel(sign="Gem", quality="Mutable", element="Air", emoji="♊️", sign_num=2),
 85        3: ZodiacSignModel(sign="Can", quality="Cardinal", element="Water", emoji="♋️", sign_num=3),
 86        4: ZodiacSignModel(sign="Leo", quality="Fixed", element="Fire", emoji="♌️", sign_num=4),
 87        5: ZodiacSignModel(sign="Vir", quality="Mutable", element="Earth", emoji="♍️", sign_num=5),
 88        6: ZodiacSignModel(sign="Lib", quality="Cardinal", element="Air", emoji="♎️", sign_num=6),
 89        7: ZodiacSignModel(sign="Sco", quality="Fixed", element="Water", emoji="♏️", sign_num=7),
 90        8: ZodiacSignModel(sign="Sag", quality="Mutable", element="Fire", emoji="♐️", sign_num=8),
 91        9: ZodiacSignModel(sign="Cap", quality="Cardinal", element="Earth", emoji="♑️", sign_num=9),
 92        10: ZodiacSignModel(sign="Aqu", quality="Fixed", element="Air", emoji="♒️", sign_num=10),
 93        11: ZodiacSignModel(sign="Pis", quality="Mutable", element="Water", emoji="♓️", sign_num=11),
 94    }
 95
 96    sign_index = int(degree // 30)
 97    sign_degree = degree % 30
 98    zodiac_sign = ZODIAC_SIGNS[sign_index]
 99
100    return KerykeionPointModel(
101        name=name,
102        quality=zodiac_sign.quality,
103        element=zodiac_sign.element,
104        sign=zodiac_sign.sign,
105        sign_num=zodiac_sign.sign_num,
106        position=sign_degree,
107        abs_pos=degree,
108        emoji=zodiac_sign.emoji,
109        point_type=point_type,
110    )
111
112def setup_logging(level: str) -> None:
113    """
114    Setup logging for testing.
115
116    Args:
117        level: Log level as a string, options: debug, info, warning, error
118    """
119    logging_options: dict[str, int] = {
120        "debug": logging.DEBUG,
121        "info": logging.INFO,
122        "warning": logging.WARNING,
123        "error": logging.ERROR,
124        "critical": logging.CRITICAL,
125    }
126    format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
127    loglevel: int = logging_options.get(level, logging.INFO)
128    logging.basicConfig(format=format, level=loglevel)
129
130
131def is_point_between(
132    start_point: Union[int, float],
133    end_point: Union[int, float],
134    evaluated_point: Union[int, float]
135) -> bool:
136    """
137    Determines if a point is between two others on a circle, with additional rules:
138    - If evaluated_point == start_point, it is considered between.
139    - If evaluated_point == end_point, it is NOT considered between.
140    - The range between start_point and end_point must not exceed 180°.
141
142    Args:
143        - start_point: The first point on the circle.
144        - end_point: The second point on the circle.
145        - evaluated_point: The point to check.
146
147    Returns:
148        - True if evaluated_point is between start_point and end_point, False otherwise.
149    """
150
151    # Normalize angles to [0, 360)
152    start_point = start_point % 360
153    end_point = end_point % 360
154    evaluated_point = evaluated_point % 360
155
156    # Compute angular difference
157    angular_difference = math.fmod(end_point - start_point + 360, 360)
158
159    # Ensure the range is not greater than 180°. Otherwise, it is not truly defined what
160    # being located in between two points on a circle actually means.
161    if angular_difference > 180:
162        raise KerykeionException(f"The angle between start and end point is not allowed to exceed 180°, yet is: {angular_difference}")
163
164    # Handle explicitly when evaluated_point == start_point. Note: It may happen for mathematical
165    # reasons that evaluated_point and start_point deviate very slightly from each other, but
166    # should really be same value. This case is captured later below by the term 0 <= p1_p3.
167    if evaluated_point == start_point:
168        return True
169
170    # Handle explicitly when evaluated_point == end_point
171    if evaluated_point == end_point:
172        return False
173
174    # Compute angular differences for evaluation
175    p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
176
177    # Check if point lies in the interval
178    return (0 <= p1_p3) and (p1_p3 < angular_difference)
179
180
181
182def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
183    """
184    Determines the house in which a planet is located based on its position in degrees.
185
186    Args:
187        planet_position_degree (Union[int, float]): The position of the planet in degrees.
188        houses_degree_ut_list (list): A list of the houses in degrees (0-360).
189
190    Returns:
191        str: The house in which the planet is located.
192
193    Raises:
194        ValueError: If the planet's position does not fall within any house range.
195    """
196
197    house_names = get_args(Houses)
198
199    # Iterate through the house boundaries to find the correct house
200    for i in range(len(house_names)):
201        start_degree = houses_degree_ut_list[i]
202        end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
203
204        if is_point_between(start_degree, end_degree, planet_position_degree):
205            return house_names[i]
206
207    # If no house is found, raise an error
208    raise ValueError(f"Error in house calculation, planet: {planet_position_degree}, houses: {houses_degree_ut_list}")
209
210
211def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
212    """
213    Returns the emoji of the moon phase.
214
215    Args:
216        - phase: The phase of the moon (0-28)
217
218    Returns:
219        - The emoji of the moon phase
220    """
221
222    lunar_phase_emojis = get_args(LunarPhaseEmoji)
223
224    if phase == 1:
225        result = lunar_phase_emojis[0]
226    elif phase < 7:
227        result = lunar_phase_emojis[1]
228    elif 7 <= phase <= 9:
229        result = lunar_phase_emojis[2]
230    elif phase < 14:
231        result = lunar_phase_emojis[3]
232    elif phase == 14:
233        result = lunar_phase_emojis[4]
234    elif phase < 20:
235        result = lunar_phase_emojis[5]
236    elif 20 <= phase <= 22:
237        result = lunar_phase_emojis[6]
238    elif phase <= 28:
239        result = lunar_phase_emojis[7]
240
241    else:
242        raise KerykeionException(f"Error in moon emoji calculation! Phase: {phase}")
243
244    return result
245
246def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
247    """
248    Returns the name of the moon phase.
249
250    Args:
251        - phase: The phase of the moon (0-28)
252
253    Returns:
254        - The name of the moon phase
255    """
256    lunar_phase_names = get_args(LunarPhaseName)
257
258
259    if phase == 1:
260        result = lunar_phase_names[0]
261    elif phase < 7:
262        result =  lunar_phase_names[1]
263    elif 7 <= phase <= 9:
264        result = lunar_phase_names[2]
265    elif phase < 14:
266        result = lunar_phase_names[3]
267    elif phase == 14:
268        result = lunar_phase_names[4]
269    elif phase < 20:
270        result = lunar_phase_names[5]
271    elif 20 <= phase <= 22:
272        result = lunar_phase_names[6]
273    elif phase <= 28:
274        result = lunar_phase_names[7]
275
276    else:
277        raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
278
279    return result
280
281
282def check_and_adjust_polar_latitude(latitude: float) -> float:
283    """
284        Utility function to check if the location is in the polar circle.
285        If it is, it sets the latitude to 66 or -66 degrees.
286    """
287    if latitude > 66.0:
288        latitude = 66.0
289        logging.info("Polar circle override for houses, using 66 degrees")
290
291    elif latitude < -66.0:
292        latitude = -66.0
293        logging.info("Polar circle override for houses, using -66 degrees")
294
295    return latitude
296
297
298def get_houses_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
299    """
300    Return the names of the houses in the order of the houses.
301    """
302    houses_absolute_position_list = []
303    for house in subject.houses_names_list:
304            houses_absolute_position_list.append(subject[house.lower()])
305
306    return houses_absolute_position_list
307
308
309def get_available_astrological_points_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
310    """
311    Return the names of the planets in the order of the planets.
312    The names can be used to access the planets from the AstrologicalSubject object with the __getitem__ method or the [] operator.
313    """
314    planets_absolute_position_list = []
315    for planet in subject.planets_names_list:
316            planets_absolute_position_list.append(subject[planet.lower()])
317
318    for axis in subject.axial_cusps_names_list:
319        planets_absolute_position_list.append(subject[axis.lower()])
320
321    return planets_absolute_position_list
322
323
324def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
325    """
326    Computes the circular mean of two astrological positions (e.g., house cusps, planets).
327
328    This function ensures that positions crossing 0° Aries (360°) are correctly averaged,
329    avoiding errors that occur with simple linear means.
330
331    Args:
332        position1 (Union[int, float]): First position in degrees (0-360).
333        position2 (Union[int, float]): Second position in degrees (0-360).
334
335    Returns:
336        float: The circular mean position in degrees (0-360).
337    """
338    x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
339    y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
340    mean_position = math.degrees(math.atan2(y, x))
341
342    # Ensure the result is within 0-360°
343    if mean_position < 0:
344        mean_position += 360
345
346    return mean_position
347
348
349def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
350    """
351    Calculate the lunar phase based on the positions of the moon and sun.
352
353    Args:
354    - moon_abs_pos (float): The absolute position of the moon.
355    - sun_abs_pos (float): The absolute position of the sun.
356
357    Returns:
358    - dict: A dictionary containing the lunar phase information.
359    """
360    # Initialize moon_phase and sun_phase to None in case of an error
361    moon_phase, sun_phase = None, None
362
363    # Calculate the anti-clockwise degrees between the sun and moon
364    degrees_between = (moon_abs_pos - sun_abs_pos) % 360
365
366    # Calculate the moon phase (1-28) based on the degrees between the sun and moon
367    step = 360.0 / 28.0
368    moon_phase = int(degrees_between // step) + 1
369
370    # Define the sun phase steps
371    sunstep = [
372        0, 30, 40, 50, 60, 70, 80, 90, 120, 130, 140, 150, 160, 170, 180,
373        210, 220, 230, 240, 250, 260, 270, 300, 310, 320, 330, 340, 350
374    ]
375
376    # Calculate the sun phase (1-28) based on the degrees between the sun and moon
377    for x in range(len(sunstep)):
378        low = sunstep[x]
379        high = sunstep[x + 1] if x < len(sunstep) - 1 else 360
380        if low <= degrees_between < high:
381            sun_phase = x + 1
382            break
383
384    # Create a dictionary with the lunar phase information
385    lunar_phase_dictionary = {
386        "degrees_between_s_m": degrees_between,
387        "moon_phase": moon_phase,
388        "sun_phase": sun_phase,
389        "moon_emoji": get_moon_emoji_from_phase_int(moon_phase),
390        "moon_phase_name": get_moon_phase_name_from_phase_int(moon_phase)
391    }
392
393    return LunarPhaseModel(**lunar_phase_dictionary)
394
395
396def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
397    """
398    Sort a list of degrees in a circular manner, starting from the first element
399    and progressing clockwise around the circle.
400
401    Args:
402        degrees: A list of numeric values representing degrees
403
404    Returns:
405        A list sorted based on circular clockwise progression from the first element
406
407    Raises:
408        ValueError: If the list is empty or contains non-numeric values
409    """
410    # Input validation
411    if not degrees:
412        raise ValueError("Input list cannot be empty")
413
414    if not all(isinstance(degree, (int, float)) for degree in degrees):
415        invalid = next(d for d in degrees if not isinstance(d, (int, float)))
416        raise ValueError(f"All elements must be numeric, found: {invalid} of type {type(invalid).__name__}")
417
418    # If list has 0 or 1 element, return it as is
419    if len(degrees) <= 1:
420        return degrees.copy()
421
422    # Save the first element as the reference
423    reference = degrees[0]
424
425    # Define a function to calculate clockwise distance from reference
426    def clockwise_distance(angle: Union[int, float]) -> Union[int, float]:
427        # Normalize angles to 0-360 range
428        ref_norm = reference % 360
429        angle_norm = angle % 360
430
431        # Calculate clockwise distance
432        distance = angle_norm - ref_norm
433        if distance < 0:
434            distance += 360
435
436        return distance
437
438    # Sort the rest of the elements based on circular distance
439    remaining = degrees[1:]
440    sorted_remaining = sorted(remaining, key=clockwise_distance)
441
442    # Return the reference followed by the sorted remaining elements
443    return [reference] + sorted_remaining
444
445
446def inline_css_variables_in_svg(svg_content: str) -> str:
447    """
448    Process an SVG string to inline all CSS custom properties.
449
450    Args:
451        svg_content (str): The original SVG string with CSS variables
452
453    Returns:
454        str: The modified SVG with all CSS variables replaced by their values
455             and all style blocks removed
456    """
457    # Find and extract CSS custom properties from style tags
458    css_variable_map = {}
459    style_tag_pattern = re.compile(r"<style.*?>(.*?)</style>", re.DOTALL)
460    style_blocks = style_tag_pattern.findall(svg_content)
461
462    # Parse all CSS custom properties from style blocks
463    for style_block in style_blocks:
464        # Match patterns like --color-primary: #ff0000;
465        css_variable_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
466        for match in css_variable_pattern.finditer(style_block):
467            variable_name = match.group(1)
468            variable_value = match.group(2).strip()
469            css_variable_map[f"--{variable_name}"] = variable_value
470
471    # Remove all style blocks from the SVG
472    svg_without_style_blocks = style_tag_pattern.sub("", svg_content)
473
474    # Function to replace var() references with their actual values
475    def replace_css_variable_reference(match):
476        variable_name = match.group(1).strip()
477        fallback_value = match.group(2) if match.group(2) else None
478
479        if variable_name in css_variable_map:
480            return css_variable_map[variable_name]
481        elif fallback_value:
482            return fallback_value.strip(", ")
483        else:
484            return ""  # If variable not found and no fallback provided
485
486    # Pattern to match var(--name) or var(--name, fallback)
487    variable_usage_pattern = re.compile(r"var\(\s*(--([\w-]+))\s*(,\s*([^)]+))?\s*\)")
488
489    # Repeatedly replace all var() references until none remain
490    # This handles nested variables or variables that reference other variables
491    processed_svg = svg_without_style_blocks
492    while variable_usage_pattern.search(processed_svg):
493        processed_svg = variable_usage_pattern.sub(
494            lambda m: replace_css_variable_reference(m), processed_svg
495        )
496
497    return processed_svg
def get_number_from_name( name: Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith']) -> int:
13def get_number_from_name(name: Planet) -> int:
14    """Utility function, gets planet id from the name."""
15
16    if name == "Sun":
17        return 0
18    elif name == "Moon":
19        return 1
20    elif name == "Mercury":
21        return 2
22    elif name == "Venus":
23        return 3
24    elif name == "Mars":
25        return 4
26    elif name == "Jupiter":
27        return 5
28    elif name == "Saturn":
29        return 6
30    elif name == "Uranus":
31        return 7
32    elif name == "Neptune":
33        return 8
34    elif name == "Pluto":
35        return 9
36    elif name == "Mean_Node":
37        return 10
38    elif name == "True_Node":
39        return 11
40    # Note: Swiss ephemeris library has no constants for south nodes. We're using integers >= 1000 for them.
41    elif name == "Mean_South_Node":
42        return 1000
43    elif name == "True_South_Node":
44        return 1100
45    elif name == "Chiron":
46        return 15
47    elif name == "Mean_Lilith":
48        return 12
49    elif name == "Ascendant": # TODO: Is this needed?
50        return 9900
51    elif name == "Descendant": # TODO: Is this needed?
52        return 9901
53    elif name == "Medium_Coeli": # TODO: Is this needed?
54        return 9902
55    elif name == "Imum_Coeli": # TODO: Is this needed?
56        return 9903
57    else:
58        raise KerykeionException(f"Error in getting number from name! Name: {name}")

Utility function, gets planet id from the name.

def get_kerykeion_point_from_degree( degree: Union[int, float], name: Union[Literal['Sun', 'Moon', 'Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Mean_Node', 'True_Node', 'Mean_South_Node', 'True_South_Node', 'Chiron', 'Mean_Lilith'], Literal['First_House', 'Second_House', 'Third_House', 'Fourth_House', 'Fifth_House', 'Sixth_House', 'Seventh_House', 'Eighth_House', 'Ninth_House', 'Tenth_House', 'Eleventh_House', 'Twelfth_House'], Literal['Ascendant', 'Medium_Coeli', 'Descendant', 'Imum_Coeli']], point_type: Literal['Planet', 'House', 'AxialCusps']) -> kerykeion.kr_types.kr_models.KerykeionPointModel:
 61def get_kerykeion_point_from_degree(
 62    degree: Union[int, float], name: Union[Planet, Houses, AxialCusps], point_type: PointType
 63) -> KerykeionPointModel:
 64    """
 65    Returns a KerykeionPointModel object based on the given degree.
 66
 67    Args:
 68        degree (Union[int, float]): The degree of the celestial point.
 69        name (str): The name of the celestial point.
 70        point_type (PointType): The type of the celestial point.
 71
 72    Raises:
 73        KerykeionException: If the degree is not within the valid range (0-360).
 74
 75    Returns:
 76        KerykeionPointModel: The model representing the celestial point.
 77    """
 78
 79    if degree < 0 or degree >= 360:
 80        raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
 81
 82    ZODIAC_SIGNS = {
 83        0: ZodiacSignModel(sign="Ari", quality="Cardinal", element="Fire", emoji="♈️", sign_num=0),
 84        1: ZodiacSignModel(sign="Tau", quality="Fixed", element="Earth", emoji="♉️", sign_num=1),
 85        2: ZodiacSignModel(sign="Gem", quality="Mutable", element="Air", emoji="♊️", sign_num=2),
 86        3: ZodiacSignModel(sign="Can", quality="Cardinal", element="Water", emoji="♋️", sign_num=3),
 87        4: ZodiacSignModel(sign="Leo", quality="Fixed", element="Fire", emoji="♌️", sign_num=4),
 88        5: ZodiacSignModel(sign="Vir", quality="Mutable", element="Earth", emoji="♍️", sign_num=5),
 89        6: ZodiacSignModel(sign="Lib", quality="Cardinal", element="Air", emoji="♎️", sign_num=6),
 90        7: ZodiacSignModel(sign="Sco", quality="Fixed", element="Water", emoji="♏️", sign_num=7),
 91        8: ZodiacSignModel(sign="Sag", quality="Mutable", element="Fire", emoji="♐️", sign_num=8),
 92        9: ZodiacSignModel(sign="Cap", quality="Cardinal", element="Earth", emoji="♑️", sign_num=9),
 93        10: ZodiacSignModel(sign="Aqu", quality="Fixed", element="Air", emoji="♒️", sign_num=10),
 94        11: ZodiacSignModel(sign="Pis", quality="Mutable", element="Water", emoji="♓️", sign_num=11),
 95    }
 96
 97    sign_index = int(degree // 30)
 98    sign_degree = degree % 30
 99    zodiac_sign = ZODIAC_SIGNS[sign_index]
100
101    return KerykeionPointModel(
102        name=name,
103        quality=zodiac_sign.quality,
104        element=zodiac_sign.element,
105        sign=zodiac_sign.sign,
106        sign_num=zodiac_sign.sign_num,
107        position=sign_degree,
108        abs_pos=degree,
109        emoji=zodiac_sign.emoji,
110        point_type=point_type,
111    )

Returns a KerykeionPointModel object based on the given degree.

Args: degree (Union[int, float]): The degree of the celestial point. name (str): The name of the celestial point. point_type (PointType): The type of the celestial point.

Raises: KerykeionException: If the degree is not within the valid range (0-360).

Returns: KerykeionPointModel: The model representing the celestial point.

def setup_logging(level: str) -> None:
113def setup_logging(level: str) -> None:
114    """
115    Setup logging for testing.
116
117    Args:
118        level: Log level as a string, options: debug, info, warning, error
119    """
120    logging_options: dict[str, int] = {
121        "debug": logging.DEBUG,
122        "info": logging.INFO,
123        "warning": logging.WARNING,
124        "error": logging.ERROR,
125        "critical": logging.CRITICAL,
126    }
127    format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
128    loglevel: int = logging_options.get(level, logging.INFO)
129    logging.basicConfig(format=format, level=loglevel)

Setup logging for testing.

Args: level: Log level as a string, options: debug, info, warning, error

def is_point_between( start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]) -> bool:
132def is_point_between(
133    start_point: Union[int, float],
134    end_point: Union[int, float],
135    evaluated_point: Union[int, float]
136) -> bool:
137    """
138    Determines if a point is between two others on a circle, with additional rules:
139    - If evaluated_point == start_point, it is considered between.
140    - If evaluated_point == end_point, it is NOT considered between.
141    - The range between start_point and end_point must not exceed 180°.
142
143    Args:
144        - start_point: The first point on the circle.
145        - end_point: The second point on the circle.
146        - evaluated_point: The point to check.
147
148    Returns:
149        - True if evaluated_point is between start_point and end_point, False otherwise.
150    """
151
152    # Normalize angles to [0, 360)
153    start_point = start_point % 360
154    end_point = end_point % 360
155    evaluated_point = evaluated_point % 360
156
157    # Compute angular difference
158    angular_difference = math.fmod(end_point - start_point + 360, 360)
159
160    # Ensure the range is not greater than 180°. Otherwise, it is not truly defined what
161    # being located in between two points on a circle actually means.
162    if angular_difference > 180:
163        raise KerykeionException(f"The angle between start and end point is not allowed to exceed 180°, yet is: {angular_difference}")
164
165    # Handle explicitly when evaluated_point == start_point. Note: It may happen for mathematical
166    # reasons that evaluated_point and start_point deviate very slightly from each other, but
167    # should really be same value. This case is captured later below by the term 0 <= p1_p3.
168    if evaluated_point == start_point:
169        return True
170
171    # Handle explicitly when evaluated_point == end_point
172    if evaluated_point == end_point:
173        return False
174
175    # Compute angular differences for evaluation
176    p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
177
178    # Check if point lies in the interval
179    return (0 <= p1_p3) and (p1_p3 < angular_difference)

Determines if a point is between two others on a circle, with additional rules:

  • If evaluated_point == start_point, it is considered between.
  • If evaluated_point == end_point, it is NOT considered between.
  • The range between start_point and end_point must not exceed 180°.

Args: - start_point: The first point on the circle. - end_point: The second point on the circle. - evaluated_point: The point to check.

Returns: - True if evaluated_point is between start_point and end_point, False otherwise.

def get_planet_house( planet_position_degree: Union[int, float], houses_degree_ut_list: list) -> Literal['First_House', 'Second_House', 'Third_House', 'Fourth_House', 'Fifth_House', 'Sixth_House', 'Seventh_House', 'Eighth_House', 'Ninth_House', 'Tenth_House', 'Eleventh_House', 'Twelfth_House']:
183def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
184    """
185    Determines the house in which a planet is located based on its position in degrees.
186
187    Args:
188        planet_position_degree (Union[int, float]): The position of the planet in degrees.
189        houses_degree_ut_list (list): A list of the houses in degrees (0-360).
190
191    Returns:
192        str: The house in which the planet is located.
193
194    Raises:
195        ValueError: If the planet's position does not fall within any house range.
196    """
197
198    house_names = get_args(Houses)
199
200    # Iterate through the house boundaries to find the correct house
201    for i in range(len(house_names)):
202        start_degree = houses_degree_ut_list[i]
203        end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
204
205        if is_point_between(start_degree, end_degree, planet_position_degree):
206            return house_names[i]
207
208    # If no house is found, raise an error
209    raise ValueError(f"Error in house calculation, planet: {planet_position_degree}, houses: {houses_degree_ut_list}")

Determines the house in which a planet is located based on its position in degrees.

Args: planet_position_degree (Union[int, float]): The position of the planet in degrees. houses_degree_ut_list (list): A list of the houses in degrees (0-360).

Returns: str: The house in which the planet is located.

Raises: ValueError: If the planet's position does not fall within any house range.

def get_moon_emoji_from_phase_int(phase: int) -> Literal['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']:
212def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
213    """
214    Returns the emoji of the moon phase.
215
216    Args:
217        - phase: The phase of the moon (0-28)
218
219    Returns:
220        - The emoji of the moon phase
221    """
222
223    lunar_phase_emojis = get_args(LunarPhaseEmoji)
224
225    if phase == 1:
226        result = lunar_phase_emojis[0]
227    elif phase < 7:
228        result = lunar_phase_emojis[1]
229    elif 7 <= phase <= 9:
230        result = lunar_phase_emojis[2]
231    elif phase < 14:
232        result = lunar_phase_emojis[3]
233    elif phase == 14:
234        result = lunar_phase_emojis[4]
235    elif phase < 20:
236        result = lunar_phase_emojis[5]
237    elif 20 <= phase <= 22:
238        result = lunar_phase_emojis[6]
239    elif phase <= 28:
240        result = lunar_phase_emojis[7]
241
242    else:
243        raise KerykeionException(f"Error in moon emoji calculation! Phase: {phase}")
244
245    return result

Returns the emoji of the moon phase.

Args: - phase: The phase of the moon (0-28)

Returns: - The emoji of the moon phase

def get_moon_phase_name_from_phase_int( phase: int) -> Literal['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous', 'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent']:
247def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
248    """
249    Returns the name of the moon phase.
250
251    Args:
252        - phase: The phase of the moon (0-28)
253
254    Returns:
255        - The name of the moon phase
256    """
257    lunar_phase_names = get_args(LunarPhaseName)
258
259
260    if phase == 1:
261        result = lunar_phase_names[0]
262    elif phase < 7:
263        result =  lunar_phase_names[1]
264    elif 7 <= phase <= 9:
265        result = lunar_phase_names[2]
266    elif phase < 14:
267        result = lunar_phase_names[3]
268    elif phase == 14:
269        result = lunar_phase_names[4]
270    elif phase < 20:
271        result = lunar_phase_names[5]
272    elif 20 <= phase <= 22:
273        result = lunar_phase_names[6]
274    elif phase <= 28:
275        result = lunar_phase_names[7]
276
277    else:
278        raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
279
280    return result

Returns the name of the moon phase.

Args: - phase: The phase of the moon (0-28)

Returns: - The name of the moon phase

def check_and_adjust_polar_latitude(latitude: float) -> float:
283def check_and_adjust_polar_latitude(latitude: float) -> float:
284    """
285        Utility function to check if the location is in the polar circle.
286        If it is, it sets the latitude to 66 or -66 degrees.
287    """
288    if latitude > 66.0:
289        latitude = 66.0
290        logging.info("Polar circle override for houses, using 66 degrees")
291
292    elif latitude < -66.0:
293        latitude = -66.0
294        logging.info("Polar circle override for houses, using -66 degrees")
295
296    return latitude

Utility function to check if the location is in the polar circle. If it is, it sets the latitude to 66 or -66 degrees.

299def get_houses_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
300    """
301    Return the names of the houses in the order of the houses.
302    """
303    houses_absolute_position_list = []
304    for house in subject.houses_names_list:
305            houses_absolute_position_list.append(subject[house.lower()])
306
307    return houses_absolute_position_list

Return the names of the houses in the order of the houses.

310def get_available_astrological_points_list(subject: Union["AstrologicalSubject", AstrologicalSubjectModel]) -> list[KerykeionPointModel]:
311    """
312    Return the names of the planets in the order of the planets.
313    The names can be used to access the planets from the AstrologicalSubject object with the __getitem__ method or the [] operator.
314    """
315    planets_absolute_position_list = []
316    for planet in subject.planets_names_list:
317            planets_absolute_position_list.append(subject[planet.lower()])
318
319    for axis in subject.axial_cusps_names_list:
320        planets_absolute_position_list.append(subject[axis.lower()])
321
322    return planets_absolute_position_list

Return the names of the planets in the order of the planets. The names can be used to access the planets from the AstrologicalSubject object with the __getitem__ method or the [] operator.

def circular_mean( first_position: Union[int, float], second_position: Union[int, float]) -> float:
325def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
326    """
327    Computes the circular mean of two astrological positions (e.g., house cusps, planets).
328
329    This function ensures that positions crossing 0° Aries (360°) are correctly averaged,
330    avoiding errors that occur with simple linear means.
331
332    Args:
333        position1 (Union[int, float]): First position in degrees (0-360).
334        position2 (Union[int, float]): Second position in degrees (0-360).
335
336    Returns:
337        float: The circular mean position in degrees (0-360).
338    """
339    x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
340    y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
341    mean_position = math.degrees(math.atan2(y, x))
342
343    # Ensure the result is within 0-360°
344    if mean_position < 0:
345        mean_position += 360
346
347    return mean_position

Computes the circular mean of two astrological positions (e.g., house cusps, planets).

This function ensures that positions crossing 0° Aries (360°) are correctly averaged, avoiding errors that occur with simple linear means.

Args: position1 (Union[int, float]): First position in degrees (0-360). position2 (Union[int, float]): Second position in degrees (0-360).

Returns: float: The circular mean position in degrees (0-360).

def calculate_moon_phase( moon_abs_pos: float, sun_abs_pos: float) -> kerykeion.kr_types.kr_models.LunarPhaseModel:
350def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
351    """
352    Calculate the lunar phase based on the positions of the moon and sun.
353
354    Args:
355    - moon_abs_pos (float): The absolute position of the moon.
356    - sun_abs_pos (float): The absolute position of the sun.
357
358    Returns:
359    - dict: A dictionary containing the lunar phase information.
360    """
361    # Initialize moon_phase and sun_phase to None in case of an error
362    moon_phase, sun_phase = None, None
363
364    # Calculate the anti-clockwise degrees between the sun and moon
365    degrees_between = (moon_abs_pos - sun_abs_pos) % 360
366
367    # Calculate the moon phase (1-28) based on the degrees between the sun and moon
368    step = 360.0 / 28.0
369    moon_phase = int(degrees_between // step) + 1
370
371    # Define the sun phase steps
372    sunstep = [
373        0, 30, 40, 50, 60, 70, 80, 90, 120, 130, 140, 150, 160, 170, 180,
374        210, 220, 230, 240, 250, 260, 270, 300, 310, 320, 330, 340, 350
375    ]
376
377    # Calculate the sun phase (1-28) based on the degrees between the sun and moon
378    for x in range(len(sunstep)):
379        low = sunstep[x]
380        high = sunstep[x + 1] if x < len(sunstep) - 1 else 360
381        if low <= degrees_between < high:
382            sun_phase = x + 1
383            break
384
385    # Create a dictionary with the lunar phase information
386    lunar_phase_dictionary = {
387        "degrees_between_s_m": degrees_between,
388        "moon_phase": moon_phase,
389        "sun_phase": sun_phase,
390        "moon_emoji": get_moon_emoji_from_phase_int(moon_phase),
391        "moon_phase_name": get_moon_phase_name_from_phase_int(moon_phase)
392    }
393
394    return LunarPhaseModel(**lunar_phase_dictionary)

Calculate the lunar phase based on the positions of the moon and sun.

Args:

  • moon_abs_pos (float): The absolute position of the moon.
  • sun_abs_pos (float): The absolute position of the sun.

Returns:

  • dict: A dictionary containing the lunar phase information.
def circular_sort( degrees: list[typing.Union[int, float]]) -> list[typing.Union[int, float]]:
397def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
398    """
399    Sort a list of degrees in a circular manner, starting from the first element
400    and progressing clockwise around the circle.
401
402    Args:
403        degrees: A list of numeric values representing degrees
404
405    Returns:
406        A list sorted based on circular clockwise progression from the first element
407
408    Raises:
409        ValueError: If the list is empty or contains non-numeric values
410    """
411    # Input validation
412    if not degrees:
413        raise ValueError("Input list cannot be empty")
414
415    if not all(isinstance(degree, (int, float)) for degree in degrees):
416        invalid = next(d for d in degrees if not isinstance(d, (int, float)))
417        raise ValueError(f"All elements must be numeric, found: {invalid} of type {type(invalid).__name__}")
418
419    # If list has 0 or 1 element, return it as is
420    if len(degrees) <= 1:
421        return degrees.copy()
422
423    # Save the first element as the reference
424    reference = degrees[0]
425
426    # Define a function to calculate clockwise distance from reference
427    def clockwise_distance(angle: Union[int, float]) -> Union[int, float]:
428        # Normalize angles to 0-360 range
429        ref_norm = reference % 360
430        angle_norm = angle % 360
431
432        # Calculate clockwise distance
433        distance = angle_norm - ref_norm
434        if distance < 0:
435            distance += 360
436
437        return distance
438
439    # Sort the rest of the elements based on circular distance
440    remaining = degrees[1:]
441    sorted_remaining = sorted(remaining, key=clockwise_distance)
442
443    # Return the reference followed by the sorted remaining elements
444    return [reference] + sorted_remaining

Sort a list of degrees in a circular manner, starting from the first element and progressing clockwise around the circle.

Args: degrees: A list of numeric values representing degrees

Returns: A list sorted based on circular clockwise progression from the first element

Raises: ValueError: If the list is empty or contains non-numeric values

def inline_css_variables_in_svg(svg_content: str) -> str:
447def inline_css_variables_in_svg(svg_content: str) -> str:
448    """
449    Process an SVG string to inline all CSS custom properties.
450
451    Args:
452        svg_content (str): The original SVG string with CSS variables
453
454    Returns:
455        str: The modified SVG with all CSS variables replaced by their values
456             and all style blocks removed
457    """
458    # Find and extract CSS custom properties from style tags
459    css_variable_map = {}
460    style_tag_pattern = re.compile(r"<style.*?>(.*?)</style>", re.DOTALL)
461    style_blocks = style_tag_pattern.findall(svg_content)
462
463    # Parse all CSS custom properties from style blocks
464    for style_block in style_blocks:
465        # Match patterns like --color-primary: #ff0000;
466        css_variable_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
467        for match in css_variable_pattern.finditer(style_block):
468            variable_name = match.group(1)
469            variable_value = match.group(2).strip()
470            css_variable_map[f"--{variable_name}"] = variable_value
471
472    # Remove all style blocks from the SVG
473    svg_without_style_blocks = style_tag_pattern.sub("", svg_content)
474
475    # Function to replace var() references with their actual values
476    def replace_css_variable_reference(match):
477        variable_name = match.group(1).strip()
478        fallback_value = match.group(2) if match.group(2) else None
479
480        if variable_name in css_variable_map:
481            return css_variable_map[variable_name]
482        elif fallback_value:
483            return fallback_value.strip(", ")
484        else:
485            return ""  # If variable not found and no fallback provided
486
487    # Pattern to match var(--name) or var(--name, fallback)
488    variable_usage_pattern = re.compile(r"var\(\s*(--([\w-]+))\s*(,\s*([^)]+))?\s*\)")
489
490    # Repeatedly replace all var() references until none remain
491    # This handles nested variables or variables that reference other variables
492    processed_svg = svg_without_style_blocks
493    while variable_usage_pattern.search(processed_svg):
494        processed_svg = variable_usage_pattern.sub(
495            lambda m: replace_css_variable_reference(m), processed_svg
496        )
497
498    return processed_svg

Process an SVG string to inline all CSS custom properties.

Args: svg_content (str): The original SVG string with CSS variables

Returns: str: The modified SVG with all CSS variables replaced by their values and all style blocks removed