kerykeion 3.1.1__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 +58 -141
- kerykeion/aspects/__init__.py +14 -0
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +164 -0
- kerykeion/astrological_subject_factory.py +1901 -0
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/__init__.py +5 -0
- kerykeion/charts/chart_drawer.py +2794 -0
- kerykeion/charts/charts_utils.py +1840 -0
- kerykeion/charts/draw_planets.py +658 -0
- kerykeion/charts/templates/aspect_grid_only.xml +596 -0
- kerykeion/charts/templates/chart.xml +741 -0
- kerykeion/charts/templates/wheel_only.xml +653 -0
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +113 -0
- kerykeion/charts/themes/dark-high-contrast.css +159 -0
- kerykeion/charts/themes/dark.css +160 -0
- kerykeion/charts/themes/light.css +160 -0
- 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 +105 -61
- 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 +70 -0
- kerykeion/kr_types/chart_template_model.py +20 -0
- kerykeion/kr_types/kerykeion_exception.py +20 -0
- kerykeion/kr_types/kr_literals.py +20 -0
- kerykeion/kr_types/kr_models.py +20 -0
- kerykeion/kr_types/settings_models.py +20 -0
- kerykeion/planetary_return_factory.py +805 -0
- kerykeion/relationship_score_factory.py +301 -0
- kerykeion/report.py +779 -0
- 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 -0
- kerykeion/settings/chart_defaults.py +444 -0
- kerykeion/settings/config_constants.py +152 -0
- kerykeion/settings/kerykeion_settings.py +51 -0
- kerykeion/settings/translation_strings.py +1499 -0
- kerykeion/settings/translations.py +74 -0
- kerykeion/sweph/README.md +3 -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/seas_18.se1 +0 -0
- kerykeion/sweph/sefstars.txt +1602 -0
- kerykeion/transits_time_range_factory.py +302 -0
- kerykeion/utilities.py +762 -130
- kerykeion-5.1.9.dist-info/METADATA +1793 -0
- kerykeion-5.1.9.dist-info/RECORD +63 -0
- {kerykeion-3.1.1.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -2
- kerykeion-5.1.9.dist-info/licenses/LICENSE +661 -0
- kerykeion/aspects.py +0 -331
- kerykeion/charts/charts_svg.py +0 -1607
- kerykeion/charts/templates/basic.xml +0 -285
- kerykeion/charts/templates/extended.xml +0 -294
- kerykeion/kr.config.json +0 -464
- kerykeion/main.py +0 -595
- kerykeion/print_all_data.py +0 -44
- kerykeion/relationship_score.py +0 -219
- kerykeion/types.py +0 -190
- kerykeion-3.1.1.dist-info/METADATA +0 -204
- kerykeion-3.1.1.dist-info/RECORD +0 -17
- kerykeion-3.1.1.dist-info/top_level.txt +0 -1
kerykeion/utilities.py
CHANGED
|
@@ -1,144 +1,776 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
elif degree < 150:
|
|
66
|
-
result = degree - 120
|
|
67
|
-
dictionary = {"name": number_name, "quality": "Fixed", "element":
|
|
68
|
-
"Fire", "sign": "Leo", "sign_num": 4, "position": result, "abs_pos": degree,
|
|
69
|
-
"emoji": "♌️", "point_type": point_type}
|
|
70
|
-
elif degree < 180:
|
|
71
|
-
result = degree - 150
|
|
72
|
-
dictionary = {"name": number_name, "quality": "Mutable", "element":
|
|
73
|
-
"Earth", "sign": "Vir", "sign_num": 5, "position": result, "abs_pos": degree,
|
|
74
|
-
"emoji": "♍️", "point_type": point_type}
|
|
75
|
-
elif degree < 210:
|
|
76
|
-
result = degree - 180
|
|
77
|
-
dictionary = {"name": number_name, "quality": "Cardinal", "element":
|
|
78
|
-
"Air", "sign": "Lib", "sign_num": 6, "position": result, "abs_pos": degree,
|
|
79
|
-
"emoji": "♎️", "point_type": point_type}
|
|
80
|
-
elif degree < 240:
|
|
81
|
-
result = degree - 210
|
|
82
|
-
dictionary = {"name": number_name, "quality": "Fixed", "element":
|
|
83
|
-
"Water", "sign": "Sco", "sign_num": 7, "position": result, "abs_pos": degree,
|
|
84
|
-
"emoji": "♏️", "point_type": point_type}
|
|
85
|
-
elif degree < 270:
|
|
86
|
-
result = degree - 240
|
|
87
|
-
dictionary = {"name": number_name, "quality": "Mutable", "element":
|
|
88
|
-
"Fire", "sign": "Sag", "sign_num": 8, "position": result, "abs_pos": degree,
|
|
89
|
-
"emoji": "♐️", "point_type": point_type}
|
|
90
|
-
elif degree < 300:
|
|
91
|
-
result = degree - 270
|
|
92
|
-
dictionary = {"name": number_name, "quality": "Cardinal", "element":
|
|
93
|
-
"Earth", "sign": "Cap", "sign_num": 9, "position": result, "abs_pos": degree,
|
|
94
|
-
"emoji": "♑️", "point_type": point_type}
|
|
95
|
-
elif degree < 330:
|
|
96
|
-
result = degree - 300
|
|
97
|
-
dictionary = {"name": number_name, "quality": "Fixed", "element":
|
|
98
|
-
"Air", "sign": "Aqu", "sign_num": 10, "position": result, "abs_pos": degree,
|
|
99
|
-
"emoji": "♒️", "point_type": point_type}
|
|
100
|
-
elif degree < 360:
|
|
101
|
-
result = degree - 330
|
|
102
|
-
dictionary = {"name": number_name, "quality": "Mutable", "element":
|
|
103
|
-
"Water", "sign": "Pis", "sign_num": 11, "position": result, "abs_pos": degree,
|
|
104
|
-
"emoji": "♓️", "point_type": point_type}
|
|
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
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
|
|
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")
|
|
105
65
|
else:
|
|
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
|
+
}
|
|
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)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_number_from_name(name: AstrologicalPoint) -> int:
|
|
118
|
+
"""
|
|
119
|
+
Convert an astrological point name to its corresponding numerical identifier.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
name: The name of the astrological point
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The numerical identifier used in Swiss Ephemeris calculations
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
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)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A KerykeionPointModel with calculated zodiac sign, position, and properties
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
KerykeionException: If the degree is >= 360 after normalization
|
|
155
|
+
"""
|
|
156
|
+
# If - single degree is given, convert it to a positive degree
|
|
157
|
+
if degree < 0:
|
|
158
|
+
degree = degree % 360
|
|
159
|
+
|
|
160
|
+
if degree >= 360:
|
|
161
|
+
raise KerykeionException(f"Error in calculating positions! Degrees: {degree}")
|
|
162
|
+
|
|
163
|
+
ZODIAC_SIGNS = {
|
|
164
|
+
0: ZodiacSignModel(sign="Ari", quality="Cardinal", element="Fire", emoji="♈️", sign_num=0),
|
|
165
|
+
1: ZodiacSignModel(sign="Tau", quality="Fixed", element="Earth", emoji="♉️", sign_num=1),
|
|
166
|
+
2: ZodiacSignModel(sign="Gem", quality="Mutable", element="Air", emoji="♊️", sign_num=2),
|
|
167
|
+
3: ZodiacSignModel(sign="Can", quality="Cardinal", element="Water", emoji="♋️", sign_num=3),
|
|
168
|
+
4: ZodiacSignModel(sign="Leo", quality="Fixed", element="Fire", emoji="♌️", sign_num=4),
|
|
169
|
+
5: ZodiacSignModel(sign="Vir", quality="Mutable", element="Earth", emoji="♍️", sign_num=5),
|
|
170
|
+
6: ZodiacSignModel(sign="Lib", quality="Cardinal", element="Air", emoji="♎️", sign_num=6),
|
|
171
|
+
7: ZodiacSignModel(sign="Sco", quality="Fixed", element="Water", emoji="♏️", sign_num=7),
|
|
172
|
+
8: ZodiacSignModel(sign="Sag", quality="Mutable", element="Fire", emoji="♐️", sign_num=8),
|
|
173
|
+
9: ZodiacSignModel(sign="Cap", quality="Cardinal", element="Earth", emoji="♑️", sign_num=9),
|
|
174
|
+
10: ZodiacSignModel(sign="Aqu", quality="Fixed", element="Air", emoji="♒️", sign_num=10),
|
|
175
|
+
11: ZodiacSignModel(sign="Pis", quality="Mutable", element="Water", emoji="♓️", sign_num=11),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
sign_index = int(degree // 30)
|
|
179
|
+
sign_degree = degree % 30
|
|
180
|
+
zodiac_sign = ZODIAC_SIGNS[sign_index]
|
|
181
|
+
return KerykeionPointModel(
|
|
182
|
+
name=name,
|
|
183
|
+
quality=zodiac_sign.quality,
|
|
184
|
+
element=zodiac_sign.element,
|
|
185
|
+
sign=zodiac_sign.sign,
|
|
186
|
+
sign_num=zodiac_sign.sign_num,
|
|
187
|
+
position=sign_degree,
|
|
188
|
+
abs_pos=degree,
|
|
189
|
+
emoji=zodiac_sign.emoji,
|
|
190
|
+
point_type=point_type,
|
|
191
|
+
speed=speed,
|
|
192
|
+
declination=declination,
|
|
193
|
+
)
|
|
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``."""
|
|
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:
|
|
106
206
|
raise KerykeionException(
|
|
107
|
-
f
|
|
207
|
+
f"The angle between start and end point is not allowed to exceed 180°, yet is: {span}"
|
|
208
|
+
)
|
|
209
|
+
if target == start:
|
|
210
|
+
return True
|
|
211
|
+
if target == end:
|
|
212
|
+
return False
|
|
213
|
+
distance_from_start = (target - start) % 360
|
|
214
|
+
return distance_from_start < span
|
|
215
|
+
|
|
216
|
+
# House helpers
|
|
217
|
+
def get_planet_house(planet_degree: Union[int, float], houses_degree_ut_list: list) -> Houses:
|
|
218
|
+
"""
|
|
219
|
+
Determine which house contains a planet based on its degree position.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
planet_degree: The planet's position in degrees (0-360)
|
|
223
|
+
houses_degree_ut_list: List of house cusp degrees
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The house name containing the planet
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If the planet's position doesn't fall within any house range
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
house_names = get_args(Houses)
|
|
233
|
+
|
|
234
|
+
# Iterate through the house boundaries to find the correct house
|
|
235
|
+
for i in range(len(house_names)):
|
|
236
|
+
start_degree = houses_degree_ut_list[i]
|
|
237
|
+
end_degree = houses_degree_ut_list[(i + 1) % len(houses_degree_ut_list)]
|
|
238
|
+
|
|
239
|
+
if is_point_between(start_degree, end_degree, planet_degree):
|
|
240
|
+
return house_names[i]
|
|
241
|
+
|
|
242
|
+
# If no house is found, raise an error
|
|
243
|
+
raise ValueError(f"Error in house calculation, planet: {planet_degree}, houses: {houses_degree_ut_list}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_moon_emoji_from_phase_int(phase: int) -> LunarPhaseEmoji:
|
|
247
|
+
"""
|
|
248
|
+
Get the emoji representation of a lunar phase.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
phase: The lunar phase number (0-28)
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
The corresponding emoji for the lunar phase
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
KerykeionException: If phase is outside valid range
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
lunar_phase_emojis = get_args(LunarPhaseEmoji)
|
|
261
|
+
|
|
262
|
+
if phase == 1:
|
|
263
|
+
result = lunar_phase_emojis[0]
|
|
264
|
+
elif phase < 7:
|
|
265
|
+
result = lunar_phase_emojis[1]
|
|
266
|
+
elif 7 <= phase <= 9:
|
|
267
|
+
result = lunar_phase_emojis[2]
|
|
268
|
+
elif phase < 14:
|
|
269
|
+
result = lunar_phase_emojis[3]
|
|
270
|
+
elif phase == 14:
|
|
271
|
+
result = lunar_phase_emojis[4]
|
|
272
|
+
elif phase < 20:
|
|
273
|
+
result = lunar_phase_emojis[5]
|
|
274
|
+
elif 20 <= phase <= 22:
|
|
275
|
+
result = lunar_phase_emojis[6]
|
|
276
|
+
elif phase <= 28:
|
|
277
|
+
result = lunar_phase_emojis[7]
|
|
278
|
+
|
|
279
|
+
else:
|
|
280
|
+
raise KerykeionException(f"Error in moon emoji calculation! Phase: {phase}")
|
|
281
|
+
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def get_moon_phase_name_from_phase_int(phase: int) -> LunarPhaseName:
|
|
286
|
+
"""
|
|
287
|
+
Get the name of a lunar phase from its numerical value.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
phase: The lunar phase number (0-28)
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The corresponding name for the lunar phase
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
KerykeionException: If phase is outside valid range
|
|
297
|
+
"""
|
|
298
|
+
lunar_phase_names = get_args(LunarPhaseName)
|
|
299
|
+
|
|
300
|
+
if phase == 1:
|
|
301
|
+
result = lunar_phase_names[0]
|
|
302
|
+
elif phase < 7:
|
|
303
|
+
result = lunar_phase_names[1]
|
|
304
|
+
elif 7 <= phase <= 9:
|
|
305
|
+
result = lunar_phase_names[2]
|
|
306
|
+
elif phase < 14:
|
|
307
|
+
result = lunar_phase_names[3]
|
|
308
|
+
elif phase == 14:
|
|
309
|
+
result = lunar_phase_names[4]
|
|
310
|
+
elif phase < 20:
|
|
311
|
+
result = lunar_phase_names[5]
|
|
312
|
+
elif 20 <= phase <= 22:
|
|
313
|
+
result = lunar_phase_names[6]
|
|
314
|
+
elif phase <= 28:
|
|
315
|
+
result = lunar_phase_names[7]
|
|
316
|
+
|
|
317
|
+
else:
|
|
318
|
+
raise KerykeionException(f"Error in moon name calculation! Phase: {phase}")
|
|
319
|
+
|
|
320
|
+
return result
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def check_and_adjust_polar_latitude(latitude: float) -> float:
|
|
324
|
+
"""
|
|
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°
|
|
334
|
+
"""
|
|
335
|
+
if latitude > 66.0:
|
|
336
|
+
latitude = 66.0
|
|
337
|
+
logger.info("Latitude capped at 66° to keep house calculations stable.")
|
|
338
|
+
|
|
339
|
+
elif latitude < -66.0:
|
|
340
|
+
latitude = -66.0
|
|
341
|
+
logger.info("Latitude capped at -66° to keep house calculations stable.")
|
|
342
|
+
|
|
343
|
+
return latitude
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def get_houses_list(
|
|
347
|
+
subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
|
|
348
|
+
) -> list[KerykeionPointModel]:
|
|
349
|
+
"""
|
|
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
|
|
357
|
+
"""
|
|
358
|
+
houses_absolute_position_list = []
|
|
359
|
+
for house in subject.houses_names_list:
|
|
360
|
+
houses_absolute_position_list.append(subject[house.lower()])
|
|
361
|
+
|
|
362
|
+
return houses_absolute_position_list
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def get_available_astrological_points_list(
|
|
366
|
+
subject: AstrologicalSubjectModel
|
|
367
|
+
) -> list[KerykeionPointModel]:
|
|
368
|
+
"""
|
|
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
|
|
376
|
+
"""
|
|
377
|
+
planets_absolute_position_list = []
|
|
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.
|
|
387
|
+
|
|
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
|
|
108
405
|
|
|
109
|
-
return
|
|
406
|
+
return mean_position
|
|
110
407
|
|
|
111
408
|
|
|
112
|
-
def
|
|
409
|
+
def calculate_moon_phase(moon_abs_pos: float, sun_abs_pos: float) -> LunarPhaseModel:
|
|
113
410
|
"""
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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):
|
|
117
516
|
"""
|
|
517
|
+
Replace CSS variable references with their actual values.
|
|
118
518
|
|
|
119
|
-
|
|
519
|
+
Args:
|
|
520
|
+
match: Regular expression match object containing variable name and optional fallback.
|
|
120
521
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
130
605
|
else:
|
|
131
|
-
|
|
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)
|
|
132
617
|
|
|
133
|
-
|
|
618
|
+
# Calculate E
|
|
619
|
+
E = int((B - D) / 30.6001)
|
|
134
620
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"'", '"')) # type: ignore TODO: Fix this
|
|
621
|
+
# Calculate day and month
|
|
622
|
+
day = B - D - int(30.6001 * E) + F
|
|
138
623
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
142
643
|
else:
|
|
143
|
-
|
|
144
|
-
|
|
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
|