kerykeion 5.0.0a9__py3-none-any.whl → 5.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kerykeion might be problematic. Click here for more details.

Files changed (79) hide show
  1. kerykeion/__init__.py +50 -9
  2. kerykeion/aspects/__init__.py +5 -2
  3. kerykeion/aspects/aspects_factory.py +568 -0
  4. kerykeion/aspects/aspects_utils.py +78 -11
  5. kerykeion/astrological_subject_factory.py +1032 -275
  6. kerykeion/backword.py +820 -0
  7. kerykeion/chart_data_factory.py +552 -0
  8. kerykeion/charts/chart_drawer.py +2661 -0
  9. kerykeion/charts/charts_utils.py +652 -399
  10. kerykeion/charts/draw_planets.py +603 -353
  11. kerykeion/charts/templates/aspect_grid_only.xml +326 -198
  12. kerykeion/charts/templates/chart.xml +306 -256
  13. kerykeion/charts/templates/wheel_only.xml +330 -200
  14. kerykeion/charts/themes/black-and-white.css +148 -0
  15. kerykeion/charts/themes/classic.css +11 -0
  16. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  17. kerykeion/charts/themes/dark.css +11 -0
  18. kerykeion/charts/themes/light.css +11 -0
  19. kerykeion/charts/themes/strawberry.css +10 -0
  20. kerykeion/composite_subject_factory.py +232 -13
  21. kerykeion/ephemeris_data_factory.py +443 -0
  22. kerykeion/fetch_geonames.py +78 -21
  23. kerykeion/house_comparison/__init__.py +4 -1
  24. kerykeion/house_comparison/house_comparison_factory.py +52 -19
  25. kerykeion/house_comparison/house_comparison_utils.py +37 -9
  26. kerykeion/kr_types/__init__.py +66 -6
  27. kerykeion/kr_types/chart_template_model.py +20 -0
  28. kerykeion/kr_types/kerykeion_exception.py +15 -9
  29. kerykeion/kr_types/kr_literals.py +14 -160
  30. kerykeion/kr_types/kr_models.py +14 -291
  31. kerykeion/kr_types/settings_models.py +15 -167
  32. kerykeion/planetary_return_factory.py +545 -40
  33. kerykeion/relationship_score_factory.py +137 -63
  34. kerykeion/report.py +749 -64
  35. kerykeion/schemas/__init__.py +106 -0
  36. kerykeion/schemas/chart_template_model.py +367 -0
  37. kerykeion/schemas/kerykeion_exception.py +20 -0
  38. kerykeion/schemas/kr_literals.py +181 -0
  39. kerykeion/schemas/kr_models.py +603 -0
  40. kerykeion/schemas/settings_models.py +188 -0
  41. kerykeion/settings/__init__.py +20 -1
  42. kerykeion/settings/chart_defaults.py +444 -0
  43. kerykeion/settings/config_constants.py +88 -12
  44. kerykeion/settings/kerykeion_settings.py +32 -75
  45. kerykeion/settings/translation_strings.py +1499 -0
  46. kerykeion/settings/translations.py +74 -0
  47. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  48. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  49. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  50. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  51. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  52. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  53. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  54. kerykeion/sweph/sefstars.txt +1602 -0
  55. kerykeion/transits_time_range_factory.py +302 -0
  56. kerykeion/utilities.py +289 -204
  57. kerykeion-5.1.8.dist-info/METADATA +1793 -0
  58. kerykeion-5.1.8.dist-info/RECORD +63 -0
  59. kerykeion/aspects/natal_aspects.py +0 -181
  60. kerykeion/aspects/synastry_aspects.py +0 -141
  61. kerykeion/aspects/transits_time_range.py +0 -41
  62. kerykeion/charts/draw_planets_v2.py +0 -649
  63. kerykeion/charts/draw_planets_v3.py +0 -679
  64. kerykeion/charts/kerykeion_chart_svg.py +0 -2038
  65. kerykeion/enums.py +0 -57
  66. kerykeion/ephemeris_data.py +0 -238
  67. kerykeion/house_comparison/house_comparison_models.py +0 -38
  68. kerykeion/kr_types/chart_types.py +0 -106
  69. kerykeion/settings/kr.config.json +0 -1304
  70. kerykeion/settings/legacy/__init__.py +0 -0
  71. kerykeion/settings/legacy/legacy_celestial_points_settings.py +0 -299
  72. kerykeion/settings/legacy/legacy_chart_aspects_settings.py +0 -71
  73. kerykeion/settings/legacy/legacy_color_settings.py +0 -42
  74. kerykeion/transits_time_range.py +0 -128
  75. kerykeion-5.0.0a9.dist-info/METADATA +0 -636
  76. kerykeion-5.0.0a9.dist-info/RECORD +0 -55
  77. kerykeion-5.0.0a9.dist-info/entry_points.txt +0 -2
  78. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/WHEEL +0 -0
  79. {kerykeion-5.0.0a9.dist-info → kerykeion-5.1.8.dist-info}/licenses/LICENSE +0 -0
@@ -1,11 +1,229 @@
1
1
  import math
2
2
  import datetime
3
- from kerykeion.kr_types import KerykeionException, ChartType
4
- from kerykeion.kr_types.kr_literals import AstrologicalPoint
5
- from typing import Union, Literal, TYPE_CHECKING
6
- from kerykeion.kr_types.kr_models import AspectModel, KerykeionPointModel, CompositeSubjectModel, PlanetReturnModel, AstrologicalSubjectModel
7
- from kerykeion.kr_types.settings_models import KerykeionLanguageCelestialPointModel, KerykeionSettingsCelestialPointModel
8
- from kerykeion.house_comparison import HouseComparisonModel
3
+ from typing import Mapping, Optional, Sequence, Union, Literal
4
+
5
+ from kerykeion.schemas import KerykeionException, ChartType
6
+ from kerykeion.schemas.kr_literals import AstrologicalPoint
7
+ from kerykeion.schemas.kr_models import AspectModel, KerykeionPointModel, CompositeSubjectModel, PlanetReturnModel, AstrologicalSubjectModel, HouseComparisonModel
8
+ from kerykeion.schemas.settings_models import KerykeionLanguageCelestialPointModel, KerykeionSettingsCelestialPointModel
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
9
227
 
10
228
 
11
229
 
@@ -189,7 +407,7 @@ def draw_zodiac_slice(
189
407
  # pie slices
190
408
  offset = 360 - seventh_house_degree_ut
191
409
  # check transit
192
- if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "Return":
410
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
193
411
  dropin: Union[int, float] = 0
194
412
  else:
195
413
  dropin = c1
@@ -198,7 +416,7 @@ def draw_zodiac_slice(
198
416
  # symbols
199
417
  offset = offset + 15
200
418
  # check transit
201
- if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "Return":
419
+ if chart_type == "Transit" or chart_type == "Synastry" or chart_type == "DualReturnChart":
202
420
  dropin = 54
203
421
  else:
204
422
  dropin = 18 + c1
@@ -287,7 +505,7 @@ def draw_aspect_line(
287
505
  y2 = sliceToY(0, ar, second_offset) + (r - ar)
288
506
 
289
507
  return (
290
- f'<g kr:node="Aspect" kr:aspectname="{aspect["aspect"]}" kr:to="{aspect["p1_name"]}" kr:tooriginaldegrees="{aspect["p1_abs_pos"]}" kr:from="{aspect["p2_name"]}" kr:fromoriginaldegrees="{aspect["p2_abs_pos"]}">'
508
+ f'<g kr:node="Aspect" kr:aspectname="{aspect["aspect"]}" kr:to="{aspect["p1_name"]}" kr:tooriginaldegrees="{aspect["p1_abs_pos"]}" kr:from="{aspect["p2_name"]}" kr:fromoriginaldegrees="{aspect["p2_abs_pos"]}" kr:orb="{aspect["orbit"]}" kr:aspectdegrees="{aspect["aspect_degrees"]}" kr:planetsdiff="{aspect["diff"]}" kr:aspectmovement="{aspect["aspect_movement"]}">'
291
509
  f'<line class="aspect" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {color}; stroke-width: 1; stroke-opacity: .9;"/>'
292
510
  f"</g>"
293
511
  )
@@ -419,7 +637,7 @@ def draw_first_circle(
419
637
  Returns:
420
638
  str: The SVG path of the first circle.
421
639
  """
422
- if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "Return":
640
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
423
641
  return f'<circle cx="{r}" cy="{r}" r="{r - 36}" style="fill: none; stroke: {stroke_color}; stroke-width: 1px; stroke-opacity:.4;" />'
424
642
  else:
425
643
  if c1 is None:
@@ -462,7 +680,7 @@ def draw_second_circle(
462
680
  str: The SVG path of the second circle.
463
681
  """
464
682
 
465
- if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "Return":
683
+ if chart_type == "Synastry" or chart_type == "Transit" or chart_type == "DualReturnChart":
466
684
  return f'<circle cx="{r}" cy="{r}" r="{r - 72}" style="fill: {fill_color}; fill-opacity:.4; stroke: {stroke_color}; stroke-opacity:.4; stroke-width: 1px" />'
467
685
 
468
686
  else:
@@ -492,7 +710,7 @@ def draw_third_circle(
492
710
  Returns:
493
711
  - str: The SVG element as a string.
494
712
  """
495
- if chart_type in {"Synastry", "Transit", "Return"}:
713
+ if chart_type in {"Synastry", "Transit", "DualReturnChart"}:
496
714
  # For Synastry and Transit charts, use a fixed radius adjustment of 160
497
715
  return f'<circle cx="{radius}" cy="{radius}" r="{radius - 160}" style="fill: {fill_color}; fill-opacity:.8; stroke: {stroke_color}; stroke-width: 1px" />'
498
716
 
@@ -572,6 +790,7 @@ def draw_houses_cusps_and_text_number(
572
790
  chart_type: ChartType,
573
791
  second_subject_houses_list: Union[list[KerykeionPointModel], None] = None,
574
792
  transit_house_cusp_color: Union[str, None] = None,
793
+ external_view: bool = False,
575
794
  ) -> str:
576
795
  """
577
796
  Draws the houses cusps and text numbers for a given chart type.
@@ -589,9 +808,10 @@ def draw_houses_cusps_and_text_number(
589
808
  - chart_type: Type of the chart (e.g., Transit, Synastry).
590
809
  - second_subject_houses_list: List of house for the second subject (optional).
591
810
  - transit_house_cusp_color: Color for transit house cusps (optional).
811
+ - external_view: Whether to use external view mode for positioning (optional).
592
812
 
593
813
  Returns:
594
- - A string containing the SVG path for the houses cusps and text numbers.
814
+ - A string containing SVG elements for house cusps and numbers.
595
815
  """
596
816
 
597
817
  path = ""
@@ -599,7 +819,7 @@ def draw_houses_cusps_and_text_number(
599
819
 
600
820
  for i in range(xr):
601
821
  # Determine offsets based on chart type
602
- dropin, roff, t_roff = (160, 72, 36) if chart_type in ["Transit", "Synastry", "Return"] else (c3, c1, False)
822
+ dropin, roff, t_roff = (160, 72, 36) if chart_type in ["Transit", "Synastry", "DualReturnChart"] else (c3, c1, False)
603
823
 
604
824
  # Calculate the offset for the current house cusp
605
825
  offset = (int(first_subject_houses_list[int(xr / 2)].abs_pos) / -1) + int(first_subject_houses_list[i].abs_pos)
@@ -621,7 +841,7 @@ def draw_houses_cusps_and_text_number(
621
841
  i, standard_house_cusp_color
622
842
  )
623
843
 
624
- if chart_type in ["Transit", "Synastry", "Return"]:
844
+ if chart_type in ["Transit", "Synastry", "DualReturnChart"]:
625
845
  if second_subject_houses_list is None or transit_house_cusp_color is None:
626
846
  raise KerykeionException("second_subject_houses_list_ut or transit_house_cusp_color is None")
627
847
 
@@ -645,30 +865,34 @@ def draw_houses_cusps_and_text_number(
645
865
 
646
866
  # Add the house number text for the second subject
647
867
  fill_opacity = "0" if chart_type == "Transit" else ".4"
648
- path += f'<g kr:node="HouseNumber">'
868
+ path += '<g kr:node="HouseNumber">'
649
869
  path += f'<text style="fill: var(--kerykeion-chart-color-house-number); fill-opacity: {fill_opacity}; font-size: 14px"><tspan x="{xtext - 3}" y="{ytext + 3}">{i + 1}</tspan></text>'
650
- path += f"</g>"
870
+ path += "</g>"
651
871
 
652
872
  # Add the house cusp line for the second subject
653
873
  stroke_opacity = "0" if chart_type == "Transit" else ".3"
654
- path += f'<g kr:node="Cusp">'
874
+ path += f'<g kr:node="Cusp" kr:absoluteposition="{second_subject_houses_list[i].abs_pos}" kr:signposition="{second_subject_houses_list[i].position}" kr:sing="{second_subject_houses_list[i].sign}" kr:slug="{second_subject_houses_list[i].name}">'
655
875
  path += f"<line x1='{t_x1}' y1='{t_y1}' x2='{t_x2}' y2='{t_y2}' style='stroke: {t_linecolor}; stroke-width: 1px; stroke-opacity:{stroke_opacity};'/>"
656
- path += f"</g>"
876
+ path += "</g>"
657
877
 
658
- # Adjust dropin based on chart type
659
- dropin = {"Transit": 84, "Synastry": 84, "Return": 84, "ExternalNatal": 100}.get(chart_type, 48)
878
+ # Adjust dropin based on chart type and external view
879
+ dropin_map = {"Transit": 84, "Synastry": 84, "DualReturnChart": 84}
880
+ if external_view:
881
+ dropin = 100
882
+ else:
883
+ dropin = dropin_map.get(chart_type, 48)
660
884
  xtext = sliceToX(0, (r - dropin), text_offset) + dropin
661
885
  ytext = sliceToY(0, (r - dropin), text_offset) + dropin
662
886
 
663
887
  # Add the house cusp line for the first subject
664
- path += f'<g kr:node="Cusp">'
888
+ path += f'<g kr:node="Cusp" kr:absoluteposition="{first_subject_houses_list[i].abs_pos}" kr:signposition="{first_subject_houses_list[i].position}" kr:sing="{first_subject_houses_list[i].sign}" kr:slug="{first_subject_houses_list[i].name}">'
665
889
  path += f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" style="stroke: {linecolor}; stroke-width: 1px; stroke-dasharray:3,2; stroke-opacity:.4;"/>'
666
- path += f"</g>"
890
+ path += "</g>"
667
891
 
668
892
  # Add the house number text for the first subject
669
- path += f'<g kr:node="HouseNumber">'
893
+ path += '<g kr:node="HouseNumber">'
670
894
  path += f'<text style="fill: var(--kerykeion-chart-color-house-number); fill-opacity: .6; font-size: 14px"><tspan x="{xtext - 3}" y="{ytext + 3}">{i + 1}</tspan></text>'
671
- path += f"</g>"
895
+ path += "</g>"
672
896
 
673
897
  return path
674
898
 
@@ -682,7 +906,8 @@ def draw_transit_aspect_list(
682
906
  aspects_per_column: int = 14,
683
907
  column_width: int = 100,
684
908
  line_height: int = 14,
685
- max_columns: int = 6
909
+ max_columns: int = 6,
910
+ chart_height: Optional[int] = None,
686
911
  ) -> str:
687
912
  """
688
913
  Generates the SVG output for the aspect transit grid.
@@ -696,6 +921,8 @@ def draw_transit_aspect_list(
696
921
  - column_width: Width in pixels for each column (default: 100).
697
922
  - line_height: Height in pixels for each line (default: 14).
698
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).
699
926
 
700
927
  Returns:
701
928
  - A string containing the SVG path data for the aspect transit grid.
@@ -708,115 +935,122 @@ def draw_transit_aspect_list(
708
935
  if aspects_list and isinstance(aspects_list[0], dict):
709
936
  aspects_list = [AspectModel(**aspect) for aspect in aspects_list] # type: ignore
710
937
 
711
- inner_path = ""
712
-
713
- for i, aspect in enumerate(aspects_list):
714
- # Calculate which column this aspect belongs in
715
- current_column = i // aspects_per_column
716
-
717
- # Calculate horizontal position based on column
718
- horizontal_position = current_column * column_width
719
-
720
- # Calculate vertical position within the column
721
- current_line = i % aspects_per_column
722
- vertical_position = current_line * line_height
723
-
724
- # Special handling for many aspects - if we exceed max_columns
725
- if current_column >= max_columns:
726
- # Calculate how many aspects will overflow beyond the max columns
727
- overflow_aspects = len(aspects_list) - (aspects_per_column * max_columns)
728
- if overflow_aspects > 0:
729
- # Adjust the starting vertical position to move text up
730
- vertical_position = vertical_position - (overflow_aspects * line_height)
731
-
732
- inner_path += f'<g transform="translate({horizontal_position},{vertical_position})">'
733
-
734
- # First planet symbol
735
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p1"]]["name"]}" />'
938
+ # Type narrowing: at this point aspects_list contains AspectModel instances
939
+ typed_aspects_list: list[AspectModel] = aspects_list # type: ignore
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
736
952
 
737
- # Aspect symbol
738
- aspect_name = aspect["aspect"]
739
- id_value = next((a["degree"] for a in aspects_settings if a["name"] == aspect_name), None) # type: ignore
740
- inner_path += f'<use x="15" y="0" xlink:href="#orb{id_value}" />'
741
-
742
- # Second planet symbol
743
- inner_path += f'<g transform="translate(30,0)">'
744
- inner_path += f'<use transform="scale(0.4)" x="0" y="3" xlink:href="#{celestial_point_language[aspect["p2"]]["name"]}" />'
745
- inner_path += f"</g>"
746
-
747
- # Difference in degrees
748
- 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>'
749
-
750
- inner_path += f"</g>"
953
+ inner_path = ""
751
954
 
752
- 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})">'
753
1018
  out += f'<text y="-15" x="0" style="fill: var(--kerykeion-chart-color-paper-0); font-size: 14px;">{grid_title}:</text>'
754
1019
  out += inner_path
755
- out += '</g>'
1020
+ out += "</g>"
756
1021
 
757
1022
  return out
758
1023
 
759
1024
 
760
1025
  def calculate_moon_phase_chart_params(
761
- degrees_between_sun_and_moon: float,
762
- latitude: float
1026
+ degrees_between_sun_and_moon: float
763
1027
  ) -> dict:
764
1028
  """
765
- Calculate the parameters for the moon phase chart.
1029
+ Calculate normalized parameters used by the moon phase icon.
766
1030
 
767
1031
  Parameters:
768
- - degrees_between_sun_and_moon (float): The degrees between the sun and the moon.
769
- - latitude (float): The latitude for rotation calculation.
1032
+ - degrees_between_sun_and_moon (float): The elongation between the sun and moon.
770
1033
 
771
1034
  Returns:
772
- - dict: The moon phase chart parameters.
773
- """
774
- deg = degrees_between_sun_and_moon
775
-
776
- # Initialize variables for lunar phase properties
777
- circle_center_x = None
778
- circle_radius = None
779
-
780
- # Determine lunar phase properties based on the degree
781
- if deg < 90.0:
782
- max_radius = deg
783
- if deg > 80.0:
784
- max_radius = max_radius * max_radius
785
- circle_center_x = 20.0 + (deg / 90.0) * (max_radius + 10.0)
786
- circle_radius = 10.0 + (deg / 90.0) * max_radius
787
-
788
- elif deg < 180.0:
789
- max_radius = 180.0 - deg
790
- if deg < 100.0:
791
- max_radius = max_radius * max_radius
792
- circle_center_x = 20.0 + ((deg - 90.0) / 90.0 * (max_radius + 10.0)) - (max_radius + 10.0)
793
- circle_radius = 10.0 + max_radius - ((deg - 90.0) / 90.0 * max_radius)
794
-
795
- elif deg < 270.0:
796
- max_radius = deg - 180.0
797
- if deg > 260.0:
798
- max_radius = max_radius * max_radius
799
- circle_center_x = 20.0 + ((deg - 180.0) / 90.0 * (max_radius + 10.0))
800
- circle_radius = 10.0 + ((deg - 180.0) / 90.0 * max_radius)
801
-
802
- elif deg < 361.0:
803
- max_radius = 360.0 - deg
804
- if deg < 280.0:
805
- max_radius = max_radius * max_radius
806
- circle_center_x = 20.0 + ((deg - 270.0) / 90.0 * (max_radius + 10.0)) - (max_radius + 10.0)
807
- circle_radius = 10.0 + max_radius - ((deg - 270.0) / 90.0 * max_radius)
808
-
809
- else:
810
- raise KerykeionException(f"Invalid degree value: {deg}")
1035
+ - dict: Normalized phase data (angle, illuminated fraction, shadow ellipse radius).
1036
+ """
1037
+ if not math.isfinite(degrees_between_sun_and_moon):
1038
+ raise KerykeionException(
1039
+ f"Invalid degree value: {degrees_between_sun_and_moon}"
1040
+ )
811
1041
 
1042
+ phase_angle = degrees_between_sun_and_moon % 360.0
1043
+ radians = math.radians(phase_angle)
1044
+ cosine = math.cos(radians)
1045
+ illuminated_fraction = (1.0 - cosine) / 2.0
812
1046
 
813
- # Calculate rotation based on latitude
814
- lunar_phase_rotate = -90.0 - latitude
1047
+ # Guard against floating point spillover outside [0, 1].
1048
+ illuminated_fraction = max(0.0, min(1.0, illuminated_fraction))
815
1049
 
816
1050
  return {
817
- "circle_center_x": circle_center_x,
818
- "circle_radius": circle_radius,
819
- "lunar_phase_rotate": lunar_phase_rotate,
1051
+ "phase_angle": phase_angle,
1052
+ "illuminated_fraction": illuminated_fraction,
1053
+ "shadow_ellipse_rx": 10.0 * cosine,
820
1054
  }
821
1055
 
822
1056
 
@@ -824,7 +1058,7 @@ def draw_main_house_grid(
824
1058
  main_subject_houses_list: list[KerykeionPointModel],
825
1059
  house_cusp_generale_name_label: str = "Cusp",
826
1060
  text_color: str = "#000000",
827
- x_position: int = 720,
1061
+ x_position: int = 750,
828
1062
  y_position: int = 30,
829
1063
  ) -> str:
830
1064
  """
@@ -862,7 +1096,7 @@ def draw_secondary_house_grid(
862
1096
  secondary_subject_houses_list: list[KerykeionPointModel],
863
1097
  house_cusp_generale_name_label: str = "Cusp",
864
1098
  text_color: str = "#000000",
865
- x_position: int = 970,
1099
+ x_position: int = 1015,
866
1100
  y_position: int = 30,
867
1101
  ) -> str:
868
1102
  """
@@ -903,57 +1137,60 @@ def draw_main_planet_grid(
903
1137
  chart_type: ChartType,
904
1138
  celestial_point_language: KerykeionLanguageCelestialPointModel,
905
1139
  text_color: str = "#000000",
1140
+ x_position: int = 645,
1141
+ y_position: int = 0,
906
1142
  ) -> str:
907
1143
  """
908
- Draws the planet grid for the main subject.
1144
+ Draw the planet grid (main subject) and optional title.
1145
+
1146
+ The entire output is wrapped in a single SVG group `<g>` so the
1147
+ whole block can be repositioned by changing the group transform.
909
1148
 
910
1149
  Args:
911
- planets_and_houses_grid_title (str): Title of the grid.
912
- subject_name (str): Name of the subject.
913
- available_kerykeion_celestial_points (list[KerykeionPointModel]): List of celestial points for the subject.
914
- chart_type (ChartType): Type of the chart.
915
- celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points.
916
- text_color (str, optional): Color of the text. Defaults to "#000000".
1150
+ planets_and_houses_grid_title: Title prefix to show for eligible chart types.
1151
+ subject_name: Subject name to append to the title.
1152
+ available_kerykeion_celestial_points: Celestial points to render in the grid.
1153
+ chart_type: Chart type identifier (Literal string).
1154
+ celestial_point_language: Language model for celestial point decoding.
1155
+ text_color: Text color for labels (default: "#000000").
1156
+ x_position: X translation applied to the outer `<g>` (default: 620).
1157
+ y_position: Y translation applied to the outer `<g>` (default: 0).
917
1158
 
918
1159
  Returns:
919
- str: The SVG output for the main planet grid.
1160
+ SVG string for the main planet grid wrapped in a `<g>`.
920
1161
  """
921
- line_height = 10
922
- offset = 0
923
- offset_between_lines = 14
924
- svg_output = ""
1162
+ # Layout constants (kept identical to previous behavior)
1163
+ BASE_Y = 30
1164
+ HEADER_Y = 15 # Title baseline inside the wrapper
1165
+ LINE_START = 10
1166
+ LINE_STEP = 14
925
1167
 
926
- if chart_type == "Synastry":
927
- svg_output += (
928
- f'<g transform="translate(620, 15)">' # Added the 620,30 offset (adjusted for -15)
929
- f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}</text>'
930
- f'</g>'
931
- )
932
- elif chart_type == "Transit":
933
- svg_output += (
934
- f'<g transform="translate(620, 15)">' # Added the 620,30 offset (adjusted for -15)
935
- f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}</text>'
936
- f'</g>'
937
- )
938
- elif chart_type == "Return":
1168
+ # Wrap everything inside a single group so position can be changed once
1169
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1170
+
1171
+ # Add title only for specific chart types
1172
+ if chart_type in ("Synastry", "Transit", "DualReturnChart"):
939
1173
  svg_output += (
940
- f'<g transform="translate(620, 15)">' # Added the 620,30 offset (adjusted for -15)
1174
+ f'<g transform="translate(0, {HEADER_Y})">'
941
1175
  f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {subject_name}</text>'
942
1176
  f'</g>'
943
1177
  )
944
- else:
945
- svg_output += ""
946
1178
 
947
1179
  end_of_line = "</g>"
948
1180
 
1181
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1182
+
949
1183
  for i, planet in enumerate(available_kerykeion_celestial_points):
950
- if i == 22:
951
- line_height = 10
952
- offset = -125
1184
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1185
+ line_height = LINE_START + (row_index * LINE_STEP)
1186
+
1187
+ decoded_name = get_decoded_kerykeion_celestial_point_name(
1188
+ planet["name"],
1189
+ celestial_point_language,
1190
+ )
953
1191
 
954
- decoded_name = get_decoded_kerykeion_celestial_point_name(planet["name"], celestial_point_language)
955
1192
  svg_output += (
956
- f'<g transform="translate({620 + offset},{30 + line_height})">' # Added the 620,30 offset
1193
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
957
1194
  f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{decoded_name}</text>'
958
1195
  f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{planet["name"]}" /></g>'
959
1196
  f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(planet["position"])}</text>'
@@ -964,7 +1201,9 @@ def draw_main_planet_grid(
964
1201
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
965
1202
 
966
1203
  svg_output += end_of_line
967
- line_height += offset_between_lines
1204
+
1205
+ # Close the wrapper group
1206
+ svg_output += "</g>"
968
1207
 
969
1208
  return svg_output
970
1209
 
@@ -976,49 +1215,66 @@ def draw_secondary_planet_grid(
976
1215
  chart_type: ChartType,
977
1216
  celestial_point_language: KerykeionLanguageCelestialPointModel,
978
1217
  text_color: str = "#000000",
1218
+ x_position: int = 910,
1219
+ y_position: int = 0,
979
1220
  ) -> str:
980
1221
  """
981
- Draws the planet grid for the secondary subject in Transit, Synastry, or Return charts.
1222
+ Draw the planet grid for the secondary subject and its title.
1223
+
1224
+ The entire output is wrapped in a single SVG group `<g>` so the
1225
+ whole block can be repositioned by changing the group transform.
982
1226
 
983
1227
  Args:
984
- planets_and_houses_grid_title (str): Title of the grid.
985
- second_subject_name (str): Name of the second subject.
986
- second_subject_available_kerykeion_celestial_points (list[KerykeionPointModel]): List of celestial points for the second subject.
987
- chart_type (ChartType): Type of the chart.
988
- celestial_point_language (KerykeionLanguageCelestialPointModel): Language model for celestial points.
989
- text_color (str, optional): Color of the text. Defaults to "#000000".
1228
+ planets_and_houses_grid_title: Title prefix (used except for Transit charts).
1229
+ second_subject_name: Name of the secondary subject.
1230
+ second_subject_available_kerykeion_celestial_points: Celestial points to render for the secondary subject.
1231
+ chart_type: Chart type identifier (Literal string).
1232
+ celestial_point_language: Language model for celestial point decoding.
1233
+ text_color: Text color for labels (default: "#000000").
1234
+ x_position: X translation applied to the outer `<g>` (default: 870).
1235
+ y_position: Y translation applied to the outer `<g>` (default: 0).
990
1236
 
991
1237
  Returns:
992
- str: The SVG output for the secondary planet grid.
1238
+ SVG string for the secondary planet grid wrapped in a `<g>`.
993
1239
  """
994
- svg_output = ""
995
- end_of_line = "</g>"
1240
+ # Layout constants
1241
+ BASE_Y = 30
1242
+ HEADER_Y = 15
1243
+ LINE_START = 10
1244
+ LINE_STEP = 14
996
1245
 
997
- if chart_type == "Transit":
998
- svg_output += (
999
- f'<g transform="translate(820, 15)">' # 620+200, 30-15
1000
- f'<text style="fill:{text_color}; font-size: 14px;">{second_subject_name}</text>'
1001
- )
1002
- elif chart_type == "Return":
1003
- svg_output += (
1004
- f'<g transform="translate(870, 15)">' # 620+250, 30-15
1005
- f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {second_subject_name}</text>'
1006
- )
1007
- else:
1008
- svg_output += (
1009
- f'<g transform="translate(870, 15)">' # 620+250, 30-15
1010
- f'<text style="fill:{text_color}; font-size: 14px;">{planets_and_houses_grid_title} {second_subject_name}</text>'
1011
- )
1246
+ # Open wrapper group
1247
+ svg_output = f'<g transform="translate({x_position},{y_position})">'
1248
+
1249
+ # Title content and its relative x offset
1250
+ header_text = (
1251
+ second_subject_name if chart_type == "Transit"
1252
+ else f"{planets_and_houses_grid_title} {second_subject_name}"
1253
+ )
1254
+ header_x_offset = -50 if chart_type == "Transit" else 0
1255
+
1256
+ svg_output += (
1257
+ f'<g transform="translate({header_x_offset}, {HEADER_Y})">'
1258
+ f'<text style="fill:{text_color}; font-size: 14px;">{header_text}</text>'
1259
+ f'</g>'
1260
+ )
1012
1261
 
1013
- svg_output += end_of_line
1262
+ # Grid rows
1263
+ line_height = LINE_START
1264
+ end_of_line = "</g>"
1014
1265
 
1015
- line_height = 10
1016
- offset = 250
1266
+ column_thresholds = _select_planet_grid_thresholds(chart_type)
1017
1267
 
1018
1268
  for i, t_planet in enumerate(second_subject_available_kerykeion_celestial_points):
1019
- second_decoded_name = get_decoded_kerykeion_celestial_point_name(t_planet["name"], celestial_point_language)
1269
+ offset, row_index = _planet_grid_layout_position(i, column_thresholds)
1270
+ line_height = LINE_START + (row_index * LINE_STEP)
1271
+
1272
+ second_decoded_name = get_decoded_kerykeion_celestial_point_name(
1273
+ t_planet["name"],
1274
+ celestial_point_language,
1275
+ )
1020
1276
  svg_output += (
1021
- f'<g transform="translate({620 + offset},{30 + line_height})">' # Added the 620,30 offset
1277
+ f'<g transform="translate({offset},{BASE_Y + line_height})">'
1022
1278
  f'<text text-anchor="end" style="fill:{text_color}; font-size: 10px;">{second_decoded_name}</text>'
1023
1279
  f'<g transform="translate(5,-8)"><use transform="scale(0.4)" xlink:href="#{t_planet["name"]}" /></g>'
1024
1280
  f'<text text-anchor="start" x="19" style="fill:{text_color}; font-size: 10px;">{convert_decimal_to_degree_string(t_planet["position"])}</text>'
@@ -1029,7 +1285,10 @@ def draw_secondary_planet_grid(
1029
1285
  svg_output += '<g transform="translate(74,-6)"><use transform="scale(.5)" xlink:href="#retrograde" /></g>'
1030
1286
 
1031
1287
  svg_output += end_of_line
1032
- line_height += 14 # Using fixed offset_between_lines value
1288
+
1289
+
1290
+ # Close wrapper group
1291
+ svg_output += "</g>"
1033
1292
 
1034
1293
  return svg_output
1035
1294
 
@@ -1155,121 +1414,89 @@ def format_datetime_with_timezone(iso_datetime_string: str) -> str:
1155
1414
 
1156
1415
 
1157
1416
  def calculate_element_points(
1158
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1159
- celestial_points_names: list[str],
1160
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1161
- ):
1417
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1418
+ celestial_points_names: Sequence[str],
1419
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1420
+ *,
1421
+ method: ElementQualityDistributionMethod = "weighted",
1422
+ custom_weights: Optional[Mapping[str, float]] = None,
1423
+ ) -> dict[str, float]:
1162
1424
  """
1163
- Calculate elemental point totals based on planetary positions.
1425
+ Calculate elemental totals for a subject using the selected strategy.
1164
1426
 
1165
1427
  Args:
1166
- planets_settings (list): List of planet configuration dictionaries
1167
- celestial_points_names (list): List of celestial point names to process
1168
- subject: Astrological subject with get() method for accessing planet data
1428
+ planets_settings: Planet configuration list (kept for API compatibility).
1429
+ celestial_points_names: Celestial point names to include.
1430
+ subject: Astrological subject with planetary data.
1431
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1432
+ custom_weights: Optional overrides for point weights keyed by name.
1169
1433
 
1170
1434
  Returns:
1171
- dict: Dictionary with element point totals for 'fire', 'earth', 'air', and 'water'
1172
- """
1173
- ZODIAC = (
1174
- {"name": "Ari", "element": "fire"},
1175
- {"name": "Tau", "element": "earth"},
1176
- {"name": "Gem", "element": "air"},
1177
- {"name": "Can", "element": "water"},
1178
- {"name": "Leo", "element": "fire"},
1179
- {"name": "Vir", "element": "earth"},
1180
- {"name": "Lib", "element": "air"},
1181
- {"name": "Sco", "element": "water"},
1182
- {"name": "Sag", "element": "fire"},
1183
- {"name": "Cap", "element": "earth"},
1184
- {"name": "Aqu", "element": "air"},
1185
- {"name": "Pis", "element": "water"},
1435
+ Dictionary mapping each element to its accumulated total.
1436
+ """
1437
+ normalized_names = [name.lower() for name in celestial_points_names]
1438
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1439
+
1440
+ return _calculate_distribution_for_subject(
1441
+ subject,
1442
+ normalized_names,
1443
+ _SIGN_TO_ELEMENT,
1444
+ _ELEMENT_KEYS,
1445
+ weight_lookup,
1446
+ fallback_weight,
1186
1447
  )
1187
1448
 
1188
- # Initialize element point totals
1189
- element_totals = {
1190
- "fire": 0.0,
1191
- "earth": 0.0,
1192
- "air": 0.0,
1193
- "water": 0.0
1194
- }
1195
-
1196
- # Make list of the points sign
1197
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1198
-
1199
- for i in range(len(planets_settings)):
1200
- # Add points to appropriate element
1201
- element = ZODIAC[points_sign[i]]["element"]
1202
- element_totals[element] += planets_settings[i]["element_points"]
1203
-
1204
- return element_totals
1205
-
1206
1449
 
1207
1450
  def calculate_synastry_element_points(
1208
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1209
- celestial_points_names: list[str],
1210
- subject1: AstrologicalSubjectModel,
1211
- subject2: AstrologicalSubjectModel,
1212
- ):
1451
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1452
+ celestial_points_names: Sequence[str],
1453
+ subject1: AstrologicalSubjectModel,
1454
+ subject2: AstrologicalSubjectModel,
1455
+ *,
1456
+ method: ElementQualityDistributionMethod = "weighted",
1457
+ custom_weights: Optional[Mapping[str, float]] = None,
1458
+ ) -> dict[str, float]:
1213
1459
  """
1214
- Calculate elemental point totals for both subjects in a synastry chart.
1460
+ Calculate combined element percentages for a synastry chart.
1215
1461
 
1216
1462
  Args:
1217
- planets_settings (list): List of planet configuration dictionaries
1218
- celestial_points_names (list): List of celestial point names to process
1219
- subject1: First astrological subject with get() method for accessing planet data
1220
- subject2: Second astrological subject with get() method for accessing planet data
1463
+ planets_settings: Planet configuration list (unused but preserved).
1464
+ celestial_points_names: Celestial point names to process.
1465
+ subject1: First astrological subject.
1466
+ subject2: Second astrological subject.
1467
+ method: Calculation strategy (pure_count or weighted).
1468
+ custom_weights: Optional overrides for point weights.
1221
1469
 
1222
1470
  Returns:
1223
- dict: Dictionary with element point totals as percentages, where the sum equals 100%
1224
- """
1225
- ZODIAC = (
1226
- {"name": "Ari", "element": "fire"},
1227
- {"name": "Tau", "element": "earth"},
1228
- {"name": "Gem", "element": "air"},
1229
- {"name": "Can", "element": "water"},
1230
- {"name": "Leo", "element": "fire"},
1231
- {"name": "Vir", "element": "earth"},
1232
- {"name": "Lib", "element": "air"},
1233
- {"name": "Sco", "element": "water"},
1234
- {"name": "Sag", "element": "fire"},
1235
- {"name": "Cap", "element": "earth"},
1236
- {"name": "Aqu", "element": "air"},
1237
- {"name": "Pis", "element": "water"},
1471
+ Dictionary with element percentages summing to 100.
1472
+ """
1473
+ normalized_names = [name.lower() for name in celestial_points_names]
1474
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1475
+
1476
+ subject1_totals = _calculate_distribution_for_subject(
1477
+ subject1,
1478
+ normalized_names,
1479
+ _SIGN_TO_ELEMENT,
1480
+ _ELEMENT_KEYS,
1481
+ weight_lookup,
1482
+ fallback_weight,
1483
+ )
1484
+ subject2_totals = _calculate_distribution_for_subject(
1485
+ subject2,
1486
+ normalized_names,
1487
+ _SIGN_TO_ELEMENT,
1488
+ _ELEMENT_KEYS,
1489
+ weight_lookup,
1490
+ fallback_weight,
1238
1491
  )
1239
1492
 
1240
- # Initialize combined element point totals
1241
- combined_totals = {
1242
- "fire": 0.0,
1243
- "earth": 0.0,
1244
- "air": 0.0,
1245
- "water": 0.0
1246
- }
1247
-
1248
- # Make list of the points sign for both subjects
1249
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1250
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1251
-
1252
- # Calculate element points for subject 1
1253
- for i in range(len(planets_settings)):
1254
- # Add points to appropriate element
1255
- element1 = ZODIAC[subject1_points_sign[i]]["element"]
1256
- combined_totals[element1] += planets_settings[i]["element_points"]
1257
-
1258
- # Calculate element points for subject 2
1259
- for i in range(len(planets_settings)):
1260
- # Add points to appropriate element
1261
- element2 = ZODIAC[subject2_points_sign[i]]["element"]
1262
- combined_totals[element2] += planets_settings[i]["element_points"]
1263
-
1264
- # Calculate total points across all elements
1493
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _ELEMENT_KEYS}
1265
1494
  total_points = sum(combined_totals.values())
1266
1495
 
1267
- # Convert to percentages (total = 100%)
1268
- if total_points > 0:
1269
- for element in combined_totals:
1270
- combined_totals[element] = (combined_totals[element] / total_points) * 100.0
1496
+ if total_points == 0:
1497
+ return {key: 0.0 for key in _ELEMENT_KEYS}
1271
1498
 
1272
- return combined_totals
1499
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _ELEMENT_KEYS}
1273
1500
 
1274
1501
 
1275
1502
  def draw_house_comparison_grid(
@@ -1281,9 +1508,9 @@ def draw_house_comparison_grid(
1281
1508
  text_color: str = "var(--kerykeion-color-neutral-content)",
1282
1509
  house_position_comparison_label: str = "House Position Comparison",
1283
1510
  return_point_label: str = "Return Point",
1284
- return_label: str = "Return",
1511
+ return_label: str = "DualReturnChart",
1285
1512
  radix_label: str = "Radix",
1286
- x_position: int = 1030,
1513
+ x_position: int = 1100,
1287
1514
  y_position: int = 0,
1288
1515
  ) -> str:
1289
1516
  """
@@ -1439,149 +1666,175 @@ def makeLunarPhase(degrees_between_sun_and_moon: float, latitude: float) -> str:
1439
1666
 
1440
1667
  Parameters:
1441
1668
  - degrees_between_sun_and_moon (float): Angle between sun and moon in degrees
1442
- - latitude (float): Observer's latitude for correct orientation
1669
+ - latitude (float): Observer's latitude (no longer used, kept for backward compatibility)
1443
1670
 
1444
1671
  Returns:
1445
1672
  - str: SVG representation of lunar phase
1446
1673
  """
1447
- # Calculate parameters for the lunar phase visualization
1448
- params = calculate_moon_phase_chart_params(degrees_between_sun_and_moon, latitude)
1449
-
1450
- # Extract the calculated values
1451
- lunar_phase_circle_center_x = params["circle_center_x"]
1452
- lunar_phase_circle_radius = params["circle_radius"]
1453
- lunar_phase_rotate = params["lunar_phase_rotate"]
1454
-
1455
- # Generate the SVG for the lunar phase
1456
- svg = (
1457
- f'<g transform="rotate({lunar_phase_rotate} 20 10)">\n'
1458
- f' <defs>\n'
1459
- f' <clipPath id="moonPhaseCutOffCircle">\n'
1460
- f' <circle cx="20" cy="10" r="10" />\n'
1461
- f' </clipPath>\n'
1462
- f' </defs>\n'
1463
- f' <circle cx="20" cy="10" r="10" style="fill: var(--kerykeion-chart-color-lunar-phase-0)" />\n'
1464
- f' <circle cx="{lunar_phase_circle_center_x}" cy="10" r="{lunar_phase_circle_radius}" style="fill: var(--kerykeion-chart-color-lunar-phase-1)" clip-path="url(#moonPhaseCutOffCircle)" />\n'
1465
- f' <circle cx="20" cy="10" r="10" style="fill: none; stroke: var(--kerykeion-chart-color-lunar-phase-0); stroke-width: 0.5px; stroke-opacity: 0.5" />\n'
1466
- f'</g>'
1467
- )
1674
+ params = calculate_moon_phase_chart_params(degrees_between_sun_and_moon)
1468
1675
 
1469
- return svg
1676
+ phase_angle = params["phase_angle"]
1677
+ illuminated_fraction = 1.0 - params["illuminated_fraction"]
1678
+ shadow_ellipse_rx = abs(params["shadow_ellipse_rx"])
1470
1679
 
1680
+ radius = 10.0
1681
+ center_x = 20.0
1682
+ center_y = 10.0
1471
1683
 
1472
- def calculate_quality_points(
1473
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1474
- celestial_points_names: list[str],
1475
- subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1476
- ):
1477
- """
1478
- Calculate quality point totals based on planetary positions.
1684
+ bright_color = "var(--kerykeion-chart-color-lunar-phase-1)"
1685
+ shadow_color = "var(--kerykeion-chart-color-lunar-phase-0)"
1479
1686
 
1480
- Args:
1481
- planets_settings (list): List of planet configuration dictionaries
1482
- celestial_points_names (list): List of celestial point names to process
1483
- subject: Astrological subject with get() method for accessing planet data
1484
- planet_in_zodiac_extra_points (int): Extra points awarded for planets in their home sign
1687
+ is_waxing = phase_angle < 180.0
1485
1688
 
1486
- Returns:
1487
- dict: Dictionary with quality point totals for 'cardinal', 'fixed', and 'mutable'
1488
- """
1489
- ZODIAC = (
1490
- {"name": "Ari", "quality": "cardinal"},
1491
- {"name": "Tau", "quality": "fixed"},
1492
- {"name": "Gem", "quality": "mutable"},
1493
- {"name": "Can", "quality": "cardinal"},
1494
- {"name": "Leo", "quality": "fixed"},
1495
- {"name": "Vir", "quality": "mutable"},
1496
- {"name": "Lib", "quality": "cardinal"},
1497
- {"name": "Sco", "quality": "fixed"},
1498
- {"name": "Sag", "quality": "mutable"},
1499
- {"name": "Cap", "quality": "cardinal"},
1500
- {"name": "Aqu", "quality": "fixed"},
1501
- {"name": "Pis", "quality": "mutable"},
1502
- )
1503
-
1504
- # Initialize quality point totals
1505
- quality_totals = {
1506
- "cardinal": 0.0,
1507
- "fixed": 0.0,
1508
- "mutable": 0.0
1509
- }
1689
+ if illuminated_fraction <= 1e-6:
1690
+ base_fill = shadow_color
1691
+ overlay_path = ""
1692
+ overlay_fill = ""
1693
+ elif 1.0 - illuminated_fraction <= 1e-6:
1694
+ base_fill = bright_color
1695
+ overlay_path = ""
1696
+ overlay_fill = ""
1697
+ else:
1698
+ is_lit_major = illuminated_fraction >= 0.5
1699
+ if is_lit_major:
1700
+ base_fill = bright_color
1701
+ overlay_fill = shadow_color
1702
+ overlay_side = "left" if is_waxing else "right"
1703
+ else:
1704
+ base_fill = shadow_color
1705
+ overlay_fill = bright_color
1706
+ overlay_side = "right" if is_waxing else "left"
1707
+
1708
+ # The illuminated limb is the orthographic projection of the lunar terminator;
1709
+ # it appears as an ellipse with vertical radius equal to the lunar radius and
1710
+ # horizontal radius scaled by |cos(phase)|.
1711
+ def build_lune_path(side: str, ellipse_rx: float) -> str:
1712
+ ellipse_rx = max(0.0, min(radius, ellipse_rx))
1713
+ top_y = center_y - radius
1714
+ bottom_y = center_y + radius
1715
+ circle_sweep = 1 if side == "right" else 0
1716
+
1717
+ if ellipse_rx <= 1e-6:
1718
+ return (
1719
+ f"M {center_x:.4f} {top_y:.4f}"
1720
+ f" A {radius:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {bottom_y:.4f}"
1721
+ f" L {center_x:.4f} {top_y:.4f}"
1722
+ " Z"
1723
+ )
1724
+
1725
+ return (
1726
+ f"M {center_x:.4f} {top_y:.4f}"
1727
+ f" A {radius:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {bottom_y:.4f}"
1728
+ f" A {ellipse_rx:.4f} {radius:.4f} 0 0 {circle_sweep} {center_x:.4f} {top_y:.4f}"
1729
+ " Z"
1730
+ )
1510
1731
 
1511
- # Make list of the points sign
1512
- points_sign = [subject.get(planet).sign_num for planet in celestial_points_names]
1732
+ overlay_path = build_lune_path(overlay_side, shadow_ellipse_rx)
1733
+
1734
+ svg_lines = [
1735
+ '<g transform="rotate(0 20 10)">',
1736
+ ' <defs>',
1737
+ ' <clipPath id="moonPhaseCutOffCircle">',
1738
+ ' <circle cx="20" cy="10" r="10" />',
1739
+ ' </clipPath>',
1740
+ ' </defs>',
1741
+ f' <circle cx="20" cy="10" r="10" style="fill: {base_fill}" />',
1742
+ ]
1743
+
1744
+ if overlay_path:
1745
+ svg_lines.append(
1746
+ f' <path d="{overlay_path}" style="fill: {overlay_fill}" clip-path="url(#moonPhaseCutOffCircle)" />'
1747
+ )
1513
1748
 
1514
- for i in range(len(planets_settings)):
1515
- # Add points to appropriate quality
1516
- quality = ZODIAC[points_sign[i]]["quality"]
1517
- quality_totals[quality] += planets_settings[i]["element_points"]
1749
+ svg_lines.append(
1750
+ ' <circle cx="20" cy="10" r="10" style="fill: none; stroke: var(--kerykeion-chart-color-lunar-phase-0); stroke-width: 0.5px; stroke-opacity: 0.5" />'
1751
+ )
1752
+ svg_lines.append('</g>')
1518
1753
 
1519
- return quality_totals
1754
+ return "\n".join(svg_lines)
1520
1755
 
1521
1756
 
1522
- def calculate_synastry_quality_points(
1523
- planets_settings: list[KerykeionSettingsCelestialPointModel],
1524
- celestial_points_names: list[str],
1525
- subject1: AstrologicalSubjectModel,
1526
- subject2: AstrologicalSubjectModel,
1527
- ):
1757
+ def calculate_quality_points(
1758
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1759
+ celestial_points_names: Sequence[str],
1760
+ subject: Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel],
1761
+ *,
1762
+ method: ElementQualityDistributionMethod = "weighted",
1763
+ custom_weights: Optional[Mapping[str, float]] = None,
1764
+ ) -> dict[str, float]:
1528
1765
  """
1529
- Calculate quality point totals for both subjects in a synastry chart.
1766
+ Calculate modality totals for a subject using the selected strategy.
1530
1767
 
1531
1768
  Args:
1532
- planets_settings (list): List of planet configuration dictionaries
1533
- celestial_points_names (list): List of celestial point names to process
1534
- subject1: First astrological subject with get() method for accessing planet data
1535
- subject2: Second astrological subject with get() method for accessing planet data
1769
+ planets_settings: Planet configuration list (kept for API compatibility).
1770
+ celestial_points_names: Celestial point names to include.
1771
+ subject: Astrological subject with planetary data.
1772
+ method: Calculation method (pure_count or weighted). Defaults to weighted.
1773
+ custom_weights: Optional overrides for point weights keyed by name.
1536
1774
 
1537
1775
  Returns:
1538
- dict: Dictionary with quality point totals as percentages, where the sum equals 100%
1539
- """
1540
- ZODIAC = (
1541
- {"name": "Ari", "quality": "cardinal"},
1542
- {"name": "Tau", "quality": "fixed"},
1543
- {"name": "Gem", "quality": "mutable"},
1544
- {"name": "Can", "quality": "cardinal"},
1545
- {"name": "Leo", "quality": "fixed"},
1546
- {"name": "Vir", "quality": "mutable"},
1547
- {"name": "Lib", "quality": "cardinal"},
1548
- {"name": "Sco", "quality": "fixed"},
1549
- {"name": "Sag", "quality": "mutable"},
1550
- {"name": "Cap", "quality": "cardinal"},
1551
- {"name": "Aqu", "quality": "fixed"},
1552
- {"name": "Pis", "quality": "mutable"},
1776
+ Dictionary mapping each modality to its accumulated total.
1777
+ """
1778
+ normalized_names = [name.lower() for name in celestial_points_names]
1779
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1780
+
1781
+ return _calculate_distribution_for_subject(
1782
+ subject,
1783
+ normalized_names,
1784
+ _SIGN_TO_QUALITY,
1785
+ _QUALITY_KEYS,
1786
+ weight_lookup,
1787
+ fallback_weight,
1553
1788
  )
1554
1789
 
1555
- # Initialize combined quality point totals
1556
- combined_totals = {
1557
- "cardinal": 0.0,
1558
- "fixed": 0.0,
1559
- "mutable": 0.0
1560
- }
1561
1790
 
1562
- # Make list of the points sign for both subjects
1563
- subject1_points_sign = [subject1.get(planet).sign_num for planet in celestial_points_names]
1564
- subject2_points_sign = [subject2.get(planet).sign_num for planet in celestial_points_names]
1791
+ def calculate_synastry_quality_points(
1792
+ planets_settings: Sequence[KerykeionSettingsCelestialPointModel],
1793
+ celestial_points_names: Sequence[str],
1794
+ subject1: AstrologicalSubjectModel,
1795
+ subject2: AstrologicalSubjectModel,
1796
+ *,
1797
+ method: ElementQualityDistributionMethod = "weighted",
1798
+ custom_weights: Optional[Mapping[str, float]] = None,
1799
+ ) -> dict[str, float]:
1800
+ """
1801
+ Calculate combined modality percentages for a synastry chart.
1565
1802
 
1566
- # Calculate quality points for subject 1
1567
- for i in range(len(planets_settings)):
1568
- # Add points to appropriate quality
1569
- quality1 = ZODIAC[subject1_points_sign[i]]["quality"]
1570
- combined_totals[quality1] += planets_settings[i]["element_points"]
1803
+ Args:
1804
+ planets_settings: Planet configuration list (unused but preserved).
1805
+ celestial_points_names: Celestial point names to process.
1806
+ subject1: First astrological subject.
1807
+ subject2: Second astrological subject.
1808
+ method: Calculation strategy (pure_count or weighted).
1809
+ custom_weights: Optional overrides for point weights.
1571
1810
 
1572
- # Calculate quality points for subject 2
1573
- for i in range(len(planets_settings)):
1574
- # Add points to appropriate quality
1575
- quality2 = ZODIAC[subject2_points_sign[i]]["quality"]
1576
- combined_totals[quality2] += planets_settings[i]["element_points"]
1811
+ Returns:
1812
+ Dictionary with modality percentages summing to 100.
1813
+ """
1814
+ normalized_names = [name.lower() for name in celestial_points_names]
1815
+ weight_lookup, fallback_weight = _prepare_weight_lookup(method, custom_weights)
1816
+
1817
+ subject1_totals = _calculate_distribution_for_subject(
1818
+ subject1,
1819
+ normalized_names,
1820
+ _SIGN_TO_QUALITY,
1821
+ _QUALITY_KEYS,
1822
+ weight_lookup,
1823
+ fallback_weight,
1824
+ )
1825
+ subject2_totals = _calculate_distribution_for_subject(
1826
+ subject2,
1827
+ normalized_names,
1828
+ _SIGN_TO_QUALITY,
1829
+ _QUALITY_KEYS,
1830
+ weight_lookup,
1831
+ fallback_weight,
1832
+ )
1577
1833
 
1578
- # Calculate total points across all qualities
1834
+ combined_totals = {key: subject1_totals[key] + subject2_totals[key] for key in _QUALITY_KEYS}
1579
1835
  total_points = sum(combined_totals.values())
1580
1836
 
1581
- # Convert to percentages (total = 100%)
1582
- if total_points > 0:
1583
- for quality in combined_totals:
1584
- combined_totals[quality] = (combined_totals[quality] / total_points) * 100.0
1585
-
1586
- return combined_totals
1837
+ if total_points == 0:
1838
+ return {key: 0.0 for key in _QUALITY_KEYS}
1587
1839
 
1840
+ return {key: (combined_totals[key] / total_points) * 100.0 for key in _QUALITY_KEYS}