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
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.
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.
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
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.
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.
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
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
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.
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).
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.
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
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