"""
Timing technique report sections.
Includes:
- ProfectionSection: Annual and monthly profections
- ZodiacalReleasingSection: Zodiacal releasing periods and timeline
"""
import datetime as dt
from typing import Any
from stellium.core.models import CalculatedChart, ZRPeriod, ZRSnapshot, ZRTimeline
from stellium.core.registry import CELESTIAL_REGISTRY
from ._utils import get_sign_glyph
[docs]
class ProfectionSection:
"""
Profection timing analysis section.
Shows annual profections with Lord of the Year, activated house,
and optionally monthly profections and multi-point analysis.
"""
def __init__(
self,
age: int | None = None,
date: dt.datetime | str | None = None,
include_monthly: bool = True,
include_multi_point: bool = True,
include_timeline: bool = False,
timeline_range: tuple[int, int] | None = None,
points: list[str] | None = None,
house_system: str | None = None,
rulership: str = "traditional",
) -> None:
"""
Initialize profection section.
Args:
age: Age for profection (either age OR date required)
date: Target date for profection (datetime or ISO string)
include_monthly: Show monthly profection (when date provided)
include_multi_point: Show profections for ASC, Sun, Moon, MC
include_timeline: Show timeline of Lords
timeline_range: Range for timeline, e.g., (25, 40). If None, uses age±5
points: Custom points for multi-point (default: ASC, Sun, Moon, MC)
house_system: House system to use (default: prefers Whole Sign)
rulership: "traditional" or "modern"
"""
if age is None and date is None:
raise ValueError("Either age or date must be provided for profections")
self.age = age
self.date = date
self.include_monthly = include_monthly
self.include_multi_point = include_multi_point
self.include_timeline = include_timeline
self.timeline_range = timeline_range
self.points = points
self.house_system = house_system
self.rulership = rulership
@property
def section_name(self) -> str:
if self.age is not None:
return f"Profections (Age {self.age})"
elif self.date:
date_str = (
self.date
if isinstance(self.date, str)
else self.date.strftime("%Y-%m-%d")
)
return f"Profections ({date_str})"
return "Profections"
[docs]
def generate_data(self, chart: CalculatedChart) -> dict:
"""
Generate profection analysis data.
"""
from stellium.engines.profections import ProfectionEngine
# Create engine
try:
engine = ProfectionEngine(chart, self.house_system, self.rulership)
except ValueError as e:
return {
"type": "text",
"content": f"Could not calculate profections: {e}",
}
# Calculate the age
if self.date is not None:
if isinstance(self.date, str):
target_date = dt.datetime.fromisoformat(self.date)
else:
target_date = self.date
age = engine._calculate_age_at_date(target_date)
else:
age = self.age
target_date = None
# Build result sections
sections = []
# Section 1: Annual Profection Summary
annual = engine.annual(age)
summary_data = self._build_annual_summary(annual, engine.house_system)
sections.append(("Annual Profection", summary_data))
# Section 2: Monthly Profection (if date provided)
if self.include_monthly and target_date is not None:
annual_result, monthly_result = engine.for_date(target_date)
monthly_data = self._build_monthly_summary(monthly_result, age)
sections.append(("Monthly Profection", monthly_data))
# Section 3: Multi-Point Lords
if self.include_multi_point:
multi = engine.multi(age, self.points)
multi_data = self._build_multi_point_table(multi)
sections.append(("All Lords", multi_data))
# Section 4: Planets in Profected House
if annual.planets_in_house:
planets_data = self._build_planets_in_house(annual)
sections.append(("Natal Planets in Activated House", planets_data))
# Section 5: Lord's Natal Condition
lord_data = self._build_lord_condition(annual)
sections.append(("Lord of Year - Natal Condition", lord_data))
# Section 6: Timeline (if enabled)
if self.include_timeline:
if self.timeline_range:
start, end = self.timeline_range
else:
# Default: age ± 5
start = max(0, age - 5)
end = age + 5
timeline = engine.timeline(start, end)
timeline_data = self._build_timeline_table(timeline, age)
sections.append((f"Timeline (Ages {start}-{end})", timeline_data))
# Build compound result
return {
"type": "compound",
"sections": sections,
}
def _build_annual_summary(self, result, house_system: str) -> dict:
"""Build the annual profection summary."""
# Get ruler glyph
ruler_glyph = ""
if result.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[result.ruler].glyph
# Get sign glyph
sign_glyph = get_sign_glyph(result.profected_sign)
data = {
"Age": str(result.units),
"Activated House": f"House {result.profected_house}",
"Activated Sign": f"{sign_glyph} {result.profected_sign}",
"Lord of the Year": f"{ruler_glyph} {result.ruler}",
"House System": house_system,
}
if result.ruler_modern:
modern_glyph = ""
if result.ruler_modern in CELESTIAL_REGISTRY:
modern_glyph = CELESTIAL_REGISTRY[result.ruler_modern].glyph
data["Modern Ruler"] = f"{modern_glyph} {result.ruler_modern}"
return {
"type": "key_value",
"data": data,
}
def _build_monthly_summary(self, result, age: int) -> dict:
"""Build monthly profection summary."""
month = result.units - age
ruler_glyph = ""
if result.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[result.ruler].glyph
sign_glyph = get_sign_glyph(result.profected_sign)
data = {
"Month in Year": str(month),
"Activated House": f"House {result.profected_house}",
"Activated Sign": f"{sign_glyph} {result.profected_sign}",
"Lord of the Month": f"{ruler_glyph} {result.ruler}",
}
return {
"type": "key_value",
"data": data,
}
def _build_multi_point_table(self, multi) -> dict:
"""Build multi-point lords table."""
headers = ["Point", "Activated House", "Sign", "Lord"]
rows = []
for point, result in multi.results.items():
point_glyph = ""
if point in CELESTIAL_REGISTRY:
point_glyph = CELESTIAL_REGISTRY[point].glyph
sign_glyph = get_sign_glyph(result.profected_sign)
ruler_glyph = ""
if result.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[result.ruler].glyph
rows.append(
[
f"{point_glyph} {point}" if point_glyph else point,
f"House {result.profected_house}",
f"{sign_glyph} {result.profected_sign}",
f"{ruler_glyph} {result.ruler}",
]
)
return {
"type": "table",
"headers": headers,
"rows": rows,
}
def _build_planets_in_house(self, result) -> dict:
"""Build list of natal planets in the activated house."""
planet_names = []
for planet in result.planets_in_house:
glyph = ""
if planet.name in CELESTIAL_REGISTRY:
glyph = CELESTIAL_REGISTRY[planet.name].glyph
planet_names.append(f"{glyph} {planet.name}" if glyph else planet.name)
return {
"type": "text",
"content": f"House {result.profected_house} contains: {', '.join(planet_names)}",
}
def _build_lord_condition(self, result) -> dict:
"""Build Lord of Year natal condition details."""
if result.ruler_position is None:
return {
"type": "text",
"content": f"{result.ruler} position not found in chart.",
}
pos = result.ruler_position
ruler_glyph = ""
if result.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[result.ruler].glyph
sign_glyph = get_sign_glyph(pos.sign)
# Format degree/minute
degree = int(pos.sign_degree)
minute = int((pos.sign_degree % 1) * 60)
data = {
"Planet": f"{ruler_glyph} {result.ruler}",
"Natal Sign": f"{sign_glyph} {pos.sign}",
"Natal Degree": f"{degree}°{minute:02d}'",
"Natal House": f"House {result.ruler_house}" if result.ruler_house else "—",
"Retrograde": "Yes ℞" if pos.is_retrograde else "No",
}
return {
"type": "key_value",
"data": data,
}
def _build_timeline_table(self, timeline, current_age: int) -> dict:
"""Build timeline table with Lords sequence."""
headers = ["Age", "House", "Sign", "Lord"]
rows = []
for entry in timeline.entries:
sign_glyph = get_sign_glyph(entry.profected_sign)
ruler_glyph = ""
if entry.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[entry.ruler].glyph
# Mark current age
age_str = str(entry.units)
if entry.units == current_age:
age_str = f"→ {entry.units} ←"
rows.append(
[
age_str,
f"House {entry.profected_house}",
f"{sign_glyph} {entry.profected_sign}",
f"{ruler_glyph} {entry.ruler}",
]
)
return {
"type": "table",
"headers": headers,
"rows": rows,
}
[docs]
class ZodiacalReleasingSection:
"""
Zodiacal Releasing timing analysis section.
Shows ZR periods from one or more Lots (Fortune, Spirit, etc.),
with options to display current snapshot and/or L1 timeline.
Snapshot mode shows:
- Current L1/L2 periods (always shown)
- L3/L4 context (current ± 2 periods) for finer timing
Timeline mode shows:
- All L1 periods with ages and status indicators
- Peak (★), Angular (◆), and Current (⚡) markers
"""
def __init__(
self,
lots: str | list[str] | None = None,
mode: str = "both",
query_date: dt.datetime | str | None = None,
query_age: float | None = None,
context_periods: int = 2,
) -> None:
"""
Initialize Zodiacal Releasing section.
Args:
lots: Which lot(s) to display:
- str: Single lot name (e.g., "Part of Fortune")
- list[str]: Multiple lots (e.g., ["Part of Fortune", "Part of Spirit"])
- None or "all": All lots calculated in the chart
mode: Display mode:
- "snapshot": Current periods only
- "timeline": L1 timeline only
- "both": Both snapshot and timeline (DEFAULT)
query_date: Date for snapshot (defaults to now)
- datetime: Use this date
- str: Parse as ISO format
- None: Use current date/time
query_age: Age for snapshot (alternative to query_date)
- float: Use this age
- None: Calculate from query_date
context_periods: Number of periods before/after current to show
for L3/L4 context (default: 2)
"""
# Normalize lots parameter
if lots is None or lots == "all":
self._lots_mode = "all"
self._lots = None
elif isinstance(lots, str):
self._lots_mode = "specific"
self._lots = [lots]
else:
self._lots_mode = "specific"
self._lots = list(lots)
if mode not in ("snapshot", "timeline", "both"):
raise ValueError(
f"mode must be 'snapshot', 'timeline', or 'both', got {mode}"
)
self.mode = mode
self.context_periods = context_periods
# Handle query date/age
if query_date is not None:
if isinstance(query_date, str):
self._query_date = dt.datetime.fromisoformat(query_date)
else:
self._query_date = query_date
self._query_age = None
elif query_age is not None:
self._query_date = None
self._query_age = query_age
else:
# Default to now
self._query_date = dt.datetime.now(dt.UTC)
self._query_age = None
@property
def section_name(self) -> str:
return "Zodiacal Releasing"
[docs]
def generate_data(self, chart: CalculatedChart) -> dict[str, Any]:
"""Generate Zodiacal Releasing data."""
# Check if ZR data exists
if "zodiacal_releasing" not in chart.metadata:
return {
"type": "text",
"content": (
"Zodiacal Releasing not calculated. Add ZodiacalReleasingAnalyzer:\n\n"
" from stellium.engines.releasing import ZodiacalReleasingAnalyzer\n\n"
" chart = (\n"
" ChartBuilder.from_native(native)\n"
" .add_analyzer(ZodiacalReleasingAnalyzer(['Part of Fortune']))\n"
" .calculate()\n"
" )"
),
}
zr_data = chart.metadata["zodiacal_releasing"]
# Determine which lots to show
if self._lots_mode == "all":
lots_to_show = list(zr_data.keys())
else:
lots_to_show = [lot for lot in self._lots if lot in zr_data]
if not lots_to_show:
return {
"type": "text",
"content": "No Zodiacal Releasing data found for the specified lot(s).",
}
# Build sections for each lot
sections = []
for lot_name in lots_to_show:
timeline: ZRTimeline = zr_data[lot_name]
# Get snapshot for query date/age
try:
if self._query_age is not None:
snapshot = timeline.at_age(self._query_age)
else:
snapshot = timeline.at_date(self._query_date)
except ValueError:
# Date outside timeline range - use age 0 as fallback
snapshot = timeline.at_age(0)
# Build lot header
lot_title = f"{lot_name} ({timeline.lot_sign})"
if self.mode in ("snapshot", "both"):
# Add snapshot section
snapshot_data = self._build_snapshot(timeline, snapshot, chart)
sections.append((lot_title, snapshot_data))
if self.mode in ("timeline", "both"):
# Add timeline section
timeline_data = self._build_timeline(timeline, snapshot)
timeline_title = (
f"{lot_name} — L1 Timeline" if self.mode == "both" else lot_title
)
sections.append((timeline_title, timeline_data))
# Return as compound section
return {
"type": "compound",
"sections": sections,
}
def _build_snapshot(
self, timeline: ZRTimeline, snapshot: ZRSnapshot, chart: CalculatedChart
) -> dict[str, Any]:
"""Build snapshot display showing current periods at all levels."""
sections = []
# Header info
query_date_str = snapshot.date.strftime("%B %d, %Y")
header_data = {
"Current Age": f"{snapshot.age:.1f} years ({query_date_str})",
"Active Rulers": ", ".join(self._format_rulers(snapshot.rulers)),
}
# Add status indicators
status_parts = []
if snapshot.is_peak:
status_parts.append("★ Peak Period")
if snapshot.is_lb:
status_parts.append("⚡ Loosing of Bond")
if status_parts:
header_data["Status"] = " | ".join(status_parts)
sections.append(("Current State", {"type": "key_value", "data": header_data}))
# L1/L2 table (always show)
l1_l2_table = self._build_l1_l2_table(snapshot, chart)
sections.append(("Major Periods", l1_l2_table))
# L3 context (if available)
if snapshot.l3 is not None:
l3_context = self._build_level_context(timeline, snapshot, level=3)
sections.append(("L3 Context", l3_context))
# L4 context (if available)
if snapshot.l4 is not None:
l4_context = self._build_level_context(timeline, snapshot, level=4)
sections.append(("L4 Context", l4_context))
return {
"type": "compound",
"sections": sections,
}
def _build_l1_l2_table(
self, snapshot: ZRSnapshot, chart: CalculatedChart
) -> dict[str, Any]:
"""Build table for L1 and L2 periods."""
headers = ["Level", "Sign", "Ruler", "Period", "Quality", "Status"]
rows = []
# L1 row
l1 = snapshot.l1
l1_age_start = (l1.start - chart.datetime.utc_datetime).days / 365.25
l1_age_end = (l1.end - chart.datetime.utc_datetime).days / 365.25
l1_period = f"Ages {l1_age_start:.0f} - {l1_age_end:.0f}"
l1_status = self._format_period_status(l1)
l1_quality = self._format_quality(l1)
rows.append(
[
"L1 (Major)",
f"{get_sign_glyph(l1.sign)} {l1.sign}",
self._format_ruler(l1.ruler),
l1_period,
l1_quality,
l1_status,
]
)
# L2 row
l2 = snapshot.l2
l2_start = l2.start.strftime("%b %Y")
l2_end = l2.end.strftime("%b %Y")
l2_period = f"{l2_start} - {l2_end}"
l2_status = self._format_period_status(l2)
l2_quality = self._format_quality(l2)
rows.append(
[
"L2 (Sub)",
f"{get_sign_glyph(l2.sign)} {l2.sign}",
self._format_ruler(l2.ruler),
l2_period,
l2_quality,
l2_status,
]
)
return {"type": "table", "headers": headers, "rows": rows}
def _build_level_context(
self, timeline: ZRTimeline, snapshot: ZRSnapshot, level: int
) -> dict[str, Any]:
"""Build context table for L3 or L4 showing periods around current."""
periods = timeline.periods.get(level, [])
if not periods:
return {"type": "text", "content": f"No L{level} data available."}
# Find current period index
current_period = snapshot.l3 if level == 3 else snapshot.l4
if current_period is None:
return {"type": "text", "content": f"No L{level} data available."}
# Find index of current period
current_idx = None
for i, p in enumerate(periods):
if p.start == current_period.start:
current_idx = i
break
if current_idx is None:
return {
"type": "text",
"content": f"Could not locate current L{level} period.",
}
# Get context window
start_idx = max(0, current_idx - self.context_periods)
end_idx = min(len(periods), current_idx + self.context_periods + 1)
context_periods = periods[start_idx:end_idx]
# Build table
headers = ["Sign", "Ruler", "Period", "Status"]
rows = []
for period in context_periods:
is_current = period.start == current_period.start
# Format sign with current marker
sign_str = f"{get_sign_glyph(period.sign)} {period.sign}"
if is_current:
sign_str = f"⚡ {sign_str}"
# Format dates
start_str = period.start.strftime("%b %d")
end_str = period.end.strftime("%b %d")
period_str = f"{start_str} - {end_str}"
# Status
status = self._format_period_status(period)
rows.append(
[sign_str, self._format_ruler(period.ruler), period_str, status]
)
return {"type": "table", "headers": headers, "rows": rows}
def _build_timeline(
self, timeline: ZRTimeline, snapshot: ZRSnapshot
) -> dict[str, Any]:
"""Build L1 timeline table."""
l1_periods = timeline.l1_periods()
if not l1_periods:
return {"type": "text", "content": "No L1 timeline data available."}
headers = ["Sign", "Ruler", "Ages", "Quality", "Status"]
rows = []
for period in l1_periods:
is_current = (
period.start <= snapshot.date < period.end
if snapshot.l1.start == period.start
else False
)
# Actually check if this is the current L1
is_current = period.start == snapshot.l1.start
# Calculate ages
age_start = (period.start - timeline.birth_date).days / 365.25
age_end = (period.end - timeline.birth_date).days / 365.25
# Format sign with current marker
sign_str = f"{get_sign_glyph(period.sign)} {period.sign}"
if is_current:
sign_str = f"⚡ {sign_str}"
# Format ages
ages_str = f"{age_start:3.0f} - {age_end:3.0f}"
# Quality
quality = self._format_quality(period)
# Status
status = self._format_period_status(period)
rows.append(
[sign_str, self._format_ruler(period.ruler), ages_str, quality, status]
)
# Add legend
legend = "★ = Peak (10th) ◆ = Angular ⚡ = Current LB = Loosing of Bond +/- = Quality Score"
return {
"type": "table",
"headers": headers,
"rows": rows,
"footer": legend,
}
def _format_period_status(self, period: ZRPeriod) -> str:
"""Format status indicators for a period."""
parts = []
if period.is_peak:
parts.append("★ Peak (10th)")
elif period.is_angular:
parts.append(f"◆ Angular ({period.angle_from_lot})")
if period.is_loosing_bond:
parts.append("LB")
return " | ".join(parts) if parts else ""
def _format_quality(self, period: ZRPeriod) -> str:
"""Format quality/scoring information for a period."""
# Build quality string with score and sentiment
score_str = f"{period.score:+d}"
# Add sentiment icon
if period.sentiment == "positive":
sentiment_icon = "✓"
elif period.sentiment == "challenging":
sentiment_icon = "✗"
else:
sentiment_icon = "—"
quality = f"{score_str} {sentiment_icon}"
# Optionally add role info (if present and not too long)
roles = []
if period.ruler_role:
# Shorten role names for display
role_map = {
"sect_benefic": "S.Ben",
"contrary_benefic": "C.Ben",
"sect_malefic": "S.Mal",
"contrary_malefic": "C.Mal",
"sect_light": "S.Lgt",
"contrary_light": "C.Lgt",
}
roles.append(role_map.get(period.ruler_role, period.ruler_role))
if roles:
quality += f" ({', '.join(roles)})"
return quality
def _format_ruler(self, ruler: str) -> str:
"""Format ruler with glyph."""
if ruler in CELESTIAL_REGISTRY:
glyph = CELESTIAL_REGISTRY[ruler].glyph
return f"{glyph} {ruler}"
return ruler
def _format_rulers(self, rulers: list[str]) -> list[str]:
"""Format multiple rulers with glyphs."""
return [self._format_ruler(r) for r in rulers]