kerykeion 3.1.1__py3-none-any.whl → 5.1.9__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.
- kerykeion/__init__.py +58 -141
- kerykeion/aspects/__init__.py +14 -0
- kerykeion/aspects/aspects_factory.py +568 -0
- kerykeion/aspects/aspects_utils.py +164 -0
- kerykeion/astrological_subject_factory.py +1901 -0
- kerykeion/backword.py +820 -0
- kerykeion/chart_data_factory.py +552 -0
- kerykeion/charts/__init__.py +5 -0
- kerykeion/charts/chart_drawer.py +2794 -0
- kerykeion/charts/charts_utils.py +1840 -0
- kerykeion/charts/draw_planets.py +658 -0
- kerykeion/charts/templates/aspect_grid_only.xml +596 -0
- kerykeion/charts/templates/chart.xml +741 -0
- kerykeion/charts/templates/wheel_only.xml +653 -0
- kerykeion/charts/themes/black-and-white.css +148 -0
- kerykeion/charts/themes/classic.css +113 -0
- kerykeion/charts/themes/dark-high-contrast.css +159 -0
- kerykeion/charts/themes/dark.css +160 -0
- kerykeion/charts/themes/light.css +160 -0
- kerykeion/charts/themes/strawberry.css +158 -0
- kerykeion/composite_subject_factory.py +408 -0
- kerykeion/ephemeris_data_factory.py +443 -0
- kerykeion/fetch_geonames.py +105 -61
- kerykeion/house_comparison/__init__.py +6 -0
- kerykeion/house_comparison/house_comparison_factory.py +103 -0
- kerykeion/house_comparison/house_comparison_utils.py +126 -0
- kerykeion/kr_types/__init__.py +70 -0
- kerykeion/kr_types/chart_template_model.py +20 -0
- kerykeion/kr_types/kerykeion_exception.py +20 -0
- kerykeion/kr_types/kr_literals.py +20 -0
- kerykeion/kr_types/kr_models.py +20 -0
- kerykeion/kr_types/settings_models.py +20 -0
- kerykeion/planetary_return_factory.py +805 -0
- kerykeion/relationship_score_factory.py +301 -0
- kerykeion/report.py +779 -0
- kerykeion/schemas/__init__.py +106 -0
- kerykeion/schemas/chart_template_model.py +367 -0
- kerykeion/schemas/kerykeion_exception.py +20 -0
- kerykeion/schemas/kr_literals.py +181 -0
- kerykeion/schemas/kr_models.py +603 -0
- kerykeion/schemas/settings_models.py +188 -0
- kerykeion/settings/__init__.py +20 -0
- kerykeion/settings/chart_defaults.py +444 -0
- kerykeion/settings/config_constants.py +152 -0
- kerykeion/settings/kerykeion_settings.py +51 -0
- kerykeion/settings/translation_strings.py +1499 -0
- kerykeion/settings/translations.py +74 -0
- kerykeion/sweph/README.md +3 -0
- kerykeion/sweph/ast136/s136108s.se1 +0 -0
- kerykeion/sweph/ast136/s136199s.se1 +0 -0
- kerykeion/sweph/ast136/s136472s.se1 +0 -0
- kerykeion/sweph/ast28/se28978s.se1 +0 -0
- kerykeion/sweph/ast50/se50000s.se1 +0 -0
- kerykeion/sweph/ast90/se90377s.se1 +0 -0
- kerykeion/sweph/ast90/se90482s.se1 +0 -0
- kerykeion/sweph/seas_18.se1 +0 -0
- kerykeion/sweph/sefstars.txt +1602 -0
- kerykeion/transits_time_range_factory.py +302 -0
- kerykeion/utilities.py +762 -130
- kerykeion-5.1.9.dist-info/METADATA +1793 -0
- kerykeion-5.1.9.dist-info/RECORD +63 -0
- {kerykeion-3.1.1.dist-info → kerykeion-5.1.9.dist-info}/WHEEL +1 -2
- kerykeion-5.1.9.dist-info/licenses/LICENSE +661 -0
- kerykeion/aspects.py +0 -331
- kerykeion/charts/charts_svg.py +0 -1607
- kerykeion/charts/templates/basic.xml +0 -285
- kerykeion/charts/templates/extended.xml +0 -294
- kerykeion/kr.config.json +0 -464
- kerykeion/main.py +0 -595
- kerykeion/print_all_data.py +0 -44
- kerykeion/relationship_score.py +0 -219
- kerykeion/types.py +0 -190
- kerykeion-3.1.1.dist-info/METADATA +0 -204
- kerykeion-3.1.1.dist-info/RECORD +0 -17
- kerykeion-3.1.1.dist-info/top_level.txt +0 -1
kerykeion/report.py
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List, Optional, Sequence, Tuple, Union, Literal
|
|
5
|
+
|
|
6
|
+
from simple_ascii_tables import AsciiTable
|
|
7
|
+
|
|
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:
|
|
46
|
+
"""
|
|
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.
|
|
53
|
+
"""
|
|
54
|
+
|
|
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()
|
|
75
|
+
|
|
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:
|
|
86
|
+
"""
|
|
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.
|
|
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
|
|
95
|
+
|
|
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)
|
|
102
|
+
|
|
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:
|
|
113
|
+
"""
|
|
114
|
+
Print the generated report to stdout.
|
|
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}.")
|
|
150
|
+
|
|
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),
|
|
161
|
+
]
|
|
162
|
+
return sections
|
|
163
|
+
|
|
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
|
|
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()
|
|
201
|
+
|
|
202
|
+
sections: List[str] = [
|
|
203
|
+
self._subject_data_report(self._primary_subject, primary_label),
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if self._secondary_subject is not None:
|
|
207
|
+
sections.append(self._subject_data_report(self._secondary_subject, secondary_label))
|
|
208
|
+
|
|
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)
|
|
400
|
+
|
|
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)],
|
|
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
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
if __name__ == "__main__":
|
|
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()
|
|
775
|
+
|
|
776
|
+
print("\n" + "=" * 58)
|
|
777
|
+
print("DualChartDataModel Report (Dual Return) — John Lennon")
|
|
778
|
+
print("=" * 58)
|
|
779
|
+
ReportGenerator(dual_return_chart_data).print_report()
|