kerykeion 5.0.0a12__py3-none-any.whl → 5.0.0b2__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 (51) hide show
  1. kerykeion/__init__.py +30 -6
  2. kerykeion/aspects/aspects_factory.py +40 -24
  3. kerykeion/aspects/aspects_utils.py +75 -6
  4. kerykeion/astrological_subject_factory.py +377 -226
  5. kerykeion/backword.py +680 -0
  6. kerykeion/chart_data_factory.py +484 -0
  7. kerykeion/charts/{kerykeion_chart_svg.py → chart_drawer.py} +688 -440
  8. kerykeion/charts/charts_utils.py +157 -94
  9. kerykeion/charts/draw_planets.py +38 -28
  10. kerykeion/charts/templates/aspect_grid_only.xml +188 -17
  11. kerykeion/charts/templates/chart.xml +153 -47
  12. kerykeion/charts/templates/wheel_only.xml +195 -24
  13. kerykeion/charts/themes/classic.css +11 -0
  14. kerykeion/charts/themes/dark-high-contrast.css +11 -0
  15. kerykeion/charts/themes/dark.css +11 -0
  16. kerykeion/charts/themes/light.css +11 -0
  17. kerykeion/charts/themes/strawberry.css +10 -0
  18. kerykeion/composite_subject_factory.py +4 -4
  19. kerykeion/ephemeris_data_factory.py +12 -9
  20. kerykeion/house_comparison/__init__.py +0 -3
  21. kerykeion/house_comparison/house_comparison_factory.py +3 -3
  22. kerykeion/house_comparison/house_comparison_utils.py +3 -4
  23. kerykeion/planetary_return_factory.py +8 -4
  24. kerykeion/relationship_score_factory.py +3 -3
  25. kerykeion/report.py +748 -67
  26. kerykeion/{kr_types → schemas}/__init__.py +44 -4
  27. kerykeion/schemas/chart_template_model.py +367 -0
  28. kerykeion/{kr_types → schemas}/kr_literals.py +7 -3
  29. kerykeion/{kr_types → schemas}/kr_models.py +220 -11
  30. kerykeion/{kr_types → schemas}/settings_models.py +7 -7
  31. kerykeion/settings/config_constants.py +75 -8
  32. kerykeion/settings/kerykeion_settings.py +1 -1
  33. kerykeion/settings/kr.config.json +132 -42
  34. kerykeion/settings/legacy/legacy_celestial_points_settings.py +8 -8
  35. kerykeion/sweph/ast136/s136108s.se1 +0 -0
  36. kerykeion/sweph/ast136/s136199s.se1 +0 -0
  37. kerykeion/sweph/ast136/s136472s.se1 +0 -0
  38. kerykeion/sweph/ast28/se28978s.se1 +0 -0
  39. kerykeion/sweph/ast50/se50000s.se1 +0 -0
  40. kerykeion/sweph/ast90/se90377s.se1 +0 -0
  41. kerykeion/sweph/ast90/se90482s.se1 +0 -0
  42. kerykeion/transits_time_range_factory.py +7 -7
  43. kerykeion/utilities.py +61 -38
  44. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/METADATA +507 -120
  45. kerykeion-5.0.0b2.dist-info/RECORD +58 -0
  46. kerykeion/house_comparison/house_comparison_models.py +0 -76
  47. kerykeion/kr_types/chart_types.py +0 -106
  48. kerykeion-5.0.0a12.dist-info/RECORD +0 -50
  49. /kerykeion/{kr_types → schemas}/kerykeion_exception.py +0 -0
  50. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/WHEEL +0 -0
  51. {kerykeion-5.0.0a12.dist-info → kerykeion-5.0.0b2.dist-info}/licenses/LICENSE +0 -0
kerykeion/report.py CHANGED
@@ -1,100 +1,781 @@
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 kerykeion.kr_types.kr_models import AstrologicalSubjectModel
5
7
 
6
- 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:
7
46
  """
8
- 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.
9
53
  """
10
54
 
11
- report_title: str
12
- data_table: str
13
- planets_table: str
14
- 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
65
+
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] = []
73
+
74
+ self._resolve_model()
15
75
 
16
- def __init__(self, instance: AstrologicalSubjectModel):
76
+ # ------------------------------------------------------------------ #
77
+ # Public API
78
+ # ------------------------------------------------------------------ #
79
+
80
+ def generate_report(
81
+ self,
82
+ *,
83
+ include_aspects: Optional[bool] = None,
84
+ max_aspects: Optional[int] = None,
85
+ ) -> str:
17
86
  """
18
- Initialize a new Report instance.
87
+ Build the report content without printing it.
19
88
 
20
89
  Args:
21
- instance: The astrological subject model to create a report for.
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.
22
92
  """
23
- self.instance = instance
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
24
95
 
25
- self.get_report_title()
26
- self.get_data_table()
27
- self.get_planets_table()
28
- self.get_houses_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)
29
102
 
30
- def get_report_title(self) -> None:
31
- """Generate the report title based on the subject's name."""
32
- self.report_title = f"\n+- Kerykeion report for {self.instance.name} -+"
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)
33
106
 
34
- def get_data_table(self) -> None:
107
+ def print_report(
108
+ self,
109
+ *,
110
+ include_aspects: Optional[bool] = None,
111
+ max_aspects: Optional[int] = None,
112
+ ) -> None:
35
113
  """
36
- Creates the data table of the report.
114
+ Print the generated report to stdout.
37
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}.")
38
150
 
39
- main_data = [["Date", "Time", "Location", "Longitude", "Latitude"]] + [
40
- [
41
- f"{self.instance.day}/{self.instance.month}/{self.instance.year}",
42
- f"{self.instance.hour}:{self.instance.minute}",
43
- f"{self.instance.city}, {self.instance.nation}",
44
- self.instance.lng,
45
- self.instance.lat,
46
- ]
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),
47
161
  ]
48
- self.data_table = AsciiTable(main_data).table
162
+ return sections
49
163
 
50
- def get_planets_table(self) -> None:
51
- """
52
- Creates the planets table.
53
- """
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
+ ]
169
+
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
54
197
 
55
- planets_data = [["AstrologicalPoint", "Sign", "Pos.", "Ret.", "House"]] + [
56
- [
57
- planet.name,
58
- planet.sign,
59
- round(float(planet.position), 2),
60
- ("R" if planet.retrograde else "-"),
61
- planet.house,
62
- ]
63
- for planet in get_available_astrological_points_list(self.instance)
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()
201
+
202
+ sections: List[str] = [
203
+ self._subject_data_report(self._primary_subject, primary_label),
64
204
  ]
65
205
 
66
- self.planets_table = AsciiTable(planets_data).table
206
+ if self._secondary_subject is not None:
207
+ sections.append(self._subject_data_report(self._secondary_subject, secondary_label))
67
208
 
68
- def get_houses_table(self) -> None:
69
- """
70
- Creates the houses table.
71
- """
209
+ sections.extend([
210
+ self._celestial_points_report(self._primary_subject, f"{primary_label} Celestial Points"),
211
+ ])
212
+
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
+ )
217
+
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)
72
400
 
73
- houses_data = [["House", "Sign", "Position"]] + [
74
- [house.name, house.sign, round(float(house.position), 2)] for house in get_houses_list(self.instance)
401
+ points: List[KerykeionPointModel] = []
402
+ active_points: Optional[Sequence[str]] = getattr(subject, "active_points", None)
403
+ if not active_points:
404
+ return points
405
+
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)],
75
446
  ]
447
+ return AsciiTable(lunar_data, title="Lunar Phase").table
76
448
 
77
- self.houses_table = AsciiTable(houses_data).table
449
+ def _elements_report(self) -> str:
450
+ if not self._chart_data or not getattr(self._chart_data, "element_distribution", None):
451
+ return ""
78
452
 
79
- def get_full_report(self) -> str:
80
- """
81
- Returns the full report.
82
- """
453
+ elem = self._chart_data.element_distribution
454
+ total = elem.fire + elem.earth + elem.air + elem.water
455
+ if total == 0:
456
+ return ""
83
457
 
84
- return f"{self.report_title}\n{self.data_table}\n{self.planets_table}\n{self.houses_table}"
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
85
467
 
86
- def print_report(self) -> None:
87
- """
88
- Print the report.
89
- """
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_model = self._chart_data.aspects
514
+ relevant_aspects = list(getattr(aspects_model, "relevant_aspects", []))
515
+
516
+ if not relevant_aspects:
517
+ return "No aspects data available."
518
+
519
+ total_aspects = len(relevant_aspects)
520
+ if max_aspects is not None:
521
+ relevant_aspects = relevant_aspects[:max_aspects]
522
+
523
+ is_dual = isinstance(self._chart_data, DualChartDataModel)
524
+ if is_dual:
525
+ table_header: List[str] = ["Point 1", "Owner 1", "Aspect", "Point 2", "Owner 2", "Orb", "Movement"]
526
+ else:
527
+ table_header = ["Point 1", "Aspect", "Point 2", "Orb", "Movement"]
528
+
529
+ aspects_table: List[List[str]] = [table_header]
530
+ for aspect in relevant_aspects:
531
+ aspect_name = str(aspect.aspect)
532
+ symbol = ASPECT_SYMBOLS.get(aspect_name.lower(), aspect_name)
533
+ movement_symbol = MOVEMENT_SYMBOLS.get(aspect.aspect_movement, "")
534
+ movement = f"{aspect.aspect_movement} {movement_symbol}".strip()
535
+
536
+ if is_dual:
537
+ aspects_table.append([
538
+ aspect.p1_name.replace("_", " "),
539
+ aspect.p1_owner,
540
+ f"{aspect.aspect} {symbol}",
541
+ aspect.p2_name.replace("_", " "),
542
+ aspect.p2_owner,
543
+ f"{aspect.orbit:.2f}°",
544
+ movement,
545
+ ])
546
+ else:
547
+ aspects_table.append([
548
+ aspect.p1_name.replace("_", " "),
549
+ f"{aspect.aspect} {symbol}",
550
+ aspect.p2_name.replace("_", " "),
551
+ f"{aspect.orbit:.2f}°",
552
+ movement,
553
+ ])
554
+
555
+ suffix = f" (showing {len(relevant_aspects)} of {total_aspects})" if max_aspects is not None else ""
556
+ title = f"Aspects{suffix}"
557
+ return AsciiTable(aspects_table, title=title).table
558
+
559
+ def _house_comparison_report(self) -> str:
560
+ if not isinstance(self._chart_data, DualChartDataModel) or not self._chart_data.house_comparison:
561
+ return ""
90
562
 
91
- print(self.get_full_report())
563
+ comparison = self._chart_data.house_comparison
564
+ sections = []
565
+
566
+ sections.append(
567
+ self._render_point_in_house_table(
568
+ comparison.first_points_in_second_houses,
569
+ f"{comparison.first_subject_name} points in {comparison.second_subject_name} houses",
570
+ )
571
+ )
572
+ sections.append(
573
+ self._render_point_in_house_table(
574
+ comparison.second_points_in_first_houses,
575
+ f"{comparison.second_subject_name} points in {comparison.first_subject_name} houses",
576
+ )
577
+ )
578
+
579
+ return "\n\n".join(section for section in sections if section)
580
+
581
+ def _render_point_in_house_table(self, points: Sequence[PointInHouseModel], title: str) -> str:
582
+ if not points:
583
+ return ""
584
+
585
+ table_data: List[List[str]] = [["Point", "Owner House", "Projected House", "Sign", "Degree"]]
586
+ for point in points:
587
+ owner_house = "-"
588
+ if point.point_owner_house_number is not None or point.point_owner_house_name:
589
+ owner_house = f"{point.point_owner_house_number or '-'} ({point.point_owner_house_name or '-'})"
590
+
591
+ projected_house = f"{point.projected_house_number} ({point.projected_house_name})"
592
+ table_data.append([
593
+ f"{point.point_owner_name} – {point.point_name.replace('_', ' ')}",
594
+ owner_house,
595
+ projected_house,
596
+ point.point_sign,
597
+ f"{point.point_degree:.2f}°",
598
+ ])
599
+
600
+ return AsciiTable(table_data, title=title).table
601
+
602
+ def _relationship_score_report(self) -> str:
603
+ if not isinstance(self._chart_data, DualChartDataModel):
604
+ return ""
605
+
606
+ score: Optional[RelationshipScoreModel] = getattr(self._chart_data, "relationship_score", None)
607
+ if not score:
608
+ return ""
609
+
610
+ summary_table = [
611
+ ["Metric", "Value"],
612
+ ["Score", str(score.score_value)],
613
+ ["Description", str(score.score_description)],
614
+ ["Destiny Signature", "Yes" if score.is_destiny_sign else "No"],
615
+ ]
616
+
617
+ sections = [AsciiTable(summary_table, title="Relationship Score Summary").table]
618
+
619
+ if score.aspects:
620
+ aspects_table: List[List[str]] = [["Point 1", "Aspect", "Point 2", "Orb"]]
621
+ for aspect in score.aspects:
622
+ aspects_table.append([
623
+ aspect.p1_name.replace("_", " "),
624
+ aspect.aspect,
625
+ aspect.p2_name.replace("_", " "),
626
+ f"{aspect.orbit:.2f}°",
627
+ ])
628
+ sections.append(AsciiTable(aspects_table, title="Score Supporting Aspects").table)
629
+
630
+ return "\n\n".join(sections)
631
+
632
+ # ------------------------------------------------------------------ #
633
+ # Utility helpers
634
+ # ------------------------------------------------------------------ #
635
+
636
+ @staticmethod
637
+ def _extract_year(iso_datetime: Optional[str]) -> Optional[str]:
638
+ if not iso_datetime:
639
+ return None
640
+ try:
641
+ return datetime.fromisoformat(iso_datetime).strftime("%Y")
642
+ except ValueError:
643
+ return None
644
+
645
+ @staticmethod
646
+ def _format_date(iso_datetime: Optional[str]) -> str:
647
+ if not iso_datetime:
648
+ return ""
649
+ try:
650
+ return datetime.fromisoformat(iso_datetime).strftime("%d/%m/%Y")
651
+ except ValueError:
652
+ return iso_datetime
92
653
 
93
654
 
94
655
  if __name__ == "__main__":
95
- from kerykeion.utilities import setup_logging
96
- setup_logging(level="debug")
656
+ from kerykeion.astrological_subject_factory import AstrologicalSubjectFactory
657
+ from kerykeion.chart_data_factory import ChartDataFactory
658
+ from kerykeion.composite_subject_factory import CompositeSubjectFactory
659
+ from kerykeion.planetary_return_factory import PlanetaryReturnFactory
660
+
661
+ # Shared offline location configuration (Rome, Italy)
662
+ john_city = "Liverpool"
663
+ john_nation = "GB"
664
+ john_lat = 53.4084
665
+ john_lng = -2.9916
666
+ john_tz = "Europe/London"
667
+ offline_online = False
668
+
669
+ # Base natal subject (AstrologicalSubjectModel)
670
+ natal_subject = AstrologicalSubjectFactory.from_birth_data(
671
+ name="Sample Natal Subject",
672
+ year=1990,
673
+ month=7,
674
+ day=21,
675
+ hour=14,
676
+ minute=45,
677
+ city=john_city,
678
+ nation=john_nation,
679
+ lat=john_lat,
680
+ lng=john_lng,
681
+ tz_str=john_tz,
682
+ online=offline_online,
683
+ )
684
+
685
+ # Partner subject for synastry/composite examples
686
+ partner_subject = AstrologicalSubjectFactory.from_birth_data(
687
+ name="Yoko Ono",
688
+ year=1933,
689
+ month=2,
690
+ day=18,
691
+ hour=20,
692
+ minute=30,
693
+ city="Tokyo",
694
+ nation="JP",
695
+ lat=35.6762,
696
+ lng=139.6503,
697
+ tz_str="Asia/Tokyo",
698
+ online=offline_online,
699
+ )
700
+
701
+ # Transit subject (John's 1980 New York snapshot)
702
+ transit_subject = AstrologicalSubjectFactory.from_birth_data(
703
+ name="1980 Transit",
704
+ year=1980,
705
+ month=12,
706
+ day=8,
707
+ hour=22,
708
+ minute=50,
709
+ city="New York",
710
+ nation="US",
711
+ lat=40.7128,
712
+ lng=-74.0060,
713
+ tz_str="America/New_York",
714
+ online=offline_online,
715
+ )
716
+
717
+ # Planetary return subject (Solar Return)
718
+ return_factory = PlanetaryReturnFactory(
719
+ natal_subject,
720
+ city=natal_subject.city,
721
+ nation=natal_subject.nation,
722
+ lat=natal_subject.lat,
723
+ lng=natal_subject.lng,
724
+ tz_str=natal_subject.tz_str,
725
+ online=False,
726
+ )
727
+ solar_return_subject = return_factory.next_return_from_iso_formatted_time(
728
+ natal_subject.iso_formatted_local_datetime,
729
+ "Solar",
730
+ )
731
+
732
+ # Composite chart subject
733
+ composite_subject = CompositeSubjectFactory(
734
+ natal_subject,
735
+ partner_subject,
736
+ chart_name="John & Yoko Composite Chart",
737
+ ).get_midpoint_composite_subject_model()
738
+
739
+ # Build chart data models mirroring ChartDrawer inputs
740
+ natal_chart_data = ChartDataFactory.create_natal_chart_data(natal_subject)
741
+ composite_chart_data = ChartDataFactory.create_composite_chart_data(composite_subject)
742
+ single_return_chart_data = ChartDataFactory.create_single_wheel_return_chart_data(solar_return_subject)
743
+ transit_chart_data = ChartDataFactory.create_transit_chart_data(natal_subject, transit_subject)
744
+ synastry_chart_data = ChartDataFactory.create_synastry_chart_data(natal_subject, partner_subject)
745
+ dual_return_chart_data = ChartDataFactory.create_return_chart_data(natal_subject, solar_return_subject)
746
+
747
+ # Demonstrate each report/model type
748
+ print("\n" + "=" * 54)
749
+ print("AstrologicalSubjectModel Report — John Lennon")
750
+ print("=" * 54)
751
+ ReportGenerator(natal_subject, include_aspects=False).print_report(include_aspects=False)
752
+
753
+ print("\n" + "=" * 57)
754
+ print("SingleChartDataModel Report (Natal) — John Lennon")
755
+ print("=" * 57)
756
+ ReportGenerator(natal_chart_data).print_report()
757
+
758
+ print("\n" + "=" * 65)
759
+ print("SingleChartDataModel Report (Composite) — John & Yoko")
760
+ print("=" * 65)
761
+ ReportGenerator(composite_chart_data).print_report()
762
+
763
+ print("\n" + "=" * 63)
764
+ print("SingleChartDataModel Report (Single Return) — John Lennon")
765
+ print("=" * 63)
766
+ ReportGenerator(single_return_chart_data).print_report()
767
+
768
+ print("\n" + "=" * 58)
769
+ print("DualChartDataModel Report (Transit) — John Lennon")
770
+ print("=" * 58)
771
+ ReportGenerator(transit_chart_data).print_report()
772
+
773
+ print("\n" + "=" * 60)
774
+ print("DualChartDataModel Report (Synastry) — John & Yoko")
775
+ print("=" * 60)
776
+ ReportGenerator(synastry_chart_data).print_report()
97
777
 
98
- john = AstrologicalSubjectFactory.from_birth_data("John", 1975, 10, 10, 21, 15, "Roma", "IT")
99
- report = Report(john)
100
- report.print_report()
778
+ print("\n" + "=" * 58)
779
+ print("DualChartDataModel Report (Dual Return) — John Lennon")
780
+ print("=" * 58)
781
+ ReportGenerator(dual_return_chart_data).print_report()