kerykeion 5.0.0b1__py3-none-any.whl → 5.0.0b4__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.

Files changed (30) hide show
  1. kerykeion/__init__.py +3 -2
  2. kerykeion/aspects/aspects_factory.py +60 -21
  3. kerykeion/aspects/aspects_utils.py +1 -1
  4. kerykeion/backword.py +111 -18
  5. kerykeion/chart_data_factory.py +72 -7
  6. kerykeion/charts/chart_drawer.py +601 -206
  7. kerykeion/charts/charts_utils.py +440 -255
  8. kerykeion/charts/templates/aspect_grid_only.xml +269 -312
  9. kerykeion/charts/templates/chart.xml +302 -328
  10. kerykeion/charts/templates/wheel_only.xml +271 -312
  11. kerykeion/charts/themes/black-and-white.css +148 -0
  12. kerykeion/relationship_score_factory.py +12 -2
  13. kerykeion/schemas/chart_template_model.py +27 -0
  14. kerykeion/schemas/kr_literals.py +1 -1
  15. kerykeion/settings/__init__.py +16 -2
  16. kerykeion/settings/chart_defaults.py +444 -0
  17. kerykeion/settings/config_constants.py +0 -5
  18. kerykeion/settings/kerykeion_settings.py +31 -74
  19. kerykeion/settings/translation_strings.py +1479 -0
  20. kerykeion/settings/translations.py +74 -0
  21. kerykeion/transits_time_range_factory.py +10 -1
  22. {kerykeion-5.0.0b1.dist-info → kerykeion-5.0.0b4.dist-info}/METADATA +304 -204
  23. {kerykeion-5.0.0b1.dist-info → kerykeion-5.0.0b4.dist-info}/RECORD +25 -26
  24. kerykeion/settings/kr.config.json +0 -1474
  25. kerykeion/settings/legacy/__init__.py +0 -0
  26. kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
  27. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
  28. kerykeion/settings/legacy/legacy_color_settings.py +0 -42
  29. {kerykeion-5.0.0b1.dist-info → kerykeion-5.0.0b4.dist-info}/WHEEL +0 -0
  30. {kerykeion-5.0.0b1.dist-info → kerykeion-5.0.0b4.dist-info}/licenses/LICENSE +0 -0
@@ -1,12 +1,231 @@
1
1
  import math
2
2
  import datetime
3
+ from typing import Mapping, Optional, Sequence, Union, Literal
4
+
3
5
  from kerykeion.schemas import KerykeionException, ChartType
4
6
  from kerykeion.schemas.kr_literals import AstrologicalPoint
5
- from typing import Union, Literal
6
7
  from kerykeion.schemas.kr_models import AspectModel, KerykeionPointModel, CompositeSubjectModel, PlanetReturnModel, AstrologicalSubjectModel, HouseComparisonModel
7
8
  from kerykeion.schemas.settings_models import KerykeionLanguageCelestialPointModel, KerykeionSettingsCelestialPointModel
8
9
 
9
10
 
11
+ ElementQualityDistributionMethod = Literal["pure_count", "weighted"]
12
+ """Supported strategies for calculating element and modality distributions."""
13
+
14
+ _SIGN_TO_ELEMENT: tuple[str, ...] = (
15
+ "fire", # Aries
16
+ "earth", # Taurus
17
+ "air", # Gemini
18
+ "water", # Cancer
19
+ "fire", # Leo
20
+ "earth", # Virgo
21
+ "air", # Libra
22
+ "water", # Scorpio
23
+ "fire", # Sagittarius
24
+ "earth", # Capricorn
25
+ "air", # Aquarius
26
+ "water", # Pisces
27
+ )
28
+
29
+ _SIGN_TO_QUALITY: tuple[str, ...] = (
30
+ "cardinal", # Aries
31
+ "fixed", # Taurus
32
+ "mutable", # Gemini
33
+ "cardinal", # Cancer
34
+ "fixed", # Leo
35
+ "mutable", # Virgo
36
+ "cardinal", # Libra
37
+ "fixed", # Scorpio
38
+ "mutable", # Sagittarius
39
+ "cardinal", # Capricorn
40
+ "fixed", # Aquarius
41
+ "mutable", # Pisces
42
+ )
43
+
44
+ _ELEMENT_KEYS: tuple[str, ...] = ("fire", "earth", "air", "water")
45
+ _QUALITY_KEYS: tuple[str, ...] = ("cardinal", "fixed", "mutable")
46
+
47
+ _DEFAULT_WEIGHTED_FALLBACK = 1.0
48
+ DEFAULT_WEIGHTED_POINT_WEIGHTS: dict[str, float] = {
49
+ # Core luminaries & angles
50
+ "sun": 2.0,
51
+ "moon": 2.0,
52
+ "ascendant": 2.0,
53
+ "medium_coeli": 1.5,
54
+ "descendant": 1.5,
55
+ "imum_coeli": 1.5,
56
+ "vertex": 0.8,
57
+ "anti_vertex": 0.8,
58
+ # Personal planets
59
+ "mercury": 1.5,
60
+ "venus": 1.5,
61
+ "mars": 1.5,
62
+ # Social planets
63
+ "jupiter": 1.0,
64
+ "saturn": 1.0,
65
+ # Outer/transpersonal
66
+ "uranus": 0.5,
67
+ "neptune": 0.5,
68
+ "pluto": 0.5,
69
+ # Lunar nodes (mean/true variants)
70
+ "mean_north_lunar_node": 0.5,
71
+ "true_north_lunar_node": 0.5,
72
+ "mean_south_lunar_node": 0.5,
73
+ "true_south_lunar_node": 0.5,
74
+ # Chiron, Lilith variants
75
+ "chiron": 0.6,
76
+ "mean_lilith": 0.5,
77
+ "true_lilith": 0.5,
78
+ # Asteroids / centaurs
79
+ "ceres": 0.5,
80
+ "pallas": 0.4,
81
+ "juno": 0.4,
82
+ "vesta": 0.4,
83
+ "pholus": 0.3,
84
+ # Dwarf planets & TNOs
85
+ "eris": 0.3,
86
+ "sedna": 0.3,
87
+ "haumea": 0.3,
88
+ "makemake": 0.3,
89
+ "ixion": 0.3,
90
+ "orcus": 0.3,
91
+ "quaoar": 0.3,
92
+ # Arabic Parts
93
+ "pars_fortunae": 0.8,
94
+ "pars_spiritus": 0.7,
95
+ "pars_amoris": 0.6,
96
+ "pars_fidei": 0.6,
97
+ # Fixed stars
98
+ "regulus": 0.2,
99
+ "spica": 0.2,
100
+ # Other
101
+ "earth": 0.3,
102
+ }
103
+
104
+
105
+ def _prepare_weight_lookup(
106
+ method: ElementQualityDistributionMethod,
107
+ custom_weights: Optional[Mapping[str, float]] = None,
108
+ ) -> tuple[dict[str, float], float]:
109
+ """
110
+ Normalize and merge default weights with any custom overrides.
111
+
112
+ Args:
113
+ method: Calculation strategy to use.
114
+ custom_weights: Optional mapping of point name (case-insensitive) to weight.
115
+ Supports special key "__default__" as fallback weight.
116
+
117
+ Returns:
118
+ A tuple containing the weight lookup dictionary and fallback weight.
119
+ """
120
+ normalized_custom = (
121
+ {key.lower(): float(value) for key, value in custom_weights.items()}
122
+ if custom_weights
123
+ else {}
124
+ )
125
+
126
+ if method == "weighted":
127
+ weight_lookup: dict[str, float] = dict(DEFAULT_WEIGHTED_POINT_WEIGHTS)
128
+ fallback_weight = _DEFAULT_WEIGHTED_FALLBACK
129
+ else:
130
+ weight_lookup = {}
131
+ fallback_weight = 1.0
132
+
133
+ fallback_weight = normalized_custom.get("__default__", fallback_weight)
134
+
135
+ for key, value in normalized_custom.items():
136
+ if key == "__default__":
137
+ continue
138
+ weight_lookup[key] = float(value)
139
+
140
+ return weight_lookup, fallback_weight
141
+
142
+
143
+ def _calculate_distribution_for_subject(
144
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
145
+ celestial_points_names: Sequence[str],
146
+ sign_to_group_map: Sequence[str],
147
+ group_keys: Sequence[str],
148
+ weight_lookup: Mapping[str, float],
149
+ fallback_weight: float,
150
+ ) -> dict[str, float]:
151
+ """
152
+ Accumulate distribution totals for a single subject.
153
+
154
+ Args:
155
+ subject: Subject providing planetary positions.
156
+ celestial_points_names: Names of celestial points to consider (lowercase).
157
+ sign_to_group_map: Mapping from sign index to element/modality key.
158
+ group_keys: Iterable of expected keys for the resulting totals.
159
+ weight_lookup: Precomputed mapping of weights per point.
160
+ fallback_weight: Default weight if point missing in lookup.
161
+
162
+ Returns:
163
+ Dictionary with accumulated totals keyed by element/modality.
164
+ """
165
+ totals = {key: 0.0 for key in group_keys}
166
+
167
+ for point_name in celestial_points_names:
168
+ point = subject.get(point_name)
169
+ if point is None:
170
+ continue
171
+
172
+ sign_index = getattr(point, "sign_num", None)
173
+ if sign_index is None or not (0 <= sign_index < len(sign_to_group_map)):
174
+ continue
175
+
176
+ group_key = sign_to_group_map[sign_index]
177
+ weight = weight_lookup.get(point_name, fallback_weight)
178
+ totals[group_key] += weight
179
+
180
+ return totals
181
+
182
+
183
+ _SECOND_COLUMN_THRESHOLD = 20
184
+ _THIRD_COLUMN_THRESHOLD = 28
185
+ _FOURTH_COLUMN_THRESHOLD = 36
186
+
187
+ _DOUBLE_CHART_TYPES: tuple[ChartType, ...] = ("Synastry", "Transit", "DualReturnChart")
188
+ _GRID_COLUMN_WIDTH = 125
189
+
190
+
191
+ def _select_planet_grid_thresholds(chart_type: ChartType) -> tuple[int, int, int]:
192
+ """Return column thresholds for the planet grids based on chart type."""
193
+ if chart_type in _DOUBLE_CHART_TYPES:
194
+ return (
195
+ 1_000_000, # effectively disable first column
196
+ 1_000_008, # effectively disable second column
197
+ 1_000_016, # effectively disable third column
198
+ )
199
+ return _SECOND_COLUMN_THRESHOLD, _THIRD_COLUMN_THRESHOLD, _FOURTH_COLUMN_THRESHOLD
200
+
201
+
202
+ def _planet_grid_layout_position(
203
+ index: int, thresholds: Optional[tuple[int, int, int]] = None
204
+ ) -> tuple[int, int]:
205
+ """Return horizontal offset and row index for planet grids."""
206
+ second_threshold, third_threshold, fourth_threshold = (
207
+ thresholds
208
+ if thresholds is not None
209
+ else (_SECOND_COLUMN_THRESHOLD, _THIRD_COLUMN_THRESHOLD, _FOURTH_COLUMN_THRESHOLD)
210
+ )
211
+
212
+ if index < second_threshold:
213
+ column = 0
214
+ row = index
215
+ elif index < third_threshold:
216
+ column = 1
217
+ row = index - second_threshold
218
+ elif index < fourth_threshold:
219
+ column = 2
220
+ row = index - third_threshold
221
+ else:
222
+ column = 3
223
+ row = index - fourth_threshold
224
+
225
+ offset = -(_GRID_COLUMN_WIDTH * column)
226
+ return offset, row
227
+
228
+
10
229
 
11
230
  def get_decoded_kerykeion_celestial_point_name(input_planet_name: str, celestial_point_language: KerykeionLanguageCelestialPointModel) -> str:
12
231
  """
@@ -687,7 +906,8 @@ def draw_transit_aspect_list(
687
906
  aspects_per_column: int = 14,
688
907
  column_width: int = 100,
689
908
  line_height: int = 14,
690
- max_columns: int = 6
909
+ max_columns: int = 6,
910
+ chart_height: Optional[int] = None,
691
911
  ) -> str:
692
912
  """
693
913
  Generates the SVG output for the aspect transit grid.
@@ -701,6 +921,8 @@ def draw_transit_aspect_list(
701
921
  - column_width: Width in pixels for each column (default: 100).
702
922
  - line_height: Height in pixels for each line (default: 14).
703
923
  - max_columns: Maximum number of columns before vertical adjustment (default: 6).
924
+ - chart_height: Total chart height. When provided, columns from the 12th onward
925
+ leverage the taller layout capacity (default: None).
704
926
 
705
927
  Returns:
706
928
  - A string containing the SVG path data for the aspect transit grid.
@@ -716,61 +938,86 @@ def draw_transit_aspect_list(
716
938
  # Type narrowing: at this point aspects_list contains AspectModel instances
717
939
  typed_aspects_list: list[AspectModel] = aspects_list # type: ignore
718
940
 
941
+ translate_x = 565
942
+ translate_y = 273
943
+ title_clearance = 18
944
+ top_limit_y: float = -translate_y + title_clearance
945
+ bottom_padding = 40
946
+ baseline_index = aspects_per_column - 1
947
+ top_limit_index = math.ceil(top_limit_y / line_height)
948
+ # `top_limit_index` identifies the highest row index we can reach without
949
+ # touching the title block. Combined with the baseline index we know how many
950
+ # rows a "tall" column may contain.
951
+ max_capacity_by_top = baseline_index - top_limit_index + 1
952
+
719
953
  inner_path = ""
720
954
 
721
- for i, aspect in enumerate(typed_aspects_list):
722
- # Calculate which column this aspect belongs in
723
- current_column = i // aspects_per_column
724
-
725
- # Calculate horizontal position based on column
726
- horizontal_position = current_column * column_width
727
-
728
- # Calculate vertical position within the column
729
- current_line = i % aspects_per_column
730
- vertical_position = current_line * line_height
731
-
732
- # Special handling for many aspects - if we exceed max_columns
733
- # Bottom-align the overflow columns so the list starts from the bottom
734
- if current_column >= max_columns:
735
- overflow_total = len(aspects_list) - (aspects_per_column * max_columns)
736
- if overflow_total > 0:
737
- # Index within the overflow sequence (beyond the first row of columns)
738
- overflow_index = i - (aspects_per_column * max_columns)
739
- # Which overflow column we are in (0-based)
740
- overflow_col_idx = overflow_index // aspects_per_column
741
- # How many items go into this overflow column
742
- items_in_this_column = min(
743
- aspects_per_column,
744
- max(0, overflow_total - (overflow_col_idx * aspects_per_column)),
745
- )
746
- # Compute extra top offset (in lines) to bottom-align this column
747
- top_offset_lines = max(0, aspects_per_column - items_in_this_column)
748
- vertical_position = (top_offset_lines + (i % aspects_per_column)) * line_height
749
-
750
- inner_path += f'<g transform="translate({horizontal_position},{vertical_position})">'
751
-
752
- # First planet symbol
753
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p1"]]["name"]}" />'
754
-
755
- # Aspect symbol
756
- aspect_name = aspect["aspect"]
757
- id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
758
- inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
759
-
760
- # Second planet symbol
761
- inner_path += '<g transform="translate(30,0)">'
762
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p2"]]["name"]}" />'
763
- inner_path += "</g>"
764
-
765
- # Difference in degrees
766
- inner_path += f'<text y="8" x="45" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 10px;">{convert_decimal_to_degree_string(aspect["orbit"])}</text>'
767
-
768
- inner_path += "</g>"
769
-
770
- out = '<g transform="translate(565,273)">'
955
+ full_height_column_index = 10 # 0-based index → 11th column onward
956
+ if chart_height is not None:
957
+ available_height = max(chart_height - translate_y - bottom_padding, line_height)
958
+ allowed_capacity = max(aspects_per_column, int(available_height // line_height))
959
+ full_height_capacity = max(aspects_per_column, min(allowed_capacity, max_capacity_by_top))
960
+ else:
961
+ full_height_capacity = aspects_per_column
962
+
963
+ # Bucket aspects into columns while respecting the capacity of each column.
964
+ columns: list[list[AspectModel]] = []
965
+ column_capacities: list[int] = []
966
+
967
+ for aspect in typed_aspects_list:
968
+ if not columns or len(columns[-1]) >= column_capacities[-1]:
969
+ new_col_index = len(columns)
970
+ capacity = aspects_per_column if new_col_index < full_height_column_index else full_height_capacity
971
+ capacity = max(capacity, 1)
972
+ columns.append([])
973
+ column_capacities.append(capacity)
974
+ columns[-1].append(aspect)
975
+
976
+ for col_idx, column in enumerate(columns):
977
+ capacity = column_capacities[col_idx]
978
+ horizontal_position = col_idx * column_width
979
+ column_len = len(column)
980
+
981
+ for row_idx, aspect in enumerate(column):
982
+ # Default top-aligned placement
983
+ vertical_position = row_idx * line_height
984
+
985
+ # Full-height columns reuse the shared baseline so every column
986
+ # finishes at the same vertical position and grows upwards.
987
+ if col_idx >= full_height_column_index:
988
+ vertical_index = baseline_index - (column_len - 1 - row_idx)
989
+ vertical_position = vertical_index * line_height
990
+ # Legacy overflow columns (before the 12th) keep the older behaviour:
991
+ # once we exceed the configured column count, bottom-align the content
992
+ # so the shorter columns do not look awkwardly padded at the top.
993
+ elif col_idx >= max_columns and capacity == aspects_per_column:
994
+ top_offset_lines = max(0, capacity - len(column))
995
+ vertical_position = (top_offset_lines + row_idx) * line_height
996
+
997
+ inner_path += f'<g transform="translate({horizontal_position},{vertical_position})">'
998
+
999
+ # First planet symbol
1000
+ inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p1"]]["name"]}" />'
1001
+
1002
+ # Aspect symbol
1003
+ aspect_name = aspect["aspect"]
1004
+ id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
1005
+ inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
1006
+
1007
+ # Second planet symbol
1008
+ inner_path += '<g transform="translate(30,0)">'
1009
+ inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p2"]]["name"]}" />'
1010
+ inner_path += "</g>"
1011
+
1012
+ # Difference in degrees
1013
+ inner_path += f'<text y="8" x="45" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 10px;">{convert_decimal_to_degree_string(aspect["orbit"])}</text>'
1014
+
1015
+ inner_path += "</g>"
1016
+
1017
+ out = f'<g transform="translate({translate_x},{translate_y})">'
771
1018
  out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
772
1019
  out += inner_path
773
- out += '</g>'
1020
+ out += "</g>"
774
1021
 
775
1022
  return out
776
1023
 
@@ -960,16 +1207,13 @@ def draw_main_planet_grid(
960
1207
  f'</g>'
961
1208
  )
962
1209
 
963
- line_height = LINE_START
964
- offset = 0
965
-
966
1210
  end_of_line = "</g>"
967
1211
 
1212
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1213
+
968
1214
  for i, planet in enumerate(available_kerykeion_celestial_points):
969
- # Start a second column at item 23 (index 22)
970
- if i == 20:
971
- line_height = LINE_START
972
- offset = -125
1215
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1216
+ line_height = LINE_START + (row_index * LINE_STEP)
973
1217
 
974
1218
  decoded_name = get_decoded_kerykeion_celestial_point_name(
975
1219
  planet["name"],
@@ -988,7 +1232,6 @@ def draw_main_planet_grid(
988
1232
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
989
1233
 
990
1234
  svg_output += end_of_line
991
- line_height += LINE_STEP
992
1235
 
993
1236
  # Close the wrapper group
994
1237
  svg_output += "</g>"
@@ -1051,13 +1294,18 @@ def draw_secondary_planet_grid(
1051
1294
  line_height = LINE_START
1052
1295
  end_of_line = "</g>"
1053
1296
 
1054
- for t_planet in second_subject_available_kerykeion_celestial_points:
1297
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1298
+
1299
+ for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
1300
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1301
+ line_height = LINE_START + (row_index * LINE_STEP)
1302
+
1055
1303
  second_decoded_name = get_decoded_kerykeion_celestial_point_name(
1056
1304
  t_planet["name"],
1057
1305
  celestial_point_language,
1058
1306
  )
1059
1307
  svg_output += (
1060
- f'<g transform="translate(0,{BASE_Y + line_height})">'
1308
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
1061
1309
  f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
1062
1310
  f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
1063
1311
  f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
@@ -1068,7 +1316,7 @@ def draw_secondary_planet_grid(
1068
1316
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1069
1317
 
1070
1318
  svg_output += end_of_line
1071
- line_height += LINE_STEP
1319
+
1072
1320
 
1073
1321
  # Close wrapper group
1074
1322
  svg_output += "</g>"
@@ -1197,121 +1445,89 @@ def format_datetime_with_timezone(iso_datetime_string: str) -> str:
1197
1445
 
1198
1446
 
1199
1447
  def calculate_element_points(
1200
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1201
- celestial_points_names: list[str],
1202
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1203
- ):
1448
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1449
+ celestial_points_names: Sequence[str],
1450
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1451
+ *,
1452
+ method: ElementQualityDistributionMethod = "weighted",
1453
+ custom_weights: Optional[Mapping[str, float]] = None,
1454
+ ) -> dict[str, float]:
1204
1455
  """
1205
- Calculate elemental point totals based on planetary positions.
1456
+ Calculate elemental totals for a subject using the selected strategy.
1206
1457
 
1207
1458
  Args:
1208
- planets_settings (list): List of planet configuration dictionaries
1209
- celestial_points_names (list): List of celestial point names to process
1210
- subject: Astrological subject with get() method for accessing planet data
1459
+ planets_settings: Planet configuration list (kept for API compatibility).
1460
+ celestial_points_names: Celestial point names to include.
1461
+ subject: Astrological subject with planetary data.
1462
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1463
+ custom_weights: Optional overrides for point weights keyed by name.
1211
1464
 
1212
1465
  Returns:
1213
- dict: Dictionary with element point totals for 'fire', 'earth', 'air', and 'water'
1214
- """
1215
- ZODIAC = (
1216
- {"name": "Ari", "element": "fire"},
1217
- {"name": "Tau", "element": "earth"},
1218
- {"name": "Gem", "element": "air"},
1219
- {"name": "Can", "element": "water"},
1220
- {"name": "Leo", "element": "fire"},
1221
- {"name": "Vir", "element": "earth"},
1222
- {"name": "Lib", "element": "air"},
1223
- {"name": "Sco", "element": "water"},
1224
- {"name": "Sag", "element": "fire"},
1225
- {"name": "Cap", "element": "earth"},
1226
- {"name": "Aqu", "element": "air"},
1227
- {"name": "Pis", "element": "water"},
1466
+ Dictionary mapping each element to its accumulated total.
1467
+ """
1468
+ normalized_names = [name.lower() for name in celestial_points_names]
1469
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1470
+
1471
+ return _calculate_distribution_for_subject(
1472
+ subject,
1473
+ normalized_names,
1474
+ _SIGN_TO_ELEMENT,
1475
+ _ELEMENT_KEYS,
1476
+ weight_lookup,
1477
+ fallback_weight,
1228
1478
  )
1229
1479
 
1230
- # Initialize element point totals
1231
- element_totals = {
1232
- "fire": 0.0,
1233
- "earth": 0.0,
1234
- "air": 0.0,
1235
- "water": 0.0
1236
- }
1237
-
1238
- # Make list of the points sign
1239
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1240
-
1241
- for i in range(len(planets_settings)):
1242
- # Add points to appropriate element
1243
- element = ZODIAC[points_sign[i]]["element"]
1244
- element_totals[element] += planets_settings[i]["element_points"]
1245
-
1246
- return element_totals
1247
-
1248
1480
 
1249
1481
  def calculate_synastry_element_points(
1250
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1251
- celestial_points_names: list[str],
1252
- subject1: AstrologicalSubjectModel,
1253
- subject2: AstrologicalSubjectModel,
1254
- ):
1482
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1483
+ celestial_points_names: Sequence[str],
1484
+ subject1: AstrologicalSubjectModel,
1485
+ subject2: AstrologicalSubjectModel,
1486
+ *,
1487
+ method: ElementQualityDistributionMethod = "weighted",
1488
+ custom_weights: Optional[Mapping[str, float]] = None,
1489
+ ) -> dict[str, float]:
1255
1490
  """
1256
- Calculate elemental point totals for both subjects in a synastry chart.
1491
+ Calculate combined element percentages for a synastry chart.
1257
1492
 
1258
1493
  Args:
1259
- planets_settings (list): List of planet configuration dictionaries
1260
- celestial_points_names (list): List of celestial point names to process
1261
- subject1: First astrological subject with get() method for accessing planet data
1262
- subject2: Second astrological subject with get() method for accessing planet data
1494
+ planets_settings: Planet configuration list (unused but preserved).
1495
+ celestial_points_names: Celestial point names to process.
1496
+ subject1: First astrological subject.
1497
+ subject2: Second astrological subject.
1498
+ method: Calculation strategy (pure_count or weighted).
1499
+ custom_weights: Optional overrides for point weights.
1263
1500
 
1264
1501
  Returns:
1265
- dict: Dictionary with element point totals as percentages, where the sum equals 100%
1266
- """
1267
- ZODIAC = (
1268
- {"name": "Ari", "element": "fire"},
1269
- {"name": "Tau", "element": "earth"},
1270
- {"name": "Gem", "element": "air"},
1271
- {"name": "Can", "element": "water"},
1272
- {"name": "Leo", "element": "fire"},
1273
- {"name": "Vir", "element": "earth"},
1274
- {"name": "Lib", "element": "air"},
1275
- {"name": "Sco", "element": "water"},
1276
- {"name": "Sag", "element": "fire"},
1277
- {"name": "Cap", "element": "earth"},
1278
- {"name": "Aqu", "element": "air"},
1279
- {"name": "Pis", "element": "water"},
1502
+ Dictionary with element percentages summing to 100.
1503
+ """
1504
+ normalized_names = [name.lower() for name in celestial_points_names]
1505
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1506
+
1507
+ subject1_totals = _calculate_distribution_for_subject(
1508
+ subject1,
1509
+ normalized_names,
1510
+ _SIGN_TO_ELEMENT,
1511
+ _ELEMENT_KEYS,
1512
+ weight_lookup,
1513
+ fallback_weight,
1514
+ )
1515
+ subject2_totals = _calculate_distribution_for_subject(
1516
+ subject2,
1517
+ normalized_names,
1518
+ _SIGN_TO_ELEMENT,
1519
+ _ELEMENT_KEYS,
1520
+ weight_lookup,
1521
+ fallback_weight,
1280
1522
  )
1281
1523
 
1282
- # Initialize combined element point totals
1283
- combined_totals = {
1284
- "fire": 0.0,
1285
- "earth": 0.0,
1286
- "air": 0.0,
1287
- "water": 0.0
1288
- }
1289
-
1290
- # Make list of the points sign for both subjects
1291
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1292
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1293
-
1294
- # Calculate element points for subject 1
1295
- for i in range(len(planets_settings)):
1296
- # Add points to appropriate element
1297
- element1 = ZODIAC[subject1_points_sign[i]]["element"]
1298
- combined_totals[element1] += planets_settings[i]["element_points"]
1299
-
1300
- # Calculate element points for subject 2
1301
- for i in range(len(planets_settings)):
1302
- # Add points to appropriate element
1303
- element2 = ZODIAC[subject2_points_sign[i]]["element"]
1304
- combined_totals[element2] += planets_settings[i]["element_points"]
1305
-
1306
- # Calculate total points across all elements
1524
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _ELEMENT_KEYS}
1307
1525
  total_points = sum(combined_totals.values())
1308
1526
 
1309
- # Convert to percentages (total = 100%)
1310
- if total_points > 0:
1311
- for element in combined_totals:
1312
- combined_totals[element] = (combined_totals[element] / total_points) * 100.0
1527
+ if total_points == 0:
1528
+ return {key: 0.0 for key in _ELEMENT_KEYS}
1313
1529
 
1314
- return combined_totals
1530
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _ELEMENT_KEYS}
1315
1531
 
1316
1532
 
1317
1533
  def draw_house_comparison_grid(
@@ -1512,117 +1728,86 @@ def makeLunarPhase(degrees_between_sun_and_moon: float, latitude: float) -> str:
1512
1728
 
1513
1729
 
1514
1730
  def calculate_quality_points(
1515
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1516
- celestial_points_names: list[str],
1517
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1518
- ):
1731
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1732
+ celestial_points_names: Sequence[str],
1733
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1734
+ *,
1735
+ method: ElementQualityDistributionMethod = "weighted",
1736
+ custom_weights: Optional[Mapping[str, float]] = None,
1737
+ ) -> dict[str, float]:
1519
1738
  """
1520
- Calculate quality point totals based on planetary positions.
1739
+ Calculate modality totals for a subject using the selected strategy.
1521
1740
 
1522
1741
  Args:
1523
- planets_settings (list): List of planet configuration dictionaries
1524
- celestial_points_names (list): List of celestial point names to process
1525
- subject: Astrological subject with get() method for accessing planet data
1526
- planet_in_zodiac_extra_points (int): Extra points awarded for planets in their home sign
1742
+ planets_settings: Planet configuration list (kept for API compatibility).
1743
+ celestial_points_names: Celestial point names to include.
1744
+ subject: Astrological subject with planetary data.
1745
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1746
+ custom_weights: Optional overrides for point weights keyed by name.
1527
1747
 
1528
1748
  Returns:
1529
- dict: Dictionary with quality point totals for 'cardinal', 'fixed', and 'mutable'
1530
- """
1531
- ZODIAC = (
1532
- {"name": "Ari", "quality": "cardinal"},
1533
- {"name": "Tau", "quality": "fixed"},
1534
- {"name": "Gem", "quality": "mutable"},
1535
- {"name": "Can", "quality": "cardinal"},
1536
- {"name": "Leo", "quality": "fixed"},
1537
- {"name": "Vir", "quality": "mutable"},
1538
- {"name": "Lib", "quality": "cardinal"},
1539
- {"name": "Sco", "quality": "fixed"},
1540
- {"name": "Sag", "quality": "mutable"},
1541
- {"name": "Cap", "quality": "cardinal"},
1542
- {"name": "Aqu", "quality": "fixed"},
1543
- {"name": "Pis", "quality": "mutable"},
1749
+ Dictionary mapping each modality to its accumulated total.
1750
+ """
1751
+ normalized_names = [name.lower() for name in celestial_points_names]
1752
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1753
+
1754
+ return _calculate_distribution_for_subject(
1755
+ subject,
1756
+ normalized_names,
1757
+ _SIGN_TO_QUALITY,
1758
+ _QUALITY_KEYS,
1759
+ weight_lookup,
1760
+ fallback_weight,
1544
1761
  )
1545
1762
 
1546
- # Initialize quality point totals
1547
- quality_totals = {
1548
- "cardinal": 0.0,
1549
- "fixed": 0.0,
1550
- "mutable": 0.0
1551
- }
1552
-
1553
- # Make list of the points sign
1554
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1555
-
1556
- for i in range(len(planets_settings)):
1557
- # Add points to appropriate quality
1558
- quality = ZODIAC[points_sign[i]]["quality"]
1559
- quality_totals[quality] += planets_settings[i]["element_points"]
1560
-
1561
- return quality_totals
1562
-
1563
1763
 
1564
1764
  def calculate_synastry_quality_points(
1565
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1566
- celestial_points_names: list[str],
1567
- subject1: AstrologicalSubjectModel,
1568
- subject2: AstrologicalSubjectModel,
1569
- ):
1765
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1766
+ celestial_points_names: Sequence[str],
1767
+ subject1: AstrologicalSubjectModel,
1768
+ subject2: AstrologicalSubjectModel,
1769
+ *,
1770
+ method: ElementQualityDistributionMethod = "weighted",
1771
+ custom_weights: Optional[Mapping[str, float]] = None,
1772
+ ) -> dict[str, float]:
1570
1773
  """
1571
- Calculate quality point totals for both subjects in a synastry chart.
1774
+ Calculate combined modality percentages for a synastry chart.
1572
1775
 
1573
1776
  Args:
1574
- planets_settings (list): List of planet configuration dictionaries
1575
- celestial_points_names (list): List of celestial point names to process
1576
- subject1: First astrological subject with get() method for accessing planet data
1577
- subject2: Second astrological subject with get() method for accessing planet data
1777
+ planets_settings: Planet configuration list (unused but preserved).
1778
+ celestial_points_names: Celestial point names to process.
1779
+ subject1: First astrological subject.
1780
+ subject2: Second astrological subject.
1781
+ method: Calculation strategy (pure_count or weighted).
1782
+ custom_weights: Optional overrides for point weights.
1578
1783
 
1579
1784
  Returns:
1580
- dict: Dictionary with quality point totals as percentages, where the sum equals 100%
1581
- """
1582
- ZODIAC = (
1583
- {"name": "Ari", "quality": "cardinal"},
1584
- {"name": "Tau", "quality": "fixed"},
1585
- {"name": "Gem", "quality": "mutable"},
1586
- {"name": "Can", "quality": "cardinal"},
1587
- {"name": "Leo", "quality": "fixed"},
1588
- {"name": "Vir", "quality": "mutable"},
1589
- {"name": "Lib", "quality": "cardinal"},
1590
- {"name": "Sco", "quality": "fixed"},
1591
- {"name": "Sag", "quality": "mutable"},
1592
- {"name": "Cap", "quality": "cardinal"},
1593
- {"name": "Aqu", "quality": "fixed"},
1594
- {"name": "Pis", "quality": "mutable"},
1785
+ Dictionary with modality percentages summing to 100.
1786
+ """
1787
+ normalized_names = [name.lower() for name in celestial_points_names]
1788
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1789
+
1790
+ subject1_totals = _calculate_distribution_for_subject(
1791
+ subject1,
1792
+ normalized_names,
1793
+ _SIGN_TO_QUALITY,
1794
+ _QUALITY_KEYS,
1795
+ weight_lookup,
1796
+ fallback_weight,
1797
+ )
1798
+ subject2_totals = _calculate_distribution_for_subject(
1799
+ subject2,
1800
+ normalized_names,
1801
+ _SIGN_TO_QUALITY,
1802
+ _QUALITY_KEYS,
1803
+ weight_lookup,
1804
+ fallback_weight,
1595
1805
  )
1596
1806
 
1597
- # Initialize combined quality point totals
1598
- combined_totals = {
1599
- "cardinal": 0.0,
1600
- "fixed": 0.0,
1601
- "mutable": 0.0
1602
- }
1603
-
1604
- # Make list of the points sign for both subjects
1605
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1606
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1607
-
1608
- # Calculate quality points for subject 1
1609
- for i in range(len(planets_settings)):
1610
- # Add points to appropriate quality
1611
- quality1 = ZODIAC[subject1_points_sign[i]]["quality"]
1612
- combined_totals[quality1] += planets_settings[i]["element_points"]
1613
-
1614
- # Calculate quality points for subject 2
1615
- for i in range(len(planets_settings)):
1616
- # Add points to appropriate quality
1617
- quality2 = ZODIAC[subject2_points_sign[i]]["quality"]
1618
- combined_totals[quality2] += planets_settings[i]["element_points"]
1619
-
1620
- # Calculate total points across all qualities
1807
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _QUALITY_KEYS}
1621
1808
  total_points = sum(combined_totals.values())
1622
1809
 
1623
- # Convert to percentages (total = 100%)
1624
- if total_points > 0:
1625
- for quality in combined_totals:
1626
- combined_totals[quality] = (combined_totals[quality] / total_points) * 100.0
1810
+ if total_points == 0:
1811
+ return {key: 0.0 for key in _QUALITY_KEYS}
1627
1812
 
1628
- return combined_totals
1813
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _QUALITY_KEYS}