kerykeion 5.0.0a9__py3-none-any.whl → 5.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of kerykeion might be problematic. Click here for more details.
- kerykeion/__init__.py +50 -9
- kerykeion/aspects/__init__.py +5 -2
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +78 -11
- kerykeion/astrological_subject_factory.py +1032 -275
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/chart_drawer.py +2661 -0
- kerykeion/charts/charts_utils.py +652 -399
- kerykeion/charts/draw_planets.py +603 -353
- kerykeion/charts/templates/aspect_grid_only.xml +326 -198
- kerykeion/charts/templates/chart.xml +306 -256
- kerykeion/charts/templates/wheel_only.xml +330 -200
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +11 -0
- kerykeion/charts/themes/dark-high-contrast.css +11 -0
- kerykeion/charts/themes/dark.css +11 -0
- kerykeion/charts/themes/light.css +11 -0
- kerykeion/charts/themes/strawberry.css +10 -0
- kerykeion/composite_subject_factory.py +232 -13
- kerykeion/ephemeris_data_factory.py +443 -0
- kerykeion/fetch_geonames.py +78 -21
- kerykeion/house_comparison/__init__.py +4 -1
- kerykeion/house_comparison/house_comparison_factory.py +52 -19
- kerykeion/house_comparison/house_comparison_utils.py +37 -9
- kerykeion/kr_types/__init__.py +66 -6
- kerykeion/kr_types/chart_template_model.py +20 -0
- kerykeion/kr_types/kerykeion_exception.py +15 -9
- kerykeion/kr_types/kr_literals.py +14 -160
- kerykeion/kr_types/kr_models.py +14 -291
- kerykeion/kr_types/settings_models.py +15 -167
- kerykeion/planetary_return_factory.py +545 -40
- kerykeion/relationship_score_factory.py +137 -63
- kerykeion/report.py +749 -64
- kerykeion/schemas/__init__.py +106 -0
- kerykeion/schemas/chart_template_model.py +367 -0
- kerykeion/schemas/kerykeion_exception.py +20 -0
- kerykeion/schemas/kr_literals.py +181 -0
- kerykeion/schemas/kr_models.py +603 -0
- kerykeion/schemas/settings_models.py +188 -0
- kerykeion/settings/__init__.py +20 -1
- kerykeion/settings/chart_defaults.py +444 -0
- kerykeion/settings/config_constants.py +88 -12
- kerykeion/settings/kerykeion_settings.py +32 -75
- kerykeion/settings/translation_strings.py +1499 -0
- kerykeion/settings/translations.py +74 -0
- kerykeion/sweph/ast136/s136108s.se1 +0 -0
- kerykeion/sweph/ast136/s136199s.se1 +0 -0
- kerykeion/sweph/ast136/s136472s.se1 +0 -0
- kerykeion/sweph/ast28/se28978s.se1 +0 -0
- kerykeion/sweph/ast50/se50000s.se1 +0 -0
- kerykeion/sweph/ast90/se90377s.se1 +0 -0
- kerykeion/sweph/ast90/se90482s.se1 +0 -0
- kerykeion/sweph/sefstars.txt +1602 -0
- kerykeion/transits_time_range_factory.py +302 -0
- kerykeion/utilities.py +289 -204
- kerykeion-5.1.8.dist-info/METADATA +1793 -0
- kerykeion-5.1.8.dist-info/RECORD +63 -0
- kerykeion/aspects/natal_aspects.py +0 -181
- kerykeion/aspects/synastry_aspects.py +0 -141
- kerykeion/aspects/transits_time_range.py +0 -41
- kerykeion/charts/draw_planets_v2.py +0 -649
- kerykeion/charts/draw_planets_v3.py +0 -679
- kerykeion/charts/kerykeion_chart_svg.py +0 -2038
- kerykeion/enums.py +0 -57
- kerykeion/ephemeris_data.py +0 -238
- kerykeion/house_comparison/house_comparison_models.py +0 -38
- kerykeion/kr_types/chart_types.py +0 -106
- kerykeion/settings/kr.config.json +0 -1304
- kerykeion/settings/legacy/__init__.py +0 -0
- kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
- kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
- kerykeion/settings/legacy/legacy_color_settings.py +0 -42
- kerykeion/transits_time_range.py +0 -128
- kerykeion-5.0.0a9.dist-info/METADATA +0 -636
- kerykeion-5.0.0a9.dist-info/RECORD +0 -55
- kerykeion-5.0.0a9.dist-info/entry_points.txt +0 -2
- {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/WHEEL +0 -0
- {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/licenses/LICENSE +0 -0
kerykeion/utilities.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
"""
|
|
2
|
+
Author: Giacomo Battaglia
|
|
3
|
+
Copyright: (C) 2025 Kerykeion Project
|
|
4
|
+
License: AGPL-3.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from kerykeion.schemas import (
|
|
2
8
|
KerykeionPointModel,
|
|
3
9
|
KerykeionException,
|
|
4
10
|
ZodiacSignModel,
|
|
@@ -6,82 +12,146 @@ from kerykeion.kr_types import (
|
|
|
6
12
|
LunarPhaseModel,
|
|
7
13
|
CompositeSubjectModel,
|
|
8
14
|
PlanetReturnModel,
|
|
15
|
+
ZodiacType,
|
|
16
|
+
)
|
|
17
|
+
from kerykeion.schemas.kr_literals import (
|
|
18
|
+
LunarPhaseEmoji,
|
|
19
|
+
LunarPhaseName,
|
|
20
|
+
PointType,
|
|
21
|
+
AstrologicalPoint,
|
|
22
|
+
Houses,
|
|
9
23
|
)
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
import logging
|
|
24
|
+
from typing import Union, Optional, get_args, cast
|
|
25
|
+
from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL, basicConfig, getLogger
|
|
13
26
|
import math
|
|
14
27
|
import re
|
|
15
28
|
from datetime import datetime
|
|
16
29
|
|
|
17
|
-
if TYPE_CHECKING:
|
|
18
|
-
from kerykeion import AstrologicalSubjectFactory
|
|
19
30
|
|
|
31
|
+
logger = getLogger(__name__)
|
|
20
32
|
|
|
21
|
-
def
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
return
|
|
51
|
-
elif
|
|
52
|
-
return
|
|
53
|
-
elif name == "Chiron":
|
|
54
|
-
return 15
|
|
55
|
-
elif name == "Mean_Lilith":
|
|
56
|
-
return 12
|
|
57
|
-
elif name == "Ascendant": # TODO: Is this needed?
|
|
58
|
-
return 9900
|
|
59
|
-
elif name == "Descendant": # TODO: Is this needed?
|
|
60
|
-
return 9901
|
|
61
|
-
elif name == "Medium_Coeli": # TODO: Is this needed?
|
|
62
|
-
return 9902
|
|
63
|
-
elif name == "Imum_Coeli": # TODO: Is this needed?
|
|
64
|
-
return 9903
|
|
33
|
+
def normalize_zodiac_type(value: str) -> ZodiacType:
|
|
34
|
+
"""
|
|
35
|
+
Normalize a zodiac type string to its canonical representation.
|
|
36
|
+
|
|
37
|
+
Handles case-insensitive matching and legacy formats like "tropic" or "Tropic",
|
|
38
|
+
automatically converting them to the canonical forms "Tropical" or "Sidereal".
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: Input zodiac type string (case-insensitive).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
ZodiacType: Canonical zodiac type ("Tropical" or "Sidereal").
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If `value` is not a recognized zodiac type.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> normalize_zodiac_type("tropical")
|
|
51
|
+
'Tropical'
|
|
52
|
+
>>> normalize_zodiac_type("Tropic")
|
|
53
|
+
'Tropical'
|
|
54
|
+
>>> normalize_zodiac_type("SIDEREAL")
|
|
55
|
+
'Sidereal'
|
|
56
|
+
"""
|
|
57
|
+
# Normalize to lowercase for comparison
|
|
58
|
+
value_lower = value.lower()
|
|
59
|
+
|
|
60
|
+
# Map legacy and case-insensitive variants to canonical forms
|
|
61
|
+
if value_lower in ("tropical", "tropic"):
|
|
62
|
+
return cast(ZodiacType, "Tropical")
|
|
63
|
+
elif value_lower == "sidereal":
|
|
64
|
+
return cast(ZodiacType, "Sidereal")
|
|
65
65
|
else:
|
|
66
|
-
raise
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"'{value}' is not a valid zodiac type. Accepted values are: Tropical, Sidereal "
|
|
68
|
+
"(case-insensitive, 'tropic' also accepted as legacy).".format(value=value)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
_POINT_NUMBER_MAP: dict[str, int] = {
|
|
72
|
+
"Sun": 0,
|
|
73
|
+
"Moon": 1,
|
|
74
|
+
"Mercury": 2,
|
|
75
|
+
"Venus": 3,
|
|
76
|
+
"Mars": 4,
|
|
77
|
+
"Jupiter": 5,
|
|
78
|
+
"Saturn": 6,
|
|
79
|
+
"Uranus": 7,
|
|
80
|
+
"Neptune": 8,
|
|
81
|
+
"Pluto": 9,
|
|
82
|
+
"Mean_North_Lunar_Node": 10,
|
|
83
|
+
"True_North_Lunar_Node": 11,
|
|
84
|
+
# Swiss Ephemeris has no dedicated IDs for the south nodes; we reserve high values.
|
|
85
|
+
"Mean_South_Lunar_Node": 1000,
|
|
86
|
+
"True_South_Lunar_Node": 1100,
|
|
87
|
+
"Chiron": 15,
|
|
88
|
+
"Mean_Lilith": 12,
|
|
89
|
+
"Ascendant": 9900,
|
|
90
|
+
"Descendant": 9901,
|
|
91
|
+
"Medium_Coeli": 9902,
|
|
92
|
+
"Imum_Coeli": 9903,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Logging helpers
|
|
96
|
+
def setup_logging(level: str) -> None:
|
|
97
|
+
"""Configure the root logger so demo scripts share the same formatting."""
|
|
98
|
+
normalized_level = (level or "").strip().lower()
|
|
99
|
+
level_map: dict[str, int] = {
|
|
100
|
+
"debug": DEBUG,
|
|
101
|
+
"info": INFO,
|
|
102
|
+
"warning": WARNING,
|
|
103
|
+
"error": ERROR,
|
|
104
|
+
"critical": CRITICAL,
|
|
105
|
+
}
|
|
67
106
|
|
|
107
|
+
selected_level = level_map.get(normalized_level, INFO)
|
|
108
|
+
basicConfig(
|
|
109
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
110
|
+
level=selected_level,
|
|
111
|
+
)
|
|
112
|
+
logger.setLevel(selected_level)
|
|
68
113
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_number_from_name(name: AstrologicalPoint) -> int:
|
|
72
118
|
"""
|
|
73
|
-
|
|
119
|
+
Convert an astrological point name to its corresponding numerical identifier.
|
|
74
120
|
|
|
75
121
|
Args:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
122
|
+
name: The name of the astrological point
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The numerical identifier used in Swiss Ephemeris calculations
|
|
79
126
|
|
|
80
127
|
Raises:
|
|
81
|
-
KerykeionException: If the
|
|
128
|
+
KerykeionException: If the name is not recognized
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return _POINT_NUMBER_MAP[str(name)]
|
|
133
|
+
except KeyError as exc: # pragma: no cover - defensive branch
|
|
134
|
+
raise KerykeionException(f"Error in getting number from name! Name: {name}") from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_kerykeion_point_from_degree(
|
|
138
|
+
degree: Union[int, float], name: Union[AstrologicalPoint, Houses], point_type: PointType, speed: Optional[float] = None, declination: Optional[float] = None
|
|
139
|
+
) -> KerykeionPointModel:
|
|
140
|
+
"""
|
|
141
|
+
Create a KerykeionPointModel from a degree position.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
degree: The degree position (0-360, negative values are converted to positive)
|
|
145
|
+
name: The name of the celestial point or house
|
|
146
|
+
point_type: The type classification of the point
|
|
147
|
+
speed: The velocity/speed of the celestial point in degrees per day (optional)
|
|
148
|
+
declination: The declination of the celestial point in degrees (optional)
|
|
82
149
|
|
|
83
150
|
Returns:
|
|
84
|
-
KerykeionPointModel
|
|
151
|
+
A KerykeionPointModel with calculated zodiac sign, position, and properties
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
KerykeionException: If the degree is >= 360 after normalization
|
|
85
155
|
"""
|
|
86
156
|
# If - single degree is given, convert it to a positive degree
|
|
87
157
|
if degree < 0:
|
|
@@ -108,7 +178,6 @@ def get_kerykeion_point_from_degree(
|
|
|
108
178
|
sign_index = int(degree // 30)
|
|
109
179
|
sign_degree = degree % 30
|
|
110
180
|
zodiac_sign = ZODIAC_SIGNS[sign_index]
|
|
111
|
-
|
|
112
181
|
return KerykeionPointModel(
|
|
113
182
|
name=name,
|
|
114
183
|
quality=zodiac_sign.quality,
|
|
@@ -119,91 +188,45 @@ def get_kerykeion_point_from_degree(
|
|
|
119
188
|
abs_pos=degree,
|
|
120
189
|
emoji=zodiac_sign.emoji,
|
|
121
190
|
point_type=point_type,
|
|
191
|
+
speed=speed,
|
|
192
|
+
declination=declination,
|
|
122
193
|
)
|
|
123
194
|
|
|
195
|
+
# Angular helpers
|
|
196
|
+
def is_point_between(start_angle: Union[int, float], end_angle: Union[int, float], candidate: Union[int, float]) -> bool:
|
|
197
|
+
"""Return True when ``candidate`` lies on the clockwise arc from ``start_angle`` to ``end_angle``."""
|
|
124
198
|
|
|
125
|
-
|
|
126
|
-
"""
|
|
127
|
-
Setup logging for testing.
|
|
128
|
-
|
|
129
|
-
Args:
|
|
130
|
-
level: Log level as a string, options: debug, info, warning, error
|
|
131
|
-
"""
|
|
132
|
-
logging_options: dict[str, int] = {
|
|
133
|
-
"debug": logging.DEBUG,
|
|
134
|
-
"info": logging.INFO,
|
|
135
|
-
"warning": logging.WARNING,
|
|
136
|
-
"error": logging.ERROR,
|
|
137
|
-
"critical": logging.CRITICAL,
|
|
138
|
-
}
|
|
139
|
-
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
140
|
-
loglevel: int = logging_options.get(level, logging.INFO)
|
|
141
|
-
logging.basicConfig(format=format, level=loglevel)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def is_point_between(
|
|
145
|
-
start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]
|
|
146
|
-
) -> bool:
|
|
147
|
-
"""
|
|
148
|
-
Determines if a point is between two others on a circle, with additional rules:
|
|
149
|
-
- If evaluated_point == start_point, it is considered between.
|
|
150
|
-
- If evaluated_point == end_point, it is NOT considered between.
|
|
151
|
-
- The range between start_point and end_point must not exceed 180°.
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
- start_point: The first point on the circle.
|
|
155
|
-
- end_point: The second point on the circle.
|
|
156
|
-
- evaluated_point: The point to check.
|
|
157
|
-
|
|
158
|
-
Returns:
|
|
159
|
-
- True if evaluated_point is between start_point and end_point, False otherwise.
|
|
160
|
-
"""
|
|
161
|
-
|
|
162
|
-
# Normalize angles to [0, 360)
|
|
163
|
-
start_point = start_point % 360
|
|
164
|
-
end_point = end_point % 360
|
|
165
|
-
evaluated_point = evaluated_point % 360
|
|
166
|
-
|
|
167
|
-
# Compute angular difference
|
|
168
|
-
angular_difference = math.fmod(end_point - start_point + 360, 360)
|
|
199
|
+
normalize = lambda value: value % 360
|
|
169
200
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
start = normalize(start_angle)
|
|
202
|
+
end = normalize(end_angle)
|
|
203
|
+
target = normalize(candidate)
|
|
204
|
+
span = (end - start) % 360
|
|
205
|
+
if span > 180:
|
|
173
206
|
raise KerykeionException(
|
|
174
|
-
f"The angle between start and end point is not allowed to exceed 180°, yet is: {
|
|
207
|
+
f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
|
|
175
208
|
)
|
|
176
|
-
|
|
177
|
-
# Handle explicitly when evaluated_point == start_point. Note: It may happen for mathematical
|
|
178
|
-
# reasons that evaluated_point and start_point deviate very slightly from each other, but
|
|
179
|
-
# should really be same value. This case is captured later below by the term 0 <= p1_p3.
|
|
180
|
-
if evaluated_point == start_point:
|
|
209
|
+
if target == start:
|
|
181
210
|
return True
|
|
182
|
-
|
|
183
|
-
# Handle explicitly when evaluated_point == end_point
|
|
184
|
-
if evaluated_point == end_point:
|
|
211
|
+
if target == end:
|
|
185
212
|
return False
|
|
213
|
+
distance_from_start = (target - start) % 360
|
|
214
|
+
return distance_from_start < span
|
|
186
215
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
# Check if point lies in the interval
|
|
191
|
-
return (0 <= p1_p3) and (p1_p3 < angular_difference)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
|
|
216
|
+
# House helpers
|
|
217
|
+
def get_planet_house(planet_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
|
|
195
218
|
"""
|
|
196
|
-
|
|
219
|
+
Determine which house contains a planet based on its degree position.
|
|
197
220
|
|
|
198
221
|
Args:
|
|
199
|
-
|
|
200
|
-
houses_degree_ut_list
|
|
222
|
+
planet_degree: The planet's position in degrees (0-360)
|
|
223
|
+
houses_degree_ut_list: List of house cusp degrees
|
|
201
224
|
|
|
202
225
|
Returns:
|
|
203
|
-
|
|
226
|
+
The house name containing the planet
|
|
204
227
|
|
|
205
228
|
Raises:
|
|
206
|
-
ValueError: If the planet's position
|
|
229
|
+
ValueError: If the planet's position doesn't fall within any house range
|
|
207
230
|
"""
|
|
208
231
|
|
|
209
232
|
house_names = get_args(Houses)
|
|
@@ -213,22 +236,25 @@ def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut
|
|
|
213
236
|
start_degree = houses_degree_ut_list[i]
|
|
214
237
|
end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
|
|
215
238
|
|
|
216
|
-
if is_point_between(start_degree, end_degree,
|
|
239
|
+
if is_point_between(start_degree, end_degree, planet_degree):
|
|
217
240
|
return house_names[i]
|
|
218
241
|
|
|
219
242
|
# If no house is found, raise an error
|
|
220
|
-
raise ValueError(f"Error in house calculation, planet: {
|
|
243
|
+
raise ValueError(f"Error in house calculation, planet: {planet_degree}, houses: {houses_degree_ut_list}")
|
|
221
244
|
|
|
222
245
|
|
|
223
246
|
def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
|
|
224
247
|
"""
|
|
225
|
-
|
|
248
|
+
Get the emoji representation of a lunar phase.
|
|
226
249
|
|
|
227
250
|
Args:
|
|
228
|
-
|
|
251
|
+
phase: The lunar phase number (0-28)
|
|
229
252
|
|
|
230
253
|
Returns:
|
|
231
|
-
|
|
254
|
+
The corresponding emoji for the lunar phase
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
KerykeionException: If phase is outside valid range
|
|
232
258
|
"""
|
|
233
259
|
|
|
234
260
|
lunar_phase_emojis = get_args(LunarPhaseEmoji)
|
|
@@ -258,13 +284,16 @@ def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
|
|
|
258
284
|
|
|
259
285
|
def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
|
|
260
286
|
"""
|
|
261
|
-
|
|
287
|
+
Get the name of a lunar phase from its numerical value.
|
|
262
288
|
|
|
263
289
|
Args:
|
|
264
|
-
|
|
290
|
+
phase: The lunar phase number (0-28)
|
|
265
291
|
|
|
266
292
|
Returns:
|
|
267
|
-
|
|
293
|
+
The corresponding name for the lunar phase
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
KerykeionException: If phase is outside valid range
|
|
268
297
|
"""
|
|
269
298
|
lunar_phase_names = get_args(LunarPhaseName)
|
|
270
299
|
|
|
@@ -293,16 +322,23 @@ def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
|
|
|
293
322
|
|
|
294
323
|
def check_and_adjust_polar_latitude(latitude: float) -> float:
|
|
295
324
|
"""
|
|
296
|
-
|
|
297
|
-
|
|
325
|
+
Adjust latitude values for polar regions to prevent calculation errors.
|
|
326
|
+
|
|
327
|
+
Latitudes beyond ±66° are clamped to ±66° for house calculations.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
latitude: The original latitude value
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
The adjusted latitude value, clamped between -66° and 66°
|
|
298
334
|
"""
|
|
299
335
|
if latitude > 66.0:
|
|
300
336
|
latitude = 66.0
|
|
301
|
-
|
|
337
|
+
logger.info("Latitude capped at 66° to keep house calculations stable.")
|
|
302
338
|
|
|
303
339
|
elif latitude < -66.0:
|
|
304
340
|
latitude = -66.0
|
|
305
|
-
|
|
341
|
+
logger.info("Latitude capped at -66° to keep house calculations stable.")
|
|
306
342
|
|
|
307
343
|
return latitude
|
|
308
344
|
|
|
@@ -311,7 +347,13 @@ def get_houses_list(
|
|
|
311
347
|
subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
|
|
312
348
|
) -> list[KerykeionPointModel]:
|
|
313
349
|
"""
|
|
314
|
-
|
|
350
|
+
Get a list of house objects in order from the subject.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
subject: The astrological subject containing house data
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
List of KerykeionPointModel objects representing the houses
|
|
315
357
|
"""
|
|
316
358
|
houses_absolute_position_list = []
|
|
317
359
|
for house in subject.houses_names_list:
|
|
@@ -324,8 +366,13 @@ def get_available_astrological_points_list(
|
|
|
324
366
|
subject: AstrologicalSubjectModel
|
|
325
367
|
) -> list[KerykeionPointModel]:
|
|
326
368
|
"""
|
|
327
|
-
|
|
328
|
-
|
|
369
|
+
Get a list of active astrological point objects from the subject.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
subject: The astrological subject containing point data
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of KerykeionPointModel objects for all active points
|
|
329
376
|
"""
|
|
330
377
|
planets_absolute_position_list = []
|
|
331
378
|
for planet in subject.active_points:
|
|
@@ -336,17 +383,17 @@ def get_available_astrological_points_list(
|
|
|
336
383
|
|
|
337
384
|
def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
|
|
338
385
|
"""
|
|
339
|
-
|
|
386
|
+
Calculate the circular mean of two angular positions.
|
|
340
387
|
|
|
341
|
-
This
|
|
342
|
-
avoiding errors that occur with simple
|
|
388
|
+
This method correctly handles positions that cross the 0°/360° boundary,
|
|
389
|
+
avoiding errors that occur with simple arithmetic means.
|
|
343
390
|
|
|
344
391
|
Args:
|
|
345
|
-
|
|
346
|
-
|
|
392
|
+
first_position: First angular position in degrees (0-360)
|
|
393
|
+
second_position: Second angular position in degrees (0-360)
|
|
347
394
|
|
|
348
395
|
Returns:
|
|
349
|
-
|
|
396
|
+
The circular mean position in degrees (0-360)
|
|
350
397
|
"""
|
|
351
398
|
x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
|
|
352
399
|
y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
|
|
@@ -361,18 +408,15 @@ def circular_mean(first_position: Union[int, float], second_position: Union[int,
|
|
|
361
408
|
|
|
362
409
|
def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
|
|
363
410
|
"""
|
|
364
|
-
Calculate
|
|
411
|
+
Calculate lunar phase information from Sun and Moon positions.
|
|
365
412
|
|
|
366
413
|
Args:
|
|
367
|
-
|
|
368
|
-
|
|
414
|
+
moon_abs_pos: Absolute position of the Moon in degrees
|
|
415
|
+
sun_abs_pos: Absolute position of the Sun in degrees
|
|
369
416
|
|
|
370
417
|
Returns:
|
|
371
|
-
|
|
418
|
+
LunarPhaseModel containing phase data, emoji, and name
|
|
372
419
|
"""
|
|
373
|
-
# Initialize moon_phase and sun_phase to None in case of an error
|
|
374
|
-
moon_phase, sun_phase = None, None
|
|
375
|
-
|
|
376
420
|
# Calculate the anti-clockwise degrees between the sun and moon
|
|
377
421
|
degrees_between = (moon_abs_pos - sun_abs_pos) % 360
|
|
378
422
|
|
|
@@ -380,42 +424,23 @@ def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseM
|
|
|
380
424
|
step = 360.0 / 28.0
|
|
381
425
|
moon_phase = int(degrees_between // step) + 1
|
|
382
426
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
# Calculate the sun phase (1-28) based on the degrees between the sun and moon
|
|
390
|
-
for x in range(len(sunstep)):
|
|
391
|
-
low = sunstep[x]
|
|
392
|
-
high = sunstep[x + 1] if x < len(sunstep) - 1 else 360
|
|
393
|
-
if low <= degrees_between < high:
|
|
394
|
-
sun_phase = x + 1
|
|
395
|
-
break
|
|
396
|
-
|
|
397
|
-
# Create a dictionary with the lunar phase information
|
|
398
|
-
lunar_phase_dictionary = {
|
|
399
|
-
"degrees_between_s_m": degrees_between,
|
|
400
|
-
"moon_phase": moon_phase,
|
|
401
|
-
"sun_phase": sun_phase,
|
|
402
|
-
"moon_emoji": get_moon_emoji_from_phase_int(moon_phase),
|
|
403
|
-
"moon_phase_name": get_moon_phase_name_from_phase_int(moon_phase),
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return LunarPhaseModel(**lunar_phase_dictionary)
|
|
427
|
+
return LunarPhaseModel(
|
|
428
|
+
degrees_between_s_m=degrees_between,
|
|
429
|
+
moon_phase=moon_phase,
|
|
430
|
+
moon_emoji=get_moon_emoji_from_phase_int(moon_phase),
|
|
431
|
+
moon_phase_name=get_moon_phase_name_from_phase_int(moon_phase)
|
|
432
|
+
)
|
|
407
433
|
|
|
408
434
|
|
|
409
435
|
def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
|
|
410
436
|
"""
|
|
411
|
-
Sort
|
|
412
|
-
and progressing clockwise around the circle.
|
|
437
|
+
Sort degrees in circular clockwise progression starting from the first element.
|
|
413
438
|
|
|
414
439
|
Args:
|
|
415
|
-
degrees:
|
|
440
|
+
degrees: List of numeric degree values
|
|
416
441
|
|
|
417
442
|
Returns:
|
|
418
|
-
|
|
443
|
+
List sorted by clockwise distance from the first element
|
|
419
444
|
|
|
420
445
|
Raises:
|
|
421
446
|
ValueError: If the list is empty or contains non-numeric values
|
|
@@ -458,14 +483,16 @@ def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
|
|
|
458
483
|
|
|
459
484
|
def inline_css_variables_in_svg(svg_content: str) -> str:
|
|
460
485
|
"""
|
|
461
|
-
|
|
486
|
+
Replace CSS custom properties (variables) with their values in SVG content.
|
|
487
|
+
|
|
488
|
+
Extracts CSS variables from style blocks, replaces var() references with actual values,
|
|
489
|
+
and removes all style blocks from the SVG.
|
|
462
490
|
|
|
463
491
|
Args:
|
|
464
|
-
svg_content
|
|
492
|
+
svg_content: The original SVG string with CSS variables
|
|
465
493
|
|
|
466
494
|
Returns:
|
|
467
|
-
|
|
468
|
-
and all style blocks removed
|
|
495
|
+
Modified SVG with CSS variables inlined and style blocks removed
|
|
469
496
|
"""
|
|
470
497
|
# Find and extract CSS custom properties from style tags
|
|
471
498
|
css_variable_map = {}
|
|
@@ -486,6 +513,15 @@ def inline_css_variables_in_svg(svg_content: str) -> str:
|
|
|
486
513
|
|
|
487
514
|
# Function to replace var() references with their actual values
|
|
488
515
|
def replace_css_variable_reference(match):
|
|
516
|
+
"""
|
|
517
|
+
Replace CSS variable references with their actual values.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
match: Regular expression match object containing variable name and optional fallback.
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
str: The resolved CSS variable value or fallback value.
|
|
524
|
+
"""
|
|
489
525
|
variable_name = match.group(1).strip()
|
|
490
526
|
fallback_value = match.group(2) if match.group(2) else None
|
|
491
527
|
|
|
@@ -510,13 +546,13 @@ def inline_css_variables_in_svg(svg_content: str) -> str:
|
|
|
510
546
|
|
|
511
547
|
def datetime_to_julian(dt: datetime) -> float:
|
|
512
548
|
"""
|
|
513
|
-
|
|
549
|
+
Convert a Python datetime object to Julian Day Number.
|
|
514
550
|
|
|
515
551
|
Args:
|
|
516
|
-
dt:
|
|
552
|
+
dt: The datetime object to convert
|
|
517
553
|
|
|
518
554
|
Returns:
|
|
519
|
-
|
|
555
|
+
The corresponding Julian Day Number (JD) as a float
|
|
520
556
|
"""
|
|
521
557
|
# Extract year, month and day
|
|
522
558
|
year = dt.year
|
|
@@ -548,13 +584,13 @@ def datetime_to_julian(dt: datetime) -> float:
|
|
|
548
584
|
|
|
549
585
|
def julian_to_datetime(jd):
|
|
550
586
|
"""
|
|
551
|
-
|
|
587
|
+
Convert a Julian Day Number to a Python datetime object.
|
|
552
588
|
|
|
553
589
|
Args:
|
|
554
|
-
jd: Julian
|
|
590
|
+
jd: Julian Day Number as a float
|
|
555
591
|
|
|
556
592
|
Returns:
|
|
557
|
-
|
|
593
|
+
The corresponding datetime object
|
|
558
594
|
"""
|
|
559
595
|
# Add 0.5 to the Julian day to adjust for noon-based Julian day
|
|
560
596
|
jd_plus = jd + 0.5
|
|
@@ -613,13 +649,16 @@ def julian_to_datetime(jd):
|
|
|
613
649
|
|
|
614
650
|
def get_house_name(house_number: int) -> Houses:
|
|
615
651
|
"""
|
|
616
|
-
|
|
652
|
+
Convert a house number to its corresponding house name.
|
|
617
653
|
|
|
618
654
|
Args:
|
|
619
655
|
house_number: House number (1-12)
|
|
620
656
|
|
|
621
657
|
Returns:
|
|
622
|
-
|
|
658
|
+
The house name
|
|
659
|
+
|
|
660
|
+
Raises:
|
|
661
|
+
ValueError: If house_number is not in range 1-12
|
|
623
662
|
"""
|
|
624
663
|
house_names: dict[int, Houses] = {
|
|
625
664
|
1: "First_House",
|
|
@@ -645,13 +684,16 @@ def get_house_name(house_number: int) -> Houses:
|
|
|
645
684
|
|
|
646
685
|
def get_house_number(house_name: Houses) -> int:
|
|
647
686
|
"""
|
|
648
|
-
|
|
687
|
+
Convert a house name to its corresponding house number.
|
|
649
688
|
|
|
650
689
|
Args:
|
|
651
|
-
house_name:
|
|
690
|
+
house_name: The house name
|
|
652
691
|
|
|
653
692
|
Returns:
|
|
654
693
|
House number (1-12)
|
|
694
|
+
|
|
695
|
+
Raises:
|
|
696
|
+
ValueError: If house_name is not recognized
|
|
655
697
|
"""
|
|
656
698
|
house_numbers: dict[Houses, int] = {
|
|
657
699
|
"First_House": 1,
|
|
@@ -677,15 +719,58 @@ def get_house_number(house_name: Houses) -> int:
|
|
|
677
719
|
|
|
678
720
|
def find_common_active_points(first_points: list[AstrologicalPoint], second_points: list[AstrologicalPoint]) -> list[AstrologicalPoint]:
|
|
679
721
|
"""
|
|
680
|
-
Find
|
|
722
|
+
Find astrological points that appear in both input lists.
|
|
681
723
|
|
|
682
724
|
Args:
|
|
683
|
-
first_points:
|
|
684
|
-
second_points:
|
|
725
|
+
first_points: First list of astrological points
|
|
726
|
+
second_points: Second list of astrological points
|
|
685
727
|
|
|
686
728
|
Returns:
|
|
687
|
-
List of
|
|
729
|
+
List of points common to both input lists (without duplicates)
|
|
688
730
|
"""
|
|
689
731
|
common_points = list(set(first_points) & set(second_points))
|
|
690
732
|
|
|
691
733
|
return common_points
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def distribute_percentages_to_100(values: dict[str, float]) -> dict[str, int]:
|
|
737
|
+
"""
|
|
738
|
+
Distribute percentages so they sum to exactly 100.
|
|
739
|
+
|
|
740
|
+
This function uses a largest remainder method to ensure that
|
|
741
|
+
the percentage total equals 100 even after rounding.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
values: Dictionary with keys and their raw percentage values
|
|
745
|
+
|
|
746
|
+
Returns:
|
|
747
|
+
Dictionary with the same keys and integer percentages that sum to 100
|
|
748
|
+
"""
|
|
749
|
+
if not values:
|
|
750
|
+
return {}
|
|
751
|
+
|
|
752
|
+
total = sum(values.values())
|
|
753
|
+
if total == 0:
|
|
754
|
+
return {key: 0 for key in values.keys()}
|
|
755
|
+
|
|
756
|
+
# Calculate base percentages
|
|
757
|
+
percentages = {key: value * 100 / total for key, value in values.items()}
|
|
758
|
+
|
|
759
|
+
# Get integer parts and remainders
|
|
760
|
+
integer_parts = {key: int(value) for key, value in percentages.items()}
|
|
761
|
+
remainders = {key: percentages[key] - integer_parts[key] for key in percentages.keys()}
|
|
762
|
+
|
|
763
|
+
# Calculate how many we need to add to reach 100
|
|
764
|
+
current_sum = sum(integer_parts.values())
|
|
765
|
+
needed = 100 - current_sum
|
|
766
|
+
|
|
767
|
+
# Sort by remainder (largest first) and add 1 to the largest remainders
|
|
768
|
+
sorted_by_remainder = sorted(remainders.items(), key=lambda x: x[1], reverse=True)
|
|
769
|
+
|
|
770
|
+
result = integer_parts.copy()
|
|
771
|
+
for i in range(needed):
|
|
772
|
+
if i < len(sorted_by_remainder):
|
|
773
|
+
key = sorted_by_remainder[i][0]
|
|
774
|
+
result[key] += 1
|
|
775
|
+
|
|
776
|
+
return result
|