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
kerykeion/report.py CHANGED
@@ -1,94 +1,779 @@
1
- from kerykeion import AstrologicalSubjectFactory
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import List, Optional, Sequence, Tuple, Union, Literal
5
+
2
6
  from simple_ascii_tables import AsciiTable
3
- from kerykeion.utilities import get_houses_list, get_available_astrological_points_list
4
- from typing import Union
5
- from kerykeion.kr_types.kr_models import AstrologicalSubjectModel
6
7
 
7
- class Report:
8
+ from kerykeion.utilities import get_available_astrological_points_list, get_houses_list
9
+ from kerykeion.schemas.kr_models import (
10
+ AstrologicalSubjectModel,
11
+ ChartDataModel,
12
+ CompositeSubjectModel,
13
+ DualChartDataModel,
14
+ PlanetReturnModel,
15
+ PointInHouseModel,
16
+ RelationshipScoreModel,
17
+ SingleChartDataModel,
18
+ KerykeionPointModel,
19
+ )
20
+
21
+
22
+ ASPECT_SYMBOLS = {
23
+ "conjunction": "☌",
24
+ "opposition": "☍",
25
+ "trine": "△",
26
+ "square": "□",
27
+ "sextile": "⚹",
28
+ "quincunx": "⚻",
29
+ "semisquare": "∠",
30
+ "sesquisquare": "⚼",
31
+ "quintile": "Q",
32
+ }
33
+
34
+ MOVEMENT_SYMBOLS = {
35
+ "Applying": "→",
36
+ "Separating": "←",
37
+ "Exact": "✓",
38
+ }
39
+
40
+
41
+ SubjectLike = Union[AstrologicalSubjectModel, CompositeSubjectModel, PlanetReturnModel]
42
+ LiteralReportKind = Literal["subject", "single_chart", "dual_chart"]
43
+
44
+
45
+ class ReportGenerator:
8
46
  """
9
- Create a report for a Kerykeion instance.
47
+ Generate textual reports for astrological data models with a structure that mirrors the
48
+ chart-specific dispatch logic used in :class:`~kerykeion.charts.chart_drawer.ChartDrawer`.
49
+
50
+ The generator accepts any of the chart data models handled by ``ChartDrawer`` as well as
51
+ raw ``AstrologicalSubjectModel`` instances. The ``print_report`` method automatically
52
+ selects the appropriate layout and sections depending on the underlying chart type.
10
53
  """
11
54
 
12
- report_title: str
13
- data_table: str
14
- planets_table: str
15
- houses_table: str
55
+ def __init__(
56
+ self,
57
+ model: Union[ChartDataModel, AstrologicalSubjectModel],
58
+ *,
59
+ include_aspects: bool = True,
60
+ max_aspects: Optional[int] = None,
61
+ ) -> None:
62
+ self.model = model
63
+ self._include_aspects_default = include_aspects
64
+ self._max_aspects_default = max_aspects
16
65
 
17
- def __init__(self, instance: AstrologicalSubjectModel):
18
- self.instance = instance
66
+ self.chart_type: Optional[str] = None
67
+ self._model_kind: LiteralReportKind
68
+ self._chart_data: Optional[ChartDataModel] = None
69
+ self._primary_subject: SubjectLike
70
+ self._secondary_subject: Optional[SubjectLike] = None
71
+ self._active_points: List[str] = []
72
+ self._active_aspects: List[dict] = []
19
73
 
20
- self.get_report_title()
21
- self.get_data_table()
22
- self.get_planets_table()
23
- self.get_houses_table()
74
+ self._resolve_model()
24
75
 
25
- def get_report_title(self) -> None:
26
- self.report_title = f"\n+- Kerykeion report for {self.instance.name} -+"
76
+ # ------------------------------------------------------------------ #
77
+ # Public API
78
+ # ------------------------------------------------------------------ #
27
79
 
28
- def get_data_table(self) -> None:
80
+ def generate_report(
81
+ self,
82
+ *,
83
+ include_aspects: Optional[bool] = None,
84
+ max_aspects: Optional[int] = None,
85
+ ) -> str:
29
86
  """
30
- Creates the data table of the report.
87
+ Build the report content without printing it.
88
+
89
+ Args:
90
+ include_aspects: Override the default setting for including the aspects section.
91
+ max_aspects: Override the default limit for the number of aspects displayed.
31
92
  """
93
+ include_aspects = self._include_aspects_default if include_aspects is None else include_aspects
94
+ max_aspects = self._max_aspects_default if max_aspects is None else max_aspects
32
95
 
33
- main_data = [["Date", "Time", "Location", "Longitude", "Latitude"]] + [
34
- [
35
- f"{self.instance.day}/{self.instance.month}/{self.instance.year}",
36
- f"{self.instance.hour}:{self.instance.minute}",
37
- f"{self.instance.city}, {self.instance.nation}",
38
- self.instance.lng,
39
- self.instance.lat,
40
- ]
41
- ]
42
- self.data_table = AsciiTable(main_data).table
96
+ if self._model_kind == "subject":
97
+ sections = self._build_subject_report()
98
+ elif self._model_kind == "single_chart":
99
+ sections = self._build_single_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
100
+ else:
101
+ sections = self._build_dual_chart_report(include_aspects=include_aspects, max_aspects=max_aspects)
43
102
 
44
- def get_planets_table(self) -> None:
103
+ title = self._build_title().strip("\n")
104
+ full_sections = [title, *[section for section in sections if section]]
105
+ return "\n\n".join(full_sections)
106
+
107
+ def print_report(
108
+ self,
109
+ *,
110
+ include_aspects: Optional[bool] = None,
111
+ max_aspects: Optional[int] = None,
112
+ ) -> None:
45
113
  """
46
- Creates the planets table.
114
+ Print the generated report to stdout.
47
115
  """
116
+ print(self.generate_report(include_aspects=include_aspects, max_aspects=max_aspects))
117
+
118
+ # ------------------------------------------------------------------ #
119
+ # Internal initialisation helpers
120
+ # ------------------------------------------------------------------ #
121
+
122
+ def _resolve_model(self) -> None:
123
+ if isinstance(self.model, AstrologicalSubjectModel):
124
+ self._model_kind = "subject"
125
+ self.chart_type = "Subject"
126
+ self._primary_subject = self.model
127
+ self._secondary_subject = None
128
+ self._active_points = list(self.model.active_points)
129
+ self._active_aspects = []
130
+ elif isinstance(self.model, SingleChartDataModel):
131
+ self._model_kind = "single_chart"
132
+ self.chart_type = self.model.chart_type
133
+ self._chart_data = self.model
134
+ self._primary_subject = self.model.subject
135
+ self._active_points = list(self.model.active_points)
136
+ self._active_aspects = [dict(aspect) for aspect in self.model.active_aspects]
137
+ elif isinstance(self.model, DualChartDataModel):
138
+ self._model_kind = "dual_chart"
139
+ self.chart_type = self.model.chart_type
140
+ self._chart_data = self.model
141
+ self._primary_subject = self.model.first_subject
142
+ self._secondary_subject = self.model.second_subject
143
+ self._active_points = list(self.model.active_points)
144
+ self._active_aspects = [dict(aspect) for aspect in self.model.active_aspects]
145
+ else:
146
+ supported = (
147
+ "AstrologicalSubjectModel, SingleChartDataModel, DualChartDataModel"
148
+ )
149
+ raise TypeError(f"Unsupported model type {type(self.model)!r}. Supported models: {supported}.")
48
150
 
49
- planets_data = [["AstrologicalPoint", "Sign", "Pos.", "Ret.", "House"]] + [
50
- [
51
- planet.name,
52
- planet.sign,
53
- round(float(planet.position), 2),
54
- ("R" if planet.retrograde else "-"),
55
- planet.house,
56
- ]
57
- for planet in get_available_astrological_points_list(self.instance)
151
+ # ------------------------------------------------------------------ #
152
+ # Report builders
153
+ # ------------------------------------------------------------------ #
154
+
155
+ def _build_subject_report(self) -> List[str]:
156
+ sections = [
157
+ self._subject_data_report(self._primary_subject, "Astrological Subject"),
158
+ self._celestial_points_report(self._primary_subject, "Celestial Points"),
159
+ self._houses_report(self._primary_subject, "Houses"),
160
+ self._lunar_phase_report(self._primary_subject),
58
161
  ]
162
+ return sections
59
163
 
60
- self.planets_table = AsciiTable(planets_data).table
164
+ def _build_single_chart_report(self, *, include_aspects: bool, max_aspects: Optional[int]) -> List[str]:
165
+ assert self._chart_data is not None
166
+ sections: List[str] = [
167
+ self._subject_data_report(self._primary_subject, self._primary_subject_label()),
168
+ ]
61
169
 
62
- def get_houses_table(self) -> None:
63
- """
64
- Creates the houses table.
65
- """
170
+ if isinstance(self._primary_subject, CompositeSubjectModel):
171
+ sections.append(
172
+ self._subject_data_report(
173
+ self._primary_subject.first_subject,
174
+ "Composite – First Subject",
175
+ )
176
+ )
177
+ sections.append(
178
+ self._subject_data_report(
179
+ self._primary_subject.second_subject,
180
+ "Composite – Second Subject",
181
+ )
182
+ )
183
+
184
+ sections.extend([
185
+ self._celestial_points_report(self._primary_subject, f"{self._primary_subject_label()} Celestial Points"),
186
+ self._houses_report(self._primary_subject, f"{self._primary_subject_label()} Houses"),
187
+ self._lunar_phase_report(self._primary_subject),
188
+ self._elements_report(),
189
+ self._qualities_report(),
190
+ self._active_configuration_report(),
191
+ ])
192
+
193
+ if include_aspects:
194
+ sections.append(self._aspects_report(max_aspects=max_aspects))
195
+
196
+ return sections
197
+
198
+ def _build_dual_chart_report(self, *, include_aspects: bool, max_aspects: Optional[int]) -> List[str]:
199
+ assert self._chart_data is not None
200
+ primary_label, secondary_label = self._subject_role_labels()
66
201
 
67
- houses_data = [["House", "Sign", "Position"]] + [
68
- [house.name, house.sign, round(float(house.position), 2)] for house in get_houses_list(self.instance)
202
+ sections: List[str] = [
203
+ self._subject_data_report(self._primary_subject, primary_label),
69
204
  ]
70
205
 
71
- self.houses_table = AsciiTable(houses_data).table
206
+ if self._secondary_subject is not None:
207
+ sections.append(self._subject_data_report(self._secondary_subject, secondary_label))
72
208
 
73
- def get_full_report(self) -> str:
74
- """
75
- Returns the full report.
76
- """
209
+ sections.extend([
210
+ self._celestial_points_report(self._primary_subject, f"{primary_label} Celestial Points"),
211
+ ])
77
212
 
78
- return f"{self.report_title}\n{self.data_table}\n{self.planets_table}\n{self.houses_table}"
213
+ if self._secondary_subject is not None:
214
+ sections.append(
215
+ self._celestial_points_report(self._secondary_subject, f"{secondary_label} Celestial Points")
216
+ )
79
217
 
80
- def print_report(self) -> None:
81
- """
82
- Print the report.
83
- """
218
+ sections.append(self._houses_report(self._primary_subject, f"{primary_label} Houses"))
219
+
220
+ if self._secondary_subject is not None:
221
+ sections.append(self._houses_report(self._secondary_subject, f"{secondary_label} Houses"))
222
+
223
+ sections.extend([
224
+ self._lunar_phase_report(self._primary_subject),
225
+ self._elements_report(),
226
+ self._qualities_report(),
227
+ self._house_comparison_report(),
228
+ self._relationship_score_report(),
229
+ self._active_configuration_report(),
230
+ ])
231
+
232
+ if include_aspects:
233
+ sections.append(self._aspects_report(max_aspects=max_aspects))
234
+
235
+ return sections
236
+
237
+ # ------------------------------------------------------------------ #
238
+ # Section helpers
239
+ # ------------------------------------------------------------------ #
240
+
241
+ def _build_title(self) -> str:
242
+ if self._model_kind == "subject":
243
+ base_title = f"{self._primary_subject.name} — Subject Report"
244
+ elif self.chart_type == "Natal":
245
+ base_title = f"{self._primary_subject.name} — Natal Chart Report"
246
+ elif self.chart_type == "Composite":
247
+ if isinstance(self._primary_subject, CompositeSubjectModel):
248
+ first = self._primary_subject.first_subject.name
249
+ second = self._primary_subject.second_subject.name
250
+ base_title = f"{first} & {second} — Composite Report"
251
+ else:
252
+ base_title = f"{self._primary_subject.name} — Composite Report"
253
+ elif self.chart_type == "SingleReturnChart":
254
+ year = self._extract_year(self._primary_subject.iso_formatted_local_datetime)
255
+ if isinstance(self._primary_subject, PlanetReturnModel) and self._primary_subject.return_type == "Solar":
256
+ base_title = f"{self._primary_subject.name} — Solar Return {year or ''}".strip()
257
+ else:
258
+ base_title = f"{self._primary_subject.name} — Lunar Return {year or ''}".strip()
259
+ elif self.chart_type == "Transit":
260
+ date_str = self._format_date(
261
+ self._secondary_subject.iso_formatted_local_datetime if self._secondary_subject else None
262
+ )
263
+ base_title = f"{self._primary_subject.name} — Transit {date_str}".strip()
264
+ elif self.chart_type == "Synastry":
265
+ second_name = self._secondary_subject.name if self._secondary_subject is not None else "Unknown"
266
+ base_title = f"{self._primary_subject.name} & {second_name} — Synastry Report"
267
+ elif self.chart_type == "DualReturnChart":
268
+ year = self._extract_year(
269
+ self._secondary_subject.iso_formatted_local_datetime if self._secondary_subject else None
270
+ )
271
+ if isinstance(self._secondary_subject, PlanetReturnModel) and self._secondary_subject.return_type == "Solar":
272
+ base_title = f"{self._primary_subject.name} — Solar Return Comparison {year or ''}".strip()
273
+ else:
274
+ base_title = f"{self._primary_subject.name} — Lunar Return Comparison {year or ''}".strip()
275
+ else:
276
+ base_title = f"{self._primary_subject.name} — Chart Report"
277
+
278
+ separator = "=" * len(base_title)
279
+ return f"\n{separator}\n{base_title}\n{separator}\n"
280
+
281
+ def _primary_subject_label(self) -> str:
282
+ if self.chart_type == "Composite":
283
+ return "Composite Chart"
284
+ if self.chart_type == "SingleReturnChart":
285
+ if isinstance(self._primary_subject, PlanetReturnModel) and self._primary_subject.return_type == "Solar":
286
+ return "Solar Return Chart"
287
+ return "Lunar Return Chart"
288
+ return f"{self.chart_type or 'Chart'}"
289
+
290
+ def _subject_role_labels(self) -> Tuple[str, str]:
291
+ if self.chart_type == "Transit":
292
+ return "Natal Subject", "Transit Subject"
293
+ if self.chart_type == "Synastry":
294
+ return "First Subject", "Second Subject"
295
+ if self.chart_type == "DualReturnChart":
296
+ return "Natal Subject", "Return Subject"
297
+ return "Primary Subject", "Secondary Subject"
298
+
299
+ def _subject_data_report(self, subject: SubjectLike, label: str) -> str:
300
+ birth_data = [["Field", "Value"], ["Name", subject.name]]
301
+
302
+ if isinstance(subject, CompositeSubjectModel):
303
+ composite_members = f"{subject.first_subject.name} & {subject.second_subject.name}"
304
+ birth_data.append(["Composite Members", composite_members])
305
+ birth_data.append(["Composite Type", subject.composite_chart_type])
306
+
307
+ if isinstance(subject, PlanetReturnModel):
308
+ birth_data.append(["Return Type", subject.return_type])
309
+
310
+ if isinstance(subject, AstrologicalSubjectModel):
311
+ birth_data.append(
312
+ ["Date", f"{subject.day:02d}/{subject.month:02d}/{subject.year}"]
313
+ )
314
+ birth_data.append(["Time", f"{subject.hour:02d}:{subject.minute:02d}"])
315
+
316
+ city = getattr(subject, "city", None)
317
+ if city:
318
+ birth_data.append(["City", str(city)])
319
+
320
+ nation = getattr(subject, "nation", None)
321
+ if nation:
322
+ birth_data.append(["Nation", str(nation)])
323
+
324
+ lat = getattr(subject, "lat", None)
325
+ if lat is not None:
326
+ birth_data.append(["Latitude", f"{lat:.4f}°"])
327
+
328
+ lng = getattr(subject, "lng", None)
329
+ if lng is not None:
330
+ birth_data.append(["Longitude", f"{lng:.4f}°"])
331
+
332
+ tz_str = getattr(subject, "tz_str", None)
333
+ if tz_str:
334
+ birth_data.append(["Timezone", str(tz_str)])
335
+
336
+ day_of_week = getattr(subject, "day_of_week", None)
337
+ if day_of_week:
338
+ birth_data.append(["Day of Week", str(day_of_week)])
339
+
340
+ iso_local = getattr(subject, "iso_formatted_local_datetime", None)
341
+ if iso_local:
342
+ birth_data.append(["ISO Local Datetime", iso_local])
343
+
344
+ settings_data = [["Setting", "Value"]]
345
+ settings_data.append(["Zodiac Type", str(subject.zodiac_type)])
346
+ if getattr(subject, "sidereal_mode", None):
347
+ settings_data.append(["Sidereal Mode", str(subject.sidereal_mode)])
348
+ settings_data.append(["Houses System", str(subject.houses_system_name)])
349
+ settings_data.append(["Perspective Type", str(subject.perspective_type)])
350
+
351
+ julian_day = getattr(subject, "julian_day", None)
352
+ if julian_day is not None:
353
+ settings_data.append(["Julian Day", f"{julian_day:.6f}"])
354
+
355
+ active_points = getattr(subject, "active_points", None)
356
+ if active_points:
357
+ settings_data.append(["Active Points Count", str(len(active_points))])
358
+
359
+ birth_table = AsciiTable(birth_data, title=f"{label} — Birth Data").table
360
+ settings_table = AsciiTable(settings_data, title=f"{label} — Settings").table
361
+ return f"{birth_table}\n\n{settings_table}"
362
+
363
+ def _celestial_points_report(self, subject: SubjectLike, title: str) -> str:
364
+ points = self._collect_celestial_points(subject)
365
+ if not points:
366
+ return "No celestial points data available."
367
+
368
+ main_planets = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
369
+ nodes = ["Mean_North_Lunar_Node", "True_North_Lunar_Node"]
370
+ angles = ["Ascendant", "Medium_Coeli", "Descendant", "Imum_Coeli"]
371
+
372
+ sorted_points = []
373
+ for name in angles + main_planets + nodes:
374
+ sorted_points.extend([p for p in points if p.name == name])
375
+
376
+ used_names = set(angles + main_planets + nodes)
377
+ sorted_points.extend([p for p in points if p.name not in used_names])
378
+
379
+ celestial_data: List[List[str]] = [["Point", "Sign", "Position", "Speed", "Decl.", "Ret.", "House"]]
380
+ for point in sorted_points:
381
+ speed_str = f"{point.speed:+.4f}°/d" if point.speed is not None else "N/A"
382
+ decl_str = f"{point.declination:+.2f}°" if point.declination is not None else "N/A"
383
+ ret_str = "R" if point.retrograde else "-"
384
+ house_str = point.house.replace("_", " ") if point.house else "-"
385
+ celestial_data.append([
386
+ point.name.replace("_", " "),
387
+ f"{point.sign} {point.emoji}",
388
+ f"{point.position:.2f}°",
389
+ speed_str,
390
+ decl_str,
391
+ ret_str,
392
+ house_str,
393
+ ])
394
+
395
+ return AsciiTable(celestial_data, title=title).table
396
+
397
+ def _collect_celestial_points(self, subject: SubjectLike) -> List[KerykeionPointModel]:
398
+ if isinstance(subject, AstrologicalSubjectModel):
399
+ return get_available_astrological_points_list(subject)
400
+
401
+ points: List[KerykeionPointModel] = []
402
+ active_points: Optional[Sequence[str]] = getattr(subject, "active_points", None)
403
+ if not active_points:
404
+ return points
84
405
 
85
- print(self.get_full_report())
406
+ for point_name in active_points:
407
+ attr_name = str(point_name).lower()
408
+ attr = getattr(subject, attr_name, None)
409
+ if attr is not None:
410
+ points.append(attr)
411
+
412
+ return points
413
+
414
+ def _houses_report(self, subject: SubjectLike, title: str) -> str:
415
+ try:
416
+ houses = get_houses_list(subject) # type: ignore[arg-type]
417
+ except Exception:
418
+ return "No houses data available."
419
+
420
+ if not houses:
421
+ return "No houses data available."
422
+
423
+ houses_data: List[List[str]] = [["House", "Sign", "Position", "Absolute Position"]]
424
+ for house in houses:
425
+ houses_data.append([
426
+ house.name.replace("_", " "),
427
+ f"{house.sign} {house.emoji}",
428
+ f"{house.position:.2f}°",
429
+ f"{house.abs_pos:.2f}°",
430
+ ])
431
+
432
+ system_name = getattr(subject, "houses_system_name", "")
433
+ table_title = f"{title} ({system_name})" if system_name else title
434
+ return AsciiTable(houses_data, title=table_title).table
435
+
436
+ def _lunar_phase_report(self, subject: SubjectLike) -> str:
437
+ lunar = getattr(subject, "lunar_phase", None)
438
+ if not lunar:
439
+ return ""
440
+
441
+ lunar_data = [
442
+ ["Lunar Phase Information", "Value"],
443
+ ["Phase Name", f"{lunar.moon_phase_name} {lunar.moon_emoji}"],
444
+ ["Sun-Moon Angle", f"{lunar.degrees_between_s_m:.2f}°"],
445
+ ["Lunation Day", str(lunar.moon_phase)],
446
+ ]
447
+ return AsciiTable(lunar_data, title="Lunar Phase").table
448
+
449
+ def _elements_report(self) -> str:
450
+ if not self._chart_data or not getattr(self._chart_data, "element_distribution", None):
451
+ return ""
452
+
453
+ elem = self._chart_data.element_distribution
454
+ total = elem.fire + elem.earth + elem.air + elem.water
455
+ if total == 0:
456
+ return ""
457
+
458
+ element_data = [
459
+ ["Element", "Count", "Percentage"],
460
+ ["Fire 🔥", elem.fire, f"{(elem.fire / total * 100):.1f}%"],
461
+ ["Earth 🌍", elem.earth, f"{(elem.earth / total * 100):.1f}%"],
462
+ ["Air 💨", elem.air, f"{(elem.air / total * 100):.1f}%"],
463
+ ["Water 💧", elem.water, f"{(elem.water / total * 100):.1f}%"],
464
+ ["Total", total, "100%"],
465
+ ]
466
+ return AsciiTable(element_data, title="Element Distribution").table
467
+
468
+ def _qualities_report(self) -> str:
469
+ if not self._chart_data or not getattr(self._chart_data, "quality_distribution", None):
470
+ return ""
471
+
472
+ qual = self._chart_data.quality_distribution
473
+ total = qual.cardinal + qual.fixed + qual.mutable
474
+ if total == 0:
475
+ return ""
476
+
477
+ quality_data = [
478
+ ["Quality", "Count", "Percentage"],
479
+ ["Cardinal", qual.cardinal, f"{(qual.cardinal / total * 100):.1f}%"],
480
+ ["Fixed", qual.fixed, f"{(qual.fixed / total * 100):.1f}%"],
481
+ ["Mutable", qual.mutable, f"{(qual.mutable / total * 100):.1f}%"],
482
+ ["Total", total, "100%"],
483
+ ]
484
+ return AsciiTable(quality_data, title="Quality Distribution").table
485
+
486
+ def _active_configuration_report(self) -> str:
487
+ if not self._active_points and not self._active_aspects:
488
+ return ""
489
+
490
+ sections: List[str] = []
491
+
492
+ if self._active_points:
493
+ points_table = [["#", "Active Point"]]
494
+ for idx, point in enumerate(self._active_points, start=1):
495
+ points_table.append([str(idx), str(point)])
496
+ sections.append(AsciiTable(points_table, title="Active Celestial Points").table)
497
+
498
+ if self._active_aspects:
499
+ aspects_table = [["Aspect", "Orb (°)"]]
500
+ for aspect in self._active_aspects:
501
+ name = str(aspect.get("name", ""))
502
+ orb = aspect.get("orb")
503
+ orbit_str = f"{orb}" if orb is not None else "-"
504
+ aspects_table.append([name, orbit_str])
505
+ sections.append(AsciiTable(aspects_table, title="Active Aspects Configuration").table)
506
+
507
+ return "\n\n".join(sections)
508
+
509
+ def _aspects_report(self, *, max_aspects: Optional[int]) -> str:
510
+ if not self._chart_data or not getattr(self._chart_data, "aspects", None):
511
+ return ""
512
+
513
+ aspects_list = list(self._chart_data.aspects)
514
+
515
+ if not aspects_list:
516
+ return "No aspects data available."
517
+
518
+ total_aspects = len(aspects_list)
519
+ if max_aspects is not None:
520
+ aspects_list = aspects_list[:max_aspects]
521
+
522
+ is_dual = isinstance(self._chart_data, DualChartDataModel)
523
+ if is_dual:
524
+ table_header: List[str] = ["Point 1", "Owner 1", "Aspect", "Point 2", "Owner 2", "Orb", "Movement"]
525
+ else:
526
+ table_header = ["Point 1", "Aspect", "Point 2", "Orb", "Movement"]
527
+
528
+ aspects_table: List[List[str]] = [table_header]
529
+ for aspect in aspects_list:
530
+ aspect_name = str(aspect.aspect)
531
+ symbol = ASPECT_SYMBOLS.get(aspect_name.lower(), aspect_name)
532
+ movement_symbol = MOVEMENT_SYMBOLS.get(aspect.aspect_movement, "")
533
+ movement = f"{aspect.aspect_movement} {movement_symbol}".strip()
534
+
535
+ if is_dual:
536
+ aspects_table.append([
537
+ aspect.p1_name.replace("_", " "),
538
+ aspect.p1_owner,
539
+ f"{aspect.aspect} {symbol}",
540
+ aspect.p2_name.replace("_", " "),
541
+ aspect.p2_owner,
542
+ f"{aspect.orbit:.2f}°",
543
+ movement,
544
+ ])
545
+ else:
546
+ aspects_table.append([
547
+ aspect.p1_name.replace("_", " "),
548
+ f"{aspect.aspect} {symbol}",
549
+ aspect.p2_name.replace("_", " "),
550
+ f"{aspect.orbit:.2f}°",
551
+ movement,
552
+ ])
553
+
554
+ suffix = f" (showing {len(aspects_list)} of {total_aspects})" if max_aspects is not None else ""
555
+ title = f"Aspects{suffix}"
556
+ return AsciiTable(aspects_table, title=title).table
557
+
558
+ def _house_comparison_report(self) -> str:
559
+ if not isinstance(self._chart_data, DualChartDataModel) or not self._chart_data.house_comparison:
560
+ return ""
561
+
562
+ comparison = self._chart_data.house_comparison
563
+ sections = []
564
+
565
+ sections.append(
566
+ self._render_point_in_house_table(
567
+ comparison.first_points_in_second_houses,
568
+ f"{comparison.first_subject_name} points in {comparison.second_subject_name} houses",
569
+ )
570
+ )
571
+ sections.append(
572
+ self._render_point_in_house_table(
573
+ comparison.second_points_in_first_houses,
574
+ f"{comparison.second_subject_name} points in {comparison.first_subject_name} houses",
575
+ )
576
+ )
577
+
578
+ return "\n\n".join(section for section in sections if section)
579
+
580
+ def _render_point_in_house_table(self, points: Sequence[PointInHouseModel], title: str) -> str:
581
+ if not points:
582
+ return ""
583
+
584
+ table_data: List[List[str]] = [["Point", "Owner House", "Projected House", "Sign", "Degree"]]
585
+ for point in points:
586
+ owner_house = "-"
587
+ if point.point_owner_house_number is not None or point.point_owner_house_name:
588
+ owner_house = f"{point.point_owner_house_number or '-'} ({point.point_owner_house_name or '-'})"
589
+
590
+ projected_house = f"{point.projected_house_number} ({point.projected_house_name})"
591
+ table_data.append([
592
+ f"{point.point_owner_name} – {point.point_name.replace('_', ' ')}",
593
+ owner_house,
594
+ projected_house,
595
+ point.point_sign,
596
+ f"{point.point_degree:.2f}°",
597
+ ])
598
+
599
+ return AsciiTable(table_data, title=title).table
600
+
601
+ def _relationship_score_report(self) -> str:
602
+ if not isinstance(self._chart_data, DualChartDataModel):
603
+ return ""
604
+
605
+ score: Optional[RelationshipScoreModel] = getattr(self._chart_data, "relationship_score", None)
606
+ if not score:
607
+ return ""
608
+
609
+ summary_table = [
610
+ ["Metric", "Value"],
611
+ ["Score", str(score.score_value)],
612
+ ["Description", str(score.score_description)],
613
+ ["Destiny Signature", "Yes" if score.is_destiny_sign else "No"],
614
+ ]
615
+
616
+ sections = [AsciiTable(summary_table, title="Relationship Score Summary").table]
617
+
618
+ if score.aspects:
619
+ aspects_table: List[List[str]] = [["Point 1", "Aspect", "Point 2", "Orb"]]
620
+ for aspect in score.aspects:
621
+ aspects_table.append([
622
+ aspect.p1_name.replace("_", " "),
623
+ aspect.aspect,
624
+ aspect.p2_name.replace("_", " "),
625
+ f"{aspect.orbit:.2f}°",
626
+ ])
627
+ sections.append(AsciiTable(aspects_table, title="Score Supporting Aspects").table)
628
+
629
+ return "\n\n".join(sections)
630
+
631
+ # ------------------------------------------------------------------ #
632
+ # Utility helpers
633
+ # ------------------------------------------------------------------ #
634
+
635
+ @staticmethod
636
+ def _extract_year(iso_datetime: Optional[str]) -> Optional[str]:
637
+ if not iso_datetime:
638
+ return None
639
+ try:
640
+ return datetime.fromisoformat(iso_datetime).strftime("%Y")
641
+ except ValueError:
642
+ return None
643
+
644
+ @staticmethod
645
+ def _format_date(iso_datetime: Optional[str]) -> str:
646
+ if not iso_datetime:
647
+ return ""
648
+ try:
649
+ return datetime.fromisoformat(iso_datetime).strftime("%d/%m/%Y")
650
+ except ValueError:
651
+ return iso_datetime
86
652
 
87
653
 
88
654
  if __name__ == "__main__":
89
- from kerykeion.utilities import setup_logging
90
- setup_logging(level="debug")
655
+ from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
656
+ from kerykeion.chart_data_factory import ChartDataFactory
657
+ from kerykeion.composite_subject_factory import CompositeSubjectFactory
658
+ from kerykeion.planetary_return_factory import PlanetaryReturnFactory
659
+
660
+ # Shared offline location configuration (Rome, Italy)
661
+ john_city = "Liverpool"
662
+ john_nation = "GB"
663
+ john_lat = 53.4084
664
+ john_lng = -2.9916
665
+ john_tz = "Europe/London"
666
+ offline_online = False
667
+
668
+ # Base natal subject (AstrologicalSubjectModel)
669
+ natal_subject = AstrologicalSubjectFactory.from_birth_data(
670
+ name="Sample Natal Subject",
671
+ year=1990,
672
+ month=7,
673
+ day=21,
674
+ hour=14,
675
+ minute=45,
676
+ city=john_city,
677
+ nation=john_nation,
678
+ lat=john_lat,
679
+ lng=john_lng,
680
+ tz_str=john_tz,
681
+ online=offline_online,
682
+ )
683
+
684
+ # Partner subject for synastry/composite examples
685
+ partner_subject = AstrologicalSubjectFactory.from_birth_data(
686
+ name="Yoko Ono",
687
+ year=1933,
688
+ month=2,
689
+ day=18,
690
+ hour=20,
691
+ minute=30,
692
+ city="Tokyo",
693
+ nation="JP",
694
+ lat=35.6762,
695
+ lng=139.6503,
696
+ tz_str="Asia/Tokyo",
697
+ online=offline_online,
698
+ )
699
+
700
+ # Transit subject (John's 1980 New York snapshot)
701
+ transit_subject = AstrologicalSubjectFactory.from_birth_data(
702
+ name="1980 Transit",
703
+ year=1980,
704
+ month=12,
705
+ day=8,
706
+ hour=22,
707
+ minute=50,
708
+ city="New York",
709
+ nation="US",
710
+ lat=40.7128,
711
+ lng=-74.0060,
712
+ tz_str="America/New_York",
713
+ online=offline_online,
714
+ )
715
+
716
+ # Planetary return subject (Solar Return)
717
+ return_factory = PlanetaryReturnFactory(
718
+ natal_subject,
719
+ city=natal_subject.city,
720
+ nation=natal_subject.nation,
721
+ lat=natal_subject.lat,
722
+ lng=natal_subject.lng,
723
+ tz_str=natal_subject.tz_str,
724
+ online=False,
725
+ )
726
+ solar_return_subject = return_factory.next_return_from_iso_formatted_time(
727
+ natal_subject.iso_formatted_local_datetime,
728
+ "Solar",
729
+ )
730
+ # Derive a composite subject representing the pair's midpoint configuration
731
+ composite_subject = CompositeSubjectFactory(
732
+ natal_subject,
733
+ partner_subject,
734
+ chart_name="John & Yoko Composite Chart",
735
+ ).get_midpoint_composite_subject_model()
736
+
737
+ # Build chart data models mirroring ChartDrawer inputs
738
+ natal_chart_data = ChartDataFactory.create_natal_chart_data(natal_subject)
739
+ composite_chart_data = ChartDataFactory.create_composite_chart_data(composite_subject)
740
+ single_return_chart_data = ChartDataFactory.create_single_wheel_return_chart_data(solar_return_subject)
741
+ transit_chart_data = ChartDataFactory.create_transit_chart_data(natal_subject, transit_subject)
742
+ synastry_chart_data = ChartDataFactory.create_synastry_chart_data(natal_subject, partner_subject)
743
+ dual_return_chart_data = ChartDataFactory.create_return_chart_data(natal_subject, solar_return_subject)
744
+
745
+ # Demonstrate each report/model type
746
+ print("\n" + "=" * 54)
747
+ print("AstrologicalSubjectModel Report — John Lennon")
748
+ print("=" * 54)
749
+ ReportGenerator(natal_subject, include_aspects=False).print_report(include_aspects=False)
750
+
751
+ print("\n" + "=" * 57)
752
+ print("SingleChartDataModel Report (Natal) — John Lennon")
753
+ print("=" * 57)
754
+ ReportGenerator(natal_chart_data).print_report()
755
+
756
+ print("\n" + "=" * 65)
757
+ print("SingleChartDataModel Report (Composite) — John & Yoko")
758
+ print("=" * 65)
759
+ ReportGenerator(composite_chart_data).print_report()
760
+
761
+ print("\n" + "=" * 63)
762
+ print("SingleChartDataModel Report (Single Return) — John Lennon")
763
+ print("=" * 63)
764
+ ReportGenerator(single_return_chart_data).print_report()
765
+
766
+ print("\n" + "=" * 58)
767
+ print("DualChartDataModel Report (Transit) — John Lennon")
768
+ print("=" * 58)
769
+ ReportGenerator(transit_chart_data).print_report()
770
+
771
+ print("\n" + "=" * 60)
772
+ print("DualChartDataModel Report (Synastry) — John & Yoko")
773
+ print("=" * 60)
774
+ ReportGenerator(synastry_chart_data).print_report()
91
775
 
92
- john = AstrologicalSubjectFactory.from_birth_data("John", 1975, 10, 10, 21, 15, "Roma", "IT")
93
- report = Report(john)
94
- report.print_report()
776
+ print("\n" + "=" * 58)
777
+ print("DualChartDataModel Report (Dual Return) — John Lennon")
778
+ print("=" * 58)
779
+ ReportGenerator(dual_return_chart_data).print_report()