# kerykeion.utilities

View Source

      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: View Source

    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: View Source

     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: View Source

    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: View Source

    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']: View Source

    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['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']: View Source

    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']: View Source

    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: View Source

    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.

def get_houses_list( subject: Union[kerykeion.astrological_subject.AstrologicalSubject, kerykeion.kr_types.kr_models.AstrologicalSubjectModel]) -> list[kerykeion.kr_types.kr_models.KerykeionPointModel]: View Source

    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.

def get_available_astrological_points_list( subject: Union[kerykeion.astrological_subject.AstrologicalSubject, kerykeion.kr_types.kr_models.AstrologicalSubjectModel]) -> list[kerykeion.kr_types.kr_models.KerykeionPointModel]: View Source

    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: View Source

    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: View Source

    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]]: View Source

    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: View Source

    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