kerykeion 4.18.3__py3-none-any.whl → 5.1.9__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 +56 -11
- kerykeion/aspects/__init__.py +7 -4
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +86 -13
- kerykeion/astrological_subject_factory.py +1901 -0
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/__init__.py +2 -2
- kerykeion/charts/chart_drawer.py +2794 -0
- kerykeion/charts/charts_utils.py +1066 -309
- kerykeion/charts/draw_planets.py +602 -351
- kerykeion/charts/templates/aspect_grid_only.xml +337 -193
- kerykeion/charts/templates/chart.xml +441 -240
- kerykeion/charts/templates/wheel_only.xml +365 -211
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +107 -76
- kerykeion/charts/themes/dark-high-contrast.css +145 -107
- kerykeion/charts/themes/dark.css +146 -107
- kerykeion/charts/themes/light.css +146 -103
- kerykeion/charts/themes/strawberry.css +158 -0
- kerykeion/composite_subject_factory.py +408 -0
- kerykeion/ephemeris_data_factory.py +443 -0
- kerykeion/fetch_geonames.py +81 -21
- kerykeion/house_comparison/__init__.py +6 -0
- kerykeion/house_comparison/house_comparison_factory.py +103 -0
- kerykeion/house_comparison/house_comparison_utils.py +126 -0
- 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 -106
- kerykeion/kr_types/kr_models.py +14 -179
- kerykeion/kr_types/settings_models.py +15 -152
- kerykeion/planetary_return_factory.py +805 -0
- kerykeion/relationship_score_factory.py +301 -0
- kerykeion/report.py +750 -65
- 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 +152 -0
- kerykeion/settings/kerykeion_settings.py +36 -61
- 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 +626 -125
- kerykeion-5.1.9.dist-info/METADATA +1793 -0
- kerykeion-5.1.9.dist-info/RECORD +63 -0
- {kerykeion-4.18.3.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -1
- kerykeion/aspects/natal_aspects.py +0 -143
- kerykeion/aspects/synastry_aspects.py +0 -113
- kerykeion/astrological_subject.py +0 -818
- kerykeion/charts/kerykeion_chart_svg.py +0 -894
- kerykeion/enums.py +0 -51
- kerykeion/ephemeris_data.py +0 -178
- kerykeion/kr_types/chart_types.py +0 -88
- kerykeion/relationship_score/__init__.py +0 -2
- kerykeion/relationship_score/relationship_score.py +0 -175
- kerykeion/relationship_score/relationship_score_factory.py +0 -275
- kerykeion/settings/kr.config.json +0 -721
- kerykeion-4.18.3.dist-info/LICENSE +0 -661
- kerykeion-4.18.3.dist-info/METADATA +0 -396
- kerykeion-4.18.3.dist-info/RECORD +0 -42
- kerykeion-4.18.3.dist-info/entry_points.txt +0 -3
- /LICENSE → /kerykeion-5.1.9.dist-info/licenses/LICENSE +0 -0
kerykeion/utilities.py
CHANGED
|
@@ -1,67 +1,163 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
"""
|
|
2
|
+
Author: Giacomo Battaglia
|
|
3
|
+
Copyright: (C) 2025 Kerykeion Project
|
|
4
|
+
License: AGPL-3.0
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from kerykeion.schemas import (
|
|
8
|
+
KerykeionPointModel,
|
|
9
|
+
KerykeionException,
|
|
10
|
+
ZodiacSignModel,
|
|
11
|
+
AstrologicalSubjectModel,
|
|
12
|
+
LunarPhaseModel,
|
|
13
|
+
CompositeSubjectModel,
|
|
14
|
+
PlanetReturnModel,
|
|
15
|
+
ZodiacType,
|
|
16
|
+
)
|
|
17
|
+
from kerykeion.schemas.kr_literals import (
|
|
18
|
+
LunarPhaseEmoji,
|
|
19
|
+
LunarPhaseName,
|
|
20
|
+
PointType,
|
|
21
|
+
AstrologicalPoint,
|
|
22
|
+
Houses,
|
|
23
|
+
)
|
|
24
|
+
from typing import Union, Optional, get_args, cast
|
|
25
|
+
from logging import DEBUG, INFO, WARNING, ERROR, CRITICAL, basicConfig, getLogger
|
|
5
26
|
import math
|
|
27
|
+
import re
|
|
28
|
+
from datetime import datetime
|
|
6
29
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return
|
|
40
|
-
elif
|
|
41
|
-
return
|
|
30
|
+
|
|
31
|
+
logger = getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
def normalize_zodiac_type(value: str) -> ZodiacType:
|
|
34
|
+
"""
|
|
35
|
+
Normalize a zodiac type string to its canonical representation.
|
|
36
|
+
|
|
37
|
+
Handles case-insensitive matching and legacy formats like "tropic" or "Tropic",
|
|
38
|
+
automatically converting them to the canonical forms "Tropical" or "Sidereal".
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
value: Input zodiac type string (case-insensitive).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
ZodiacType: Canonical zodiac type ("Tropical" or "Sidereal").
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If `value` is not a recognized zodiac type.
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
>>> normalize_zodiac_type("tropical")
|
|
51
|
+
'Tropical'
|
|
52
|
+
>>> normalize_zodiac_type("Tropic")
|
|
53
|
+
'Tropical'
|
|
54
|
+
>>> normalize_zodiac_type("SIDEREAL")
|
|
55
|
+
'Sidereal'
|
|
56
|
+
"""
|
|
57
|
+
# Normalize to lowercase for comparison
|
|
58
|
+
value_lower = value.lower()
|
|
59
|
+
|
|
60
|
+
# Map legacy and case-insensitive variants to canonical forms
|
|
61
|
+
if value_lower in ("tropical", "tropic"):
|
|
62
|
+
return cast(ZodiacType, "Tropical")
|
|
63
|
+
elif value_lower == "sidereal":
|
|
64
|
+
return cast(ZodiacType, "Sidereal")
|
|
42
65
|
else:
|
|
43
|
-
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
|
+
}
|
|
44
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)
|
|
45
113
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_number_from_name(name: AstrologicalPoint) -> int:
|
|
49
118
|
"""
|
|
50
|
-
|
|
119
|
+
Convert an astrological point name to its corresponding numerical identifier.
|
|
51
120
|
|
|
52
121
|
Args:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
122
|
+
name: The name of the astrological point
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The numerical identifier used in Swiss Ephemeris calculations
|
|
56
126
|
|
|
57
127
|
Raises:
|
|
58
|
-
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)
|
|
59
149
|
|
|
60
150
|
Returns:
|
|
61
|
-
KerykeionPointModel
|
|
151
|
+
A KerykeionPointModel with calculated zodiac sign, position, and properties
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
KerykeionException: If the degree is >= 360 after normalization
|
|
62
155
|
"""
|
|
156
|
+
# If - single degree is given, convert it to a positive degree
|
|
157
|
+
if degree < 0:
|
|
158
|
+
degree = degree % 360
|
|
63
159
|
|
|
64
|
-
if degree
|
|
160
|
+
if degree >= 360:
|
|
65
161
|
raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
|
|
66
162
|
|
|
67
163
|
ZODIAC_SIGNS = {
|
|
@@ -82,7 +178,6 @@ def get_kerykeion_point_from_degree(
|
|
|
82
178
|
sign_index = int(degree // 30)
|
|
83
179
|
sign_degree = degree % 30
|
|
84
180
|
zodiac_sign = ZODIAC_SIGNS[sign_index]
|
|
85
|
-
|
|
86
181
|
return KerykeionPointModel(
|
|
87
182
|
name=name,
|
|
88
183
|
quality=zodiac_sign.quality,
|
|
@@ -93,63 +188,45 @@ def get_kerykeion_point_from_degree(
|
|
|
93
188
|
abs_pos=degree,
|
|
94
189
|
emoji=zodiac_sign.emoji,
|
|
95
190
|
point_type=point_type,
|
|
191
|
+
speed=speed,
|
|
192
|
+
declination=declination,
|
|
96
193
|
)
|
|
97
194
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
loglevel: int = logging_options.get(level, logging.INFO)
|
|
114
|
-
logging.basicConfig(format=format, level=loglevel)
|
|
115
|
-
|
|
116
|
-
def check_if_point_between(
|
|
117
|
-
start_point: Union[int, float], end_point: Union[int, float], evaluated_point: Union[int, float]
|
|
118
|
-
) -> bool:
|
|
119
|
-
"""
|
|
120
|
-
Finds if a point is between two other in a circle.
|
|
121
|
-
|
|
122
|
-
Args:
|
|
123
|
-
- start_point: The first point
|
|
124
|
-
- end_point: The second point
|
|
125
|
-
- point: The point to check if it is between start_point and end_point
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
- True if point is between start_point and end_point, False otherwise
|
|
129
|
-
"""
|
|
130
|
-
|
|
131
|
-
p1_p2 = math.fmod(end_point - start_point + 360, 360)
|
|
132
|
-
p1_p3 = math.fmod(evaluated_point - start_point + 360, 360)
|
|
133
|
-
|
|
134
|
-
if (p1_p2 <= 180) != (p1_p3 > p1_p2):
|
|
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``."""
|
|
198
|
+
|
|
199
|
+
normalize = lambda value: value % 360
|
|
200
|
+
|
|
201
|
+
start = normalize(start_angle)
|
|
202
|
+
end = normalize(end_angle)
|
|
203
|
+
target = normalize(candidate)
|
|
204
|
+
span = (end - start) % 360
|
|
205
|
+
if span > 180:
|
|
206
|
+
raise KerykeionException(
|
|
207
|
+
f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
|
|
208
|
+
)
|
|
209
|
+
if target == start:
|
|
135
210
|
return True
|
|
136
|
-
|
|
211
|
+
if target == end:
|
|
137
212
|
return False
|
|
213
|
+
distance_from_start = (target - start) % 360
|
|
214
|
+
return distance_from_start < span
|
|
138
215
|
|
|
139
|
-
|
|
140
|
-
def get_planet_house(
|
|
216
|
+
# House helpers
|
|
217
|
+
def get_planet_house(planet_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
|
|
141
218
|
"""
|
|
142
|
-
|
|
219
|
+
Determine which house contains a planet based on its degree position.
|
|
143
220
|
|
|
144
221
|
Args:
|
|
145
|
-
|
|
146
|
-
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
|
|
147
224
|
|
|
148
225
|
Returns:
|
|
149
|
-
|
|
226
|
+
The house name containing the planet
|
|
150
227
|
|
|
151
228
|
Raises:
|
|
152
|
-
ValueError: If the planet's position
|
|
229
|
+
ValueError: If the planet's position doesn't fall within any house range
|
|
153
230
|
"""
|
|
154
231
|
|
|
155
232
|
house_names = get_args(Houses)
|
|
@@ -158,26 +235,30 @@ def get_planet_house(planet_position_degree: Union[int, float], houses_degree_ut
|
|
|
158
235
|
for i in range(len(house_names)):
|
|
159
236
|
start_degree = houses_degree_ut_list[i]
|
|
160
237
|
end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
|
|
161
|
-
|
|
238
|
+
|
|
239
|
+
if is_point_between(start_degree, end_degree, planet_degree):
|
|
162
240
|
return house_names[i]
|
|
163
241
|
|
|
164
242
|
# If no house is found, raise an error
|
|
165
|
-
raise ValueError(f"Error in house calculation, planet: {
|
|
243
|
+
raise ValueError(f"Error in house calculation, planet: {planet_degree}, houses: {houses_degree_ut_list}")
|
|
166
244
|
|
|
167
245
|
|
|
168
246
|
def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
|
|
169
247
|
"""
|
|
170
|
-
|
|
171
|
-
|
|
248
|
+
Get the emoji representation of a lunar phase.
|
|
249
|
+
|
|
172
250
|
Args:
|
|
173
|
-
|
|
174
|
-
|
|
251
|
+
phase: The lunar phase number (0-28)
|
|
252
|
+
|
|
175
253
|
Returns:
|
|
176
|
-
|
|
254
|
+
The corresponding emoji for the lunar phase
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
KerykeionException: If phase is outside valid range
|
|
177
258
|
"""
|
|
178
259
|
|
|
179
260
|
lunar_phase_emojis = get_args(LunarPhaseEmoji)
|
|
180
|
-
|
|
261
|
+
|
|
181
262
|
if phase == 1:
|
|
182
263
|
result = lunar_phase_emojis[0]
|
|
183
264
|
elif phase < 7:
|
|
@@ -200,23 +281,26 @@ def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
|
|
|
200
281
|
|
|
201
282
|
return result
|
|
202
283
|
|
|
284
|
+
|
|
203
285
|
def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
|
|
204
286
|
"""
|
|
205
|
-
|
|
206
|
-
|
|
287
|
+
Get the name of a lunar phase from its numerical value.
|
|
288
|
+
|
|
207
289
|
Args:
|
|
208
|
-
|
|
209
|
-
|
|
290
|
+
phase: The lunar phase number (0-28)
|
|
291
|
+
|
|
210
292
|
Returns:
|
|
211
|
-
|
|
293
|
+
The corresponding name for the lunar phase
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
KerykeionException: If phase is outside valid range
|
|
212
297
|
"""
|
|
213
298
|
lunar_phase_names = get_args(LunarPhaseName)
|
|
214
299
|
|
|
215
|
-
|
|
216
300
|
if phase == 1:
|
|
217
301
|
result = lunar_phase_names[0]
|
|
218
302
|
elif phase < 7:
|
|
219
|
-
result =
|
|
303
|
+
result = lunar_phase_names[1]
|
|
220
304
|
elif 7 <= phase <= 9:
|
|
221
305
|
result = lunar_phase_names[2]
|
|
222
306
|
elif phase < 14:
|
|
@@ -232,44 +316,461 @@ def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
|
|
|
232
316
|
|
|
233
317
|
else:
|
|
234
318
|
raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
|
|
235
|
-
|
|
319
|
+
|
|
236
320
|
return result
|
|
237
321
|
|
|
238
322
|
|
|
239
323
|
def check_and_adjust_polar_latitude(latitude: float) -> float:
|
|
240
324
|
"""
|
|
241
|
-
|
|
242
|
-
|
|
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°
|
|
243
334
|
"""
|
|
244
335
|
if latitude > 66.0:
|
|
245
336
|
latitude = 66.0
|
|
246
|
-
|
|
337
|
+
logger.info("Latitude capped at 66° to keep house calculations stable.")
|
|
247
338
|
|
|
248
339
|
elif latitude < -66.0:
|
|
249
340
|
latitude = -66.0
|
|
250
|
-
|
|
341
|
+
logger.info("Latitude capped at -66° to keep house calculations stable.")
|
|
251
342
|
|
|
252
343
|
return latitude
|
|
253
344
|
|
|
254
345
|
|
|
255
|
-
def get_houses_list(
|
|
346
|
+
def get_houses_list(
|
|
347
|
+
subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
|
|
348
|
+
) -> list[KerykeionPointModel]:
|
|
256
349
|
"""
|
|
257
|
-
|
|
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
|
|
258
357
|
"""
|
|
259
358
|
houses_absolute_position_list = []
|
|
260
359
|
for house in subject.houses_names_list:
|
|
261
|
-
|
|
360
|
+
houses_absolute_position_list.append(subject[house.lower()])
|
|
262
361
|
|
|
263
362
|
return houses_absolute_position_list
|
|
264
363
|
|
|
265
364
|
|
|
266
|
-
def
|
|
365
|
+
def get_available_astrological_points_list(
|
|
366
|
+
subject: AstrologicalSubjectModel
|
|
367
|
+
) -> list[KerykeionPointModel]:
|
|
267
368
|
"""
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
270
376
|
"""
|
|
271
377
|
planets_absolute_position_list = []
|
|
272
|
-
for planet in subject.
|
|
273
|
-
|
|
378
|
+
for planet in subject.active_points:
|
|
379
|
+
planets_absolute_position_list.append(subject[planet.lower()])
|
|
380
|
+
|
|
381
|
+
return planets_absolute_position_list
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def circular_mean(first_position: Union[int, float], second_position: Union[int, float]) -> float:
|
|
385
|
+
"""
|
|
386
|
+
Calculate the circular mean of two angular positions.
|
|
274
387
|
|
|
275
|
-
|
|
388
|
+
This method correctly handles positions that cross the 0°/360° boundary,
|
|
389
|
+
avoiding errors that occur with simple arithmetic means.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
first_position: First angular position in degrees (0-360)
|
|
393
|
+
second_position: Second angular position in degrees (0-360)
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
The circular mean position in degrees (0-360)
|
|
397
|
+
"""
|
|
398
|
+
x = (math.cos(math.radians(first_position)) + math.cos(math.radians(second_position))) / 2
|
|
399
|
+
y = (math.sin(math.radians(first_position)) + math.sin(math.radians(second_position))) / 2
|
|
400
|
+
mean_position = math.degrees(math.atan2(y, x))
|
|
401
|
+
|
|
402
|
+
# Ensure the result is within 0-360°
|
|
403
|
+
if mean_position < 0:
|
|
404
|
+
mean_position += 360
|
|
405
|
+
|
|
406
|
+
return mean_position
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
|
|
410
|
+
"""
|
|
411
|
+
Calculate lunar phase information from Sun and Moon positions.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
moon_abs_pos: Absolute position of the Moon in degrees
|
|
415
|
+
sun_abs_pos: Absolute position of the Sun in degrees
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
LunarPhaseModel containing phase data, emoji, and name
|
|
419
|
+
"""
|
|
420
|
+
# Calculate the anti-clockwise degrees between the sun and moon
|
|
421
|
+
degrees_between = (moon_abs_pos - sun_abs_pos) % 360
|
|
422
|
+
|
|
423
|
+
# Calculate the moon phase (1-28) based on the degrees between the sun and moon
|
|
424
|
+
step = 360.0 / 28.0
|
|
425
|
+
moon_phase = int(degrees_between // step) + 1
|
|
426
|
+
|
|
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
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def circular_sort(degrees: list[Union[int, float]]) -> list[Union[int, float]]:
|
|
436
|
+
"""
|
|
437
|
+
Sort degrees in circular clockwise progression starting from the first element.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
degrees: List of numeric degree values
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
List sorted by clockwise distance from the first element
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
ValueError: If the list is empty or contains non-numeric values
|
|
447
|
+
"""
|
|
448
|
+
# Input validation
|
|
449
|
+
if not degrees:
|
|
450
|
+
raise ValueError("Input list cannot be empty")
|
|
451
|
+
|
|
452
|
+
if not all(isinstance(degree, (int, float)) for degree in degrees):
|
|
453
|
+
invalid = next(d for d in degrees if not isinstance(d, (int, float)))
|
|
454
|
+
raise ValueError(f"All elements must be numeric, found: {invalid} of type {type(invalid).__name__}")
|
|
455
|
+
|
|
456
|
+
# If list has 0 or 1 element, return it as is
|
|
457
|
+
if len(degrees) <= 1:
|
|
458
|
+
return degrees.copy()
|
|
459
|
+
|
|
460
|
+
# Save the first element as the reference
|
|
461
|
+
reference = degrees[0]
|
|
462
|
+
|
|
463
|
+
# Define a function to calculate clockwise distance from reference
|
|
464
|
+
def clockwise_distance(angle: Union[int, float]) -> Union[int, float]:
|
|
465
|
+
# Normalize angles to 0-360 range
|
|
466
|
+
ref_norm = reference % 360
|
|
467
|
+
angle_norm = angle % 360
|
|
468
|
+
|
|
469
|
+
# Calculate clockwise distance
|
|
470
|
+
distance = angle_norm - ref_norm
|
|
471
|
+
if distance < 0:
|
|
472
|
+
distance += 360
|
|
473
|
+
|
|
474
|
+
return distance
|
|
475
|
+
|
|
476
|
+
# Sort the rest of the elements based on circular distance
|
|
477
|
+
remaining = degrees[1:]
|
|
478
|
+
sorted_remaining = sorted(remaining, key=clockwise_distance)
|
|
479
|
+
|
|
480
|
+
# Return the reference followed by the sorted remaining elements
|
|
481
|
+
return [reference] + sorted_remaining
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def inline_css_variables_in_svg(svg_content: str) -> str:
|
|
485
|
+
"""
|
|
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.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
svg_content: The original SVG string with CSS variables
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Modified SVG with CSS variables inlined and style blocks removed
|
|
496
|
+
"""
|
|
497
|
+
# Find and extract CSS custom properties from style tags
|
|
498
|
+
css_variable_map = {}
|
|
499
|
+
style_tag_pattern = re.compile(r"<style.*?>(.*?)</style>", re.DOTALL)
|
|
500
|
+
style_blocks = style_tag_pattern.findall(svg_content)
|
|
501
|
+
|
|
502
|
+
# Parse all CSS custom properties from style blocks
|
|
503
|
+
for style_block in style_blocks:
|
|
504
|
+
# Match patterns like --color-primary: #ff0000;
|
|
505
|
+
css_variable_pattern = re.compile(r"--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);")
|
|
506
|
+
for match in css_variable_pattern.finditer(style_block):
|
|
507
|
+
variable_name = match.group(1)
|
|
508
|
+
variable_value = match.group(2).strip()
|
|
509
|
+
css_variable_map[f"--{variable_name}"] = variable_value
|
|
510
|
+
|
|
511
|
+
# Remove all style blocks from the SVG
|
|
512
|
+
svg_without_style_blocks = style_tag_pattern.sub("", svg_content)
|
|
513
|
+
|
|
514
|
+
# Function to replace var() references with their actual values
|
|
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
|
+
"""
|
|
525
|
+
variable_name = match.group(1).strip()
|
|
526
|
+
fallback_value = match.group(2) if match.group(2) else None
|
|
527
|
+
|
|
528
|
+
if variable_name in css_variable_map:
|
|
529
|
+
return css_variable_map[variable_name]
|
|
530
|
+
elif fallback_value:
|
|
531
|
+
return fallback_value.strip(", ")
|
|
532
|
+
else:
|
|
533
|
+
return "" # If variable not found and no fallback provided
|
|
534
|
+
|
|
535
|
+
# Pattern to match var(--name) or var(--name, fallback)
|
|
536
|
+
variable_usage_pattern = re.compile(r"var\(\s*(--([\w-]+))\s*(,\s*([^)]+))?\s*\)")
|
|
537
|
+
|
|
538
|
+
# Repeatedly replace all var() references until none remain
|
|
539
|
+
# This handles nested variables or variables that reference other variables
|
|
540
|
+
processed_svg = svg_without_style_blocks
|
|
541
|
+
while variable_usage_pattern.search(processed_svg):
|
|
542
|
+
processed_svg = variable_usage_pattern.sub(lambda m: replace_css_variable_reference(m), processed_svg)
|
|
543
|
+
|
|
544
|
+
return processed_svg
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def datetime_to_julian(dt: datetime) -> float:
|
|
548
|
+
"""
|
|
549
|
+
Convert a Python datetime object to Julian Day Number.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
dt: The datetime object to convert
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
The corresponding Julian Day Number (JD) as a float
|
|
556
|
+
"""
|
|
557
|
+
# Extract year, month and day
|
|
558
|
+
year = dt.year
|
|
559
|
+
month = dt.month
|
|
560
|
+
day = dt.day
|
|
561
|
+
|
|
562
|
+
# Adjust month and year according to the conversion formula
|
|
563
|
+
if month <= 2:
|
|
564
|
+
year -= 1
|
|
565
|
+
month += 12
|
|
566
|
+
|
|
567
|
+
# Calculate century and year in century
|
|
568
|
+
a = year // 100
|
|
569
|
+
b = 2 - a + (a // 4)
|
|
570
|
+
|
|
571
|
+
# Calculate the Julian day
|
|
572
|
+
jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + b - 1524.5
|
|
573
|
+
|
|
574
|
+
# Add the time portion
|
|
575
|
+
hour = dt.hour
|
|
576
|
+
minute = dt.minute
|
|
577
|
+
second = dt.second
|
|
578
|
+
microsecond = dt.microsecond
|
|
579
|
+
|
|
580
|
+
jd += (hour + minute / 60 + second / 3600 + microsecond / 3600000000) / 24
|
|
581
|
+
|
|
582
|
+
return jd
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def julian_to_datetime(jd):
|
|
586
|
+
"""
|
|
587
|
+
Convert a Julian Day Number to a Python datetime object.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
jd: Julian Day Number as a float
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
The corresponding datetime object
|
|
594
|
+
"""
|
|
595
|
+
# Add 0.5 to the Julian day to adjust for noon-based Julian day
|
|
596
|
+
jd_plus = jd + 0.5
|
|
597
|
+
|
|
598
|
+
# Integer and fractional parts
|
|
599
|
+
Z = int(jd_plus)
|
|
600
|
+
F = jd_plus - Z
|
|
601
|
+
|
|
602
|
+
# Calculate alpha
|
|
603
|
+
if Z < 2299161:
|
|
604
|
+
A = Z # Julian calendar
|
|
605
|
+
else:
|
|
606
|
+
alpha = int((Z - 1867216.25) / 36524.25)
|
|
607
|
+
A = Z + 1 + alpha - int(alpha / 4) # Gregorian calendar
|
|
608
|
+
|
|
609
|
+
# Calculate B
|
|
610
|
+
B = A + 1524
|
|
611
|
+
|
|
612
|
+
# Calculate C
|
|
613
|
+
C = int((B - 122.1) / 365.25)
|
|
614
|
+
|
|
615
|
+
# Calculate D
|
|
616
|
+
D = int(365.25 * C)
|
|
617
|
+
|
|
618
|
+
# Calculate E
|
|
619
|
+
E = int((B - D) / 30.6001)
|
|
620
|
+
|
|
621
|
+
# Calculate day and month
|
|
622
|
+
day = B - D - int(30.6001 * E) + F
|
|
623
|
+
|
|
624
|
+
# Integer part of day
|
|
625
|
+
day_int = int(day)
|
|
626
|
+
|
|
627
|
+
# Fractional part converted to hours, minutes, seconds, microseconds
|
|
628
|
+
day_frac = day - day_int
|
|
629
|
+
hours = int(day_frac * 24)
|
|
630
|
+
minutes = int((day_frac * 24 - hours) * 60)
|
|
631
|
+
seconds = int((day_frac * 24 * 60 - hours * 60 - minutes) * 60)
|
|
632
|
+
microseconds = int(((day_frac * 24 * 60 - hours * 60 - minutes) * 60 - seconds) * 1000000)
|
|
633
|
+
|
|
634
|
+
# Calculate month
|
|
635
|
+
if E < 14:
|
|
636
|
+
month = E - 1
|
|
637
|
+
else:
|
|
638
|
+
month = E - 13
|
|
639
|
+
|
|
640
|
+
# Calculate year
|
|
641
|
+
if month > 2:
|
|
642
|
+
year = C - 4716
|
|
643
|
+
else:
|
|
644
|
+
year = C - 4715
|
|
645
|
+
|
|
646
|
+
# Create and return datetime object
|
|
647
|
+
return datetime(year, month, day_int, hours, minutes, seconds, microseconds)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def get_house_name(house_number: int) -> Houses:
|
|
651
|
+
"""
|
|
652
|
+
Convert a house number to its corresponding house name.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
house_number: House number (1-12)
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
The house name
|
|
659
|
+
|
|
660
|
+
Raises:
|
|
661
|
+
ValueError: If house_number is not in range 1-12
|
|
662
|
+
"""
|
|
663
|
+
house_names: dict[int, Houses] = {
|
|
664
|
+
1: "First_House",
|
|
665
|
+
2: "Second_House",
|
|
666
|
+
3: "Third_House",
|
|
667
|
+
4: "Fourth_House",
|
|
668
|
+
5: "Fifth_House",
|
|
669
|
+
6: "Sixth_House",
|
|
670
|
+
7: "Seventh_House",
|
|
671
|
+
8: "Eighth_House",
|
|
672
|
+
9: "Ninth_House",
|
|
673
|
+
10: "Tenth_House",
|
|
674
|
+
11: "Eleventh_House",
|
|
675
|
+
12: "Twelfth_House",
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
name = house_names.get(house_number, None)
|
|
679
|
+
if name is None:
|
|
680
|
+
raise ValueError(f"Invalid house number: {house_number}")
|
|
681
|
+
|
|
682
|
+
return name
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def get_house_number(house_name: Houses) -> int:
|
|
686
|
+
"""
|
|
687
|
+
Convert a house name to its corresponding house number.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
house_name: The house name
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
House number (1-12)
|
|
694
|
+
|
|
695
|
+
Raises:
|
|
696
|
+
ValueError: If house_name is not recognized
|
|
697
|
+
"""
|
|
698
|
+
house_numbers: dict[Houses, int] = {
|
|
699
|
+
"First_House": 1,
|
|
700
|
+
"Second_House": 2,
|
|
701
|
+
"Third_House": 3,
|
|
702
|
+
"Fourth_House": 4,
|
|
703
|
+
"Fifth_House": 5,
|
|
704
|
+
"Sixth_House": 6,
|
|
705
|
+
"Seventh_House": 7,
|
|
706
|
+
"Eighth_House": 8,
|
|
707
|
+
"Ninth_House": 9,
|
|
708
|
+
"Tenth_House": 10,
|
|
709
|
+
"Eleventh_House": 11,
|
|
710
|
+
"Twelfth_House": 12,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
number = house_numbers.get(house_name, None)
|
|
714
|
+
if number is None:
|
|
715
|
+
raise ValueError(f"Invalid house name: {house_name}")
|
|
716
|
+
|
|
717
|
+
return number
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def find_common_active_points(first_points: list[AstrologicalPoint], second_points: list[AstrologicalPoint]) -> list[AstrologicalPoint]:
|
|
721
|
+
"""
|
|
722
|
+
Find astrological points that appear in both input lists.
|
|
723
|
+
|
|
724
|
+
Args:
|
|
725
|
+
first_points: First list of astrological points
|
|
726
|
+
second_points: Second list of astrological points
|
|
727
|
+
|
|
728
|
+
Returns:
|
|
729
|
+
List of points common to both input lists (without duplicates)
|
|
730
|
+
"""
|
|
731
|
+
common_points = list(set(first_points) & set(second_points))
|
|
732
|
+
|
|
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
|