kerykeion 5.0.0b2__py3-none-any.whl → 5.0.0b5__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 (36) 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 +524 -203
  7. kerykeion/charts/charts_utils.py +416 -253
  8. kerykeion/charts/templates/aspect_grid_only.xml +269 -312
  9. kerykeion/charts/templates/chart.xml +248 -304
  10. kerykeion/charts/templates/wheel_only.xml +271 -312
  11. kerykeion/charts/themes/black-and-white.css +148 -0
  12. kerykeion/kr_types/__init__.py +70 -0
  13. kerykeion/kr_types/chart_template_model.py +20 -0
  14. kerykeion/kr_types/kerykeion_exception.py +20 -0
  15. kerykeion/kr_types/kr_literals.py +20 -0
  16. kerykeion/kr_types/kr_models.py +20 -0
  17. kerykeion/kr_types/settings_models.py +20 -0
  18. kerykeion/relationship_score_factory.py +12 -2
  19. kerykeion/schemas/__init__.py +7 -0
  20. kerykeion/schemas/kr_literals.py +12 -1
  21. kerykeion/settings/__init__.py +16 -2
  22. kerykeion/settings/chart_defaults.py +444 -0
  23. kerykeion/settings/config_constants.py +0 -5
  24. kerykeion/settings/kerykeion_settings.py +31 -74
  25. kerykeion/settings/translation_strings.py +1479 -0
  26. kerykeion/settings/translations.py +74 -0
  27. kerykeion/transits_time_range_factory.py +10 -1
  28. {kerykeion-5.0.0b2.dist-info → kerykeion-5.0.0b5.dist-info}/METADATA +333 -207
  29. {kerykeion-5.0.0b2.dist-info → kerykeion-5.0.0b5.dist-info}/RECORD +31 -26
  30. kerykeion/settings/kr.config.json +0 -1474
  31. kerykeion/settings/legacy/__init__.py +0 -0
  32. kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
  33. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
  34. kerykeion/settings/legacy/legacy_color_settings.py +0 -42
  35. {kerykeion-5.0.0b2.dist-info → kerykeion-5.0.0b5.dist-info}/WHEEL +0 -0
  36. {kerykeion-5.0.0b2.dist-info → kerykeion-5.0.0b5.dist-info}/licenses/LICENSE +0 -0
@@ -1,32 +1,226 @@
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
+
10
183
  _SECOND_COLUMN_THRESHOLD = 20
11
184
  _THIRD_COLUMN_THRESHOLD = 28
12
185
  _FOURTH_COLUMN_THRESHOLD = 36
186
+
187
+ _DOUBLE_CHART_TYPES: tuple[ChartType, ...] = ("Synastry", "Transit", "DualReturnChart")
13
188
  _GRID_COLUMN_WIDTH = 125
14
189
 
15
190
 
16
- def _planet_grid_layout_position(index: int) -> tuple[int, int]:
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]:
17
205
  """Return horizontal offset and row index for planet grids."""
18
- if index < _SECOND_COLUMN_THRESHOLD:
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:
19
213
  column = 0
20
214
  row = index
21
- elif index < _THIRD_COLUMN_THRESHOLD:
215
+ elif index < third_threshold:
22
216
  column = 1
23
- row = index - _SECOND_COLUMN_THRESHOLD
24
- elif index < _FOURTH_COLUMN_THRESHOLD:
217
+ row = index - second_threshold
218
+ elif index < fourth_threshold:
25
219
  column = 2
26
- row = index - _THIRD_COLUMN_THRESHOLD
220
+ row = index - third_threshold
27
221
  else:
28
222
  column = 3
29
- row = index - _FOURTH_COLUMN_THRESHOLD
223
+ row = index - fourth_threshold
30
224
 
31
225
  offset = -(_GRID_COLUMN_WIDTH * column)
32
226
  return offset, row
@@ -712,7 +906,8 @@ def draw_transit_aspect_list(
712
906
  aspects_per_column: int = 14,
713
907
  column_width: int = 100,
714
908
  line_height: int = 14,
715
- max_columns: int = 6
909
+ max_columns: int = 6,
910
+ chart_height: Optional[int] = None,
716
911
  ) -> str:
717
912
  """
718
913
  Generates the SVG output for the aspect transit grid.
@@ -726,6 +921,8 @@ def draw_transit_aspect_list(
726
921
  - column_width: Width in pixels for each column (default: 100).
727
922
  - line_height: Height in pixels for each line (default: 14).
728
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).
729
926
 
730
927
  Returns:
731
928
  - A string containing the SVG path data for the aspect transit grid.
@@ -741,61 +938,86 @@ def draw_transit_aspect_list(
741
938
  # Type narrowing: at this point aspects_list contains AspectModel instances
742
939
  typed_aspects_list: list[AspectModel] = aspects_list # type: ignore
743
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
+
744
953
  inner_path = ""
745
954
 
746
- for i, aspect in enumerate(typed_aspects_list):
747
- # Calculate which column this aspect belongs in
748
- current_column = i // aspects_per_column
749
-
750
- # Calculate horizontal position based on column
751
- horizontal_position = current_column * column_width
752
-
753
- # Calculate vertical position within the column
754
- current_line = i % aspects_per_column
755
- vertical_position = current_line * line_height
756
-
757
- # Special handling for many aspects - if we exceed max_columns
758
- # Bottom-align the overflow columns so the list starts from the bottom
759
- if current_column >= max_columns:
760
- overflow_total = len(aspects_list) - (aspects_per_column * max_columns)
761
- if overflow_total > 0:
762
- # Index within the overflow sequence (beyond the first row of columns)
763
- overflow_index = i - (aspects_per_column * max_columns)
764
- # Which overflow column we are in (0-based)
765
- overflow_col_idx = overflow_index // aspects_per_column
766
- # How many items go into this overflow column
767
- items_in_this_column = min(
768
- aspects_per_column,
769
- max(0, overflow_total - (overflow_col_idx * aspects_per_column)),
770
- )
771
- # Compute extra top offset (in lines) to bottom-align this column
772
- top_offset_lines = max(0, aspects_per_column - items_in_this_column)
773
- vertical_position = (top_offset_lines + (i % aspects_per_column)) * line_height
774
-
775
- inner_path += f'<g transform="translate({horizontal_position},{vertical_position})">'
776
-
777
- # First planet symbol
778
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p1"]]["name"]}" />'
779
-
780
- # Aspect symbol
781
- aspect_name = aspect["aspect"]
782
- id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
783
- inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
784
-
785
- # Second planet symbol
786
- inner_path += '<g transform="translate(30,0)">'
787
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p2"]]["name"]}" />'
788
- inner_path += "</g>"
789
-
790
- # Difference in degrees
791
- 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>'
792
-
793
- inner_path += "</g>"
794
-
795
- 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})">'
796
1018
  out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
797
1019
  out += inner_path
798
- out += '</g>'
1020
+ out += "</g>"
799
1021
 
800
1022
  return out
801
1023
 
@@ -987,8 +1209,10 @@ def draw_main_planet_grid(
987
1209
 
988
1210
  end_of_line = "</g>"
989
1211
 
1212
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1213
+
990
1214
  for i, planet in enumerate(available_kerykeion_celestial_points):
991
- offset, row_index = _planet_grid_layout_position(i)
1215
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
992
1216
  line_height = LINE_START + (row_index * LINE_STEP)
993
1217
 
994
1218
  decoded_name = get_decoded_kerykeion_celestial_point_name(
@@ -1070,8 +1294,10 @@ def draw_secondary_planet_grid(
1070
1294
  line_height = LINE_START
1071
1295
  end_of_line = "</g>"
1072
1296
 
1297
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1298
+
1073
1299
  for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
1074
- offset, row_index = _planet_grid_layout_position(i)
1300
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1075
1301
  line_height = LINE_START + (row_index * LINE_STEP)
1076
1302
 
1077
1303
  second_decoded_name = get_decoded_kerykeion_celestial_point_name(
@@ -1219,121 +1445,89 @@ def format_datetime_with_timezone(iso_datetime_string: str) -> str:
1219
1445
 
1220
1446
 
1221
1447
  def calculate_element_points(
1222
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1223
- celestial_points_names: list[str],
1224
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1225
- ):
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]:
1226
1455
  """
1227
- Calculate elemental point totals based on planetary positions.
1456
+ Calculate elemental totals for a subject using the selected strategy.
1228
1457
 
1229
1458
  Args:
1230
- planets_settings (list): List of planet configuration dictionaries
1231
- celestial_points_names (list): List of celestial point names to process
1232
- 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.
1233
1464
 
1234
1465
  Returns:
1235
- dict: Dictionary with element point totals for 'fire', 'earth', 'air', and 'water'
1236
- """
1237
- ZODIAC = (
1238
- {"name": "Ari", "element": "fire"},
1239
- {"name": "Tau", "element": "earth"},
1240
- {"name": "Gem", "element": "air"},
1241
- {"name": "Can", "element": "water"},
1242
- {"name": "Leo", "element": "fire"},
1243
- {"name": "Vir", "element": "earth"},
1244
- {"name": "Lib", "element": "air"},
1245
- {"name": "Sco", "element": "water"},
1246
- {"name": "Sag", "element": "fire"},
1247
- {"name": "Cap", "element": "earth"},
1248
- {"name": "Aqu", "element": "air"},
1249
- {"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,
1250
1478
  )
1251
1479
 
1252
- # Initialize element point totals
1253
- element_totals = {
1254
- "fire": 0.0,
1255
- "earth": 0.0,
1256
- "air": 0.0,
1257
- "water": 0.0
1258
- }
1259
-
1260
- # Make list of the points sign
1261
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1262
-
1263
- for i in range(len(planets_settings)):
1264
- # Add points to appropriate element
1265
- element = ZODIAC[points_sign[i]]["element"]
1266
- element_totals[element] += planets_settings[i]["element_points"]
1267
-
1268
- return element_totals
1269
-
1270
1480
 
1271
1481
  def calculate_synastry_element_points(
1272
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1273
- celestial_points_names: list[str],
1274
- subject1: AstrologicalSubjectModel,
1275
- subject2: AstrologicalSubjectModel,
1276
- ):
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]:
1277
1490
  """
1278
- Calculate elemental point totals for both subjects in a synastry chart.
1491
+ Calculate combined element percentages for a synastry chart.
1279
1492
 
1280
1493
  Args:
1281
- planets_settings (list): List of planet configuration dictionaries
1282
- celestial_points_names (list): List of celestial point names to process
1283
- subject1: First astrological subject with get() method for accessing planet data
1284
- 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.
1285
1500
 
1286
1501
  Returns:
1287
- dict: Dictionary with element point totals as percentages, where the sum equals 100%
1288
- """
1289
- ZODIAC = (
1290
- {"name": "Ari", "element": "fire"},
1291
- {"name": "Tau", "element": "earth"},
1292
- {"name": "Gem", "element": "air"},
1293
- {"name": "Can", "element": "water"},
1294
- {"name": "Leo", "element": "fire"},
1295
- {"name": "Vir", "element": "earth"},
1296
- {"name": "Lib", "element": "air"},
1297
- {"name": "Sco", "element": "water"},
1298
- {"name": "Sag", "element": "fire"},
1299
- {"name": "Cap", "element": "earth"},
1300
- {"name": "Aqu", "element": "air"},
1301
- {"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,
1302
1522
  )
1303
1523
 
1304
- # Initialize combined element point totals
1305
- combined_totals = {
1306
- "fire": 0.0,
1307
- "earth": 0.0,
1308
- "air": 0.0,
1309
- "water": 0.0
1310
- }
1311
-
1312
- # Make list of the points sign for both subjects
1313
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1314
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1315
-
1316
- # Calculate element points for subject 1
1317
- for i in range(len(planets_settings)):
1318
- # Add points to appropriate element
1319
- element1 = ZODIAC[subject1_points_sign[i]]["element"]
1320
- combined_totals[element1] += planets_settings[i]["element_points"]
1321
-
1322
- # Calculate element points for subject 2
1323
- for i in range(len(planets_settings)):
1324
- # Add points to appropriate element
1325
- element2 = ZODIAC[subject2_points_sign[i]]["element"]
1326
- combined_totals[element2] += planets_settings[i]["element_points"]
1327
-
1328
- # Calculate total points across all elements
1524
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _ELEMENT_KEYS}
1329
1525
  total_points = sum(combined_totals.values())
1330
1526
 
1331
- # Convert to percentages (total = 100%)
1332
- if total_points > 0:
1333
- for element in combined_totals:
1334
- 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}
1335
1529
 
1336
- return combined_totals
1530
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _ELEMENT_KEYS}
1337
1531
 
1338
1532
 
1339
1533
  def draw_house_comparison_grid(
@@ -1534,117 +1728,86 @@ def makeLunarPhase(degrees_between_sun_and_moon: float, latitude: float) -> str:
1534
1728
 
1535
1729
 
1536
1730
  def calculate_quality_points(
1537
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1538
- celestial_points_names: list[str],
1539
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1540
- ):
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]:
1541
1738
  """
1542
- Calculate quality point totals based on planetary positions.
1739
+ Calculate modality totals for a subject using the selected strategy.
1543
1740
 
1544
1741
  Args:
1545
- planets_settings (list): List of planet configuration dictionaries
1546
- celestial_points_names (list): List of celestial point names to process
1547
- subject: Astrological subject with get() method for accessing planet data
1548
- 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.
1549
1747
 
1550
1748
  Returns:
1551
- dict: Dictionary with quality point totals for 'cardinal', 'fixed', and 'mutable'
1552
- """
1553
- ZODIAC = (
1554
- {"name": "Ari", "quality": "cardinal"},
1555
- {"name": "Tau", "quality": "fixed"},
1556
- {"name": "Gem", "quality": "mutable"},
1557
- {"name": "Can", "quality": "cardinal"},
1558
- {"name": "Leo", "quality": "fixed"},
1559
- {"name": "Vir", "quality": "mutable"},
1560
- {"name": "Lib", "quality": "cardinal"},
1561
- {"name": "Sco", "quality": "fixed"},
1562
- {"name": "Sag", "quality": "mutable"},
1563
- {"name": "Cap", "quality": "cardinal"},
1564
- {"name": "Aqu", "quality": "fixed"},
1565
- {"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,
1566
1761
  )
1567
1762
 
1568
- # Initialize quality point totals
1569
- quality_totals = {
1570
- "cardinal": 0.0,
1571
- "fixed": 0.0,
1572
- "mutable": 0.0
1573
- }
1574
-
1575
- # Make list of the points sign
1576
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1577
-
1578
- for i in range(len(planets_settings)):
1579
- # Add points to appropriate quality
1580
- quality = ZODIAC[points_sign[i]]["quality"]
1581
- quality_totals[quality] += planets_settings[i]["element_points"]
1582
-
1583
- return quality_totals
1584
-
1585
1763
 
1586
1764
  def calculate_synastry_quality_points(
1587
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1588
- celestial_points_names: list[str],
1589
- subject1: AstrologicalSubjectModel,
1590
- subject2: AstrologicalSubjectModel,
1591
- ):
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]:
1592
1773
  """
1593
- Calculate quality point totals for both subjects in a synastry chart.
1774
+ Calculate combined modality percentages for a synastry chart.
1594
1775
 
1595
1776
  Args:
1596
- planets_settings (list): List of planet configuration dictionaries
1597
- celestial_points_names (list): List of celestial point names to process
1598
- subject1: First astrological subject with get() method for accessing planet data
1599
- 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.
1600
1783
 
1601
1784
  Returns:
1602
- dict: Dictionary with quality point totals as percentages, where the sum equals 100%
1603
- """
1604
- ZODIAC = (
1605
- {"name": "Ari", "quality": "cardinal"},
1606
- {"name": "Tau", "quality": "fixed"},
1607
- {"name": "Gem", "quality": "mutable"},
1608
- {"name": "Can", "quality": "cardinal"},
1609
- {"name": "Leo", "quality": "fixed"},
1610
- {"name": "Vir", "quality": "mutable"},
1611
- {"name": "Lib", "quality": "cardinal"},
1612
- {"name": "Sco", "quality": "fixed"},
1613
- {"name": "Sag", "quality": "mutable"},
1614
- {"name": "Cap", "quality": "cardinal"},
1615
- {"name": "Aqu", "quality": "fixed"},
1616
- {"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,
1617
1805
  )
1618
1806
 
1619
- # Initialize combined quality point totals
1620
- combined_totals = {
1621
- "cardinal": 0.0,
1622
- "fixed": 0.0,
1623
- "mutable": 0.0
1624
- }
1625
-
1626
- # Make list of the points sign for both subjects
1627
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1628
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1629
-
1630
- # Calculate quality points for subject 1
1631
- for i in range(len(planets_settings)):
1632
- # Add points to appropriate quality
1633
- quality1 = ZODIAC[subject1_points_sign[i]]["quality"]
1634
- combined_totals[quality1] += planets_settings[i]["element_points"]
1635
-
1636
- # Calculate quality points for subject 2
1637
- for i in range(len(planets_settings)):
1638
- # Add points to appropriate quality
1639
- quality2 = ZODIAC[subject2_points_sign[i]]["quality"]
1640
- combined_totals[quality2] += planets_settings[i]["element_points"]
1641
-
1642
- # Calculate total points across all qualities
1807
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _QUALITY_KEYS}
1643
1808
  total_points = sum(combined_totals.values())
1644
1809
 
1645
- # Convert to percentages (total = 100%)
1646
- if total_points > 0:
1647
- for quality in combined_totals:
1648
- 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}
1649
1812
 
1650
- return combined_totals
1813
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _QUALITY_KEYS}