#!/usr/bin/env python3 """Auditable tropical transit and solar-return evidence calculations.""" from __future__ import annotations from datetime import datetime, timedelta from typing import Any from zoneinfo import ZoneInfo import swisseph as swe try: from western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart except ImportError: # pragma: no cover - package import path from scripts.western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart _PLANETARY_YEARS = { "sun": 19, "moon": 25, "mercury": 20, "venus": 8, "mars": 15, "jupiter": 12, "saturn": 30, } def _target_jd(target_date: str, timezone: str | float | int) -> tuple[float, datetime]: zone, _ = _birth_zone(timezone) local = datetime.fromisoformat(target_date).replace(tzinfo=zone) utc = local.astimezone(ZoneInfo("UTC")) jd = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) return jd, local def _cross_aspects(transits: dict[str, dict[str, Any]], natal: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: matches: list[dict[str, Any]] = [] for transit_name, transit in transits.items(): for natal_name, point in natal.items(): separation = abs(transit["longitude"] - point["longitude"]) separation = min(separation, 360.0 - separation) allowed_orb = _orb_for(transit_name, natal_name) for aspect, exact in _ASPECTS.items(): orb = abs(separation - exact) if orb <= allowed_orb: matches.append({ "transit_planet": transit_name, "natal_point": natal_name, "aspect": aspect, "exact_degrees": exact, "separation": round(separation, 6), "orb": round(orb, 6), "allowed_orb": allowed_orb, }) return sorted(matches, key=lambda row: (row["orb"], row["transit_planet"], row["natal_point"])) def calculate_transit_to_natal(*, target_date: str, **birth: Any) -> dict[str, Any]: """Calculate major tropical transits to natal planets and ASC/MC on a local date.""" natal_chart = build_tropical_natal_chart(**birth) jd, local = _target_jd(target_date, birth["timezone"]) flags = swe.FLG_SWIEPH | swe.FLG_SPEED planets: dict[str, dict[str, Any]] = {} for name, planet_id in _PLANETS.items(): values, _ = swe.calc_ut(jd, planet_id, flags) planets[name] = _point(values[0], speed=values[3]) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } return { "technique": "transits", "status": "used", "target_date": target_date, "target_local_time": local.isoformat(), "zodiac": "tropical", "transit_planets": planets, "aspects": _cross_aspects(planets, natal_points), "orb_policy": "major aspects 0/60/90/120/180; min(per-point configured orb)", "boundary": "A dated transit snapshot only; no duration, outcome, or interpretation is inferred.", } def _jd_to_local(jd_ut: float, timezone: str | float | int) -> datetime: zone, _ = _birth_zone(timezone) year, month, day, hour_float = swe.revjul(jd_ut, swe.GREG_CAL) utc = datetime(year, month, day, tzinfo=ZoneInfo("UTC")) + timedelta(hours=hour_float) return utc.astimezone(zone) def calculate_solar_return(*, target_year: int, **birth: Any) -> dict[str, Any]: """Find the exact tropical solar return and calculate its local return chart.""" natal_chart = build_tropical_natal_chart(**birth) natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] start_jd = swe.julday(int(target_year), 1, 1, 0.0) return_jd = swe.solcross_ut(natal_sun, start_jd, swe.FLG_SWIEPH) return_local = _jd_to_local(return_jd, birth["timezone"]) return_birth = { **birth, "year": return_local.year, "month": return_local.month, "day": return_local.day, "hour": return_local.hour, "minute": return_local.minute, "second": return_local.second, } return_chart = build_tropical_natal_chart(**return_birth) returned_sun = return_chart["natal"]["planets"]["sun"]["longitude"] delta = abs(_longitude(returned_sun - natal_sun)) delta = min(delta, 360.0 - delta) return { "technique": "solar_return", "status": "used", "target_year": int(target_year), "return_julian_day_ut": round(return_jd, 8), "return_local_time": return_local.isoformat(), "natal_sun_longitude": natal_sun, "return_sun_longitude": returned_sun, "sun_longitude_delta": round(delta, 8), "return_chart": return_chart, "boundary": "Exact solar return time and chart only; annual topics require separate audited interpretation.", } def _birth_jd(**birth: Any) -> float: zone, _ = _birth_zone(birth["timezone"]) local = datetime( int(birth["year"]), int(birth["month"]), int(birth["day"]), int(birth["hour"]), int(birth["minute"]), int(birth.get("second", 0)), tzinfo=zone, ) utc = local.astimezone(ZoneInfo("UTC")) return swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) def _progressed_planets(progressed_jd: float) -> dict[str, dict[str, Any]]: flags = swe.FLG_SWIEPH | swe.FLG_SPEED planets: dict[str, dict[str, Any]] = {} for name, planet_id in _PLANETS.items(): values, _ = swe.calc_ut(progressed_jd, planet_id, flags) planets[name] = _point(values[0], speed=values[3]) return planets def _quotidian_progressed_angles(progressed_jd: float, birth: dict[str, Any]) -> dict[str, Any]: local = _jd_to_local(progressed_jd, birth["timezone"]) progressed_birth = { **birth, "year": local.year, "month": local.month, "day": local.day, "hour": local.hour, "minute": local.minute, "second": local.second, } chart = build_tropical_natal_chart(**progressed_birth) return { "status": "used", "method": "secondary_quotidian_progressed_date_same_location", "progressed_local_time": local.isoformat(), "angles": chart["natal"]["angles"], "boundary": "Quotidian progressed-date angles; Naibod and solar-arc angle variants are separate methods.", } def calculate_secondary_progressions(*, target_date: str, **birth: Any) -> dict[str, Any]: """Calculate progressed planets using one ephemeris day per tropical year.""" natal_chart = build_tropical_natal_chart(**birth) target_jd, local = _target_jd(target_date, birth["timezone"]) birth_jd = _birth_jd(**birth) elapsed_years = (target_jd - birth_jd) / 365.242189 progressed_jd = birth_jd + elapsed_years planets = _progressed_planets(progressed_jd) progressed_angles = _quotidian_progressed_angles(progressed_jd, birth) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } return { "technique": "secondary_progressions", "status": "partial", "method": "one_ephemeris_day_per_tropical_year", "target_date": target_date, "target_local_time": local.isoformat(), "elapsed_tropical_years": round(elapsed_years, 8), "progressed_julian_day_ut": round(progressed_jd, 8), "natal_sun_longitude": natal_chart["natal"]["planets"]["sun"]["longitude"], "progressed_planets": planets, "progressed_angles": progressed_angles, "aspects": _cross_aspects(planets, natal_points), "boundary": "Progressed planets plus explicitly selected quotidian angles. Lunar phases, stations, duration, and interpretation remain separate audited layers.", } def calculate_tertiary_progressed_moon(*, target_date: str, **birth: Any) -> dict[str, Any]: """Calculate the tertiary progressed Moon using one day per sidereal lunar month.""" natal_chart = build_tropical_natal_chart(**birth) target_jd, local = _target_jd(target_date, birth["timezone"]) birth_jd = _birth_jd(**birth) elapsed_months = (target_jd - birth_jd) / 27.321661 progressed_jd = birth_jd + elapsed_months planets = _progressed_planets(progressed_jd) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } return { "technique": "tertiary_progressed_moon", "status": "partial", "method": "one_ephemeris_day_per_sidereal_lunar_month", "target_date": target_date, "target_local_time": local.isoformat(), "elapsed_sidereal_lunar_months": round(elapsed_months, 8), "progressed_julian_day_ut": round(progressed_jd, 8), "natal_moon_longitude": natal_chart["natal"]["planets"]["moon"]["longitude"], "progressed_moon": planets["moon"], "aspects": _cross_aspects({"moon": planets["moon"]}, natal_points), "boundary": "Tertiary progressed Moon only; progressed house framework, duration, and interpretation remain separate audited layers.", } def calculate_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: """Direct natal points by the true arc of the secondary progressed Sun.""" natal_chart = build_tropical_natal_chart(**birth) progressions = calculate_secondary_progressions(target_date=target_date, **birth) natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] progressed_sun = progressions["progressed_planets"]["sun"]["longitude"] arc = _longitude(progressed_sun - natal_sun) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } directed = {name: _point(point["longitude"] + arc) for name, point in natal_points.items()} return { "technique": "solar_arc_directions", "status": "partial", "method": "secondary_progressed_sun_arc", "target_date": target_date, "natal_sun_longitude": natal_sun, "progressed_sun_longitude": progressed_sun, "solar_arc_degrees": round(arc, 8), "directed_points": directed, "aspects": _cross_aspects(directed, natal_points), "boundary": "True secondary-progressed-Sun arc applied to natal planets/ASC/MC. Directional converse, latitude, parans, midpoint, duration, and event interpretation are not inferred.", } def calculate_converse_secondary_progressions(*, target_date: str, **birth: Any) -> dict[str, Any]: """Calculate converse progressed planets using one ephemeris day per tropical year backward.""" natal_chart = build_tropical_natal_chart(**birth) target_jd, local = _target_jd(target_date, birth["timezone"]) birth_jd = _birth_jd(**birth) elapsed_years = (target_jd - birth_jd) / 365.242189 progressed_jd = birth_jd - elapsed_years planets = _progressed_planets(progressed_jd) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } return { "technique": "converse_secondary_progressions", "status": "partial", "method": "one_ephemeris_day_per_tropical_year_backward", "target_date": target_date, "target_local_time": local.isoformat(), "elapsed_tropical_years": round(elapsed_years, 8), "progressed_julian_day_ut": round(progressed_jd, 8), "progressed_planets": planets, "aspects": _cross_aspects(planets, natal_points), "progressed_angles": { "status": "blocked", "reason": "Progressed angle method is not selected; quotidian/solar-arc/Naibod variants are not interchangeable.", }, "boundary": "Converse progressed planets only; progressed angles and interpretation remain blocked until a method is selected.", } def calculate_converse_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: """Direct natal points backward by the converse secondary-progressed Sun arc.""" natal_chart = build_tropical_natal_chart(**birth) progressions = calculate_converse_secondary_progressions(target_date=target_date, **birth) natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] progressed_sun = progressions["progressed_planets"]["sun"]["longitude"] arc = _longitude(natal_sun - progressed_sun) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } directed = {name: _point(point["longitude"] - arc) for name, point in natal_points.items()} return { "technique": "converse_solar_arc_directions", "status": "partial", "method": "converse_secondary_progressed_sun_arc", "target_date": target_date, "natal_sun_longitude": natal_sun, "converse_progressed_sun_longitude": progressed_sun, "converse_solar_arc_degrees": round(arc, 8), "directed_points": directed, "aspects": _cross_aspects(directed, natal_points), "boundary": "Backward solar arc applied to natal planets/ASC/MC. Interpretation and parans remain separate audited layers.", } def _midpoint_longitude(first: float, second: float) -> float: diff = _longitude(second - first) if diff > 180.0: diff -= 360.0 return _longitude(first + diff / 2.0) def calculate_midpoints(*, target_date: str | None = None, orb: float = 1.5, **birth: Any) -> dict[str, Any]: """Calculate natal midpoint tree and optional transit conjunction/opposition hits.""" natal_chart = build_tropical_natal_chart(**birth) natal_points = { **natal_chart["natal"]["planets"], "ascendant": natal_chart["natal"]["angles"]["ascendant"], "mc": natal_chart["natal"]["angles"]["mc"], } names = [name for name in [*_PLANETS.keys(), "ascendant", "mc"] if name in natal_points] midpoints: dict[str, dict[str, Any]] = {} for index, first_name in enumerate(names): for second_name in names[index + 1:]: key = f"{first_name}/{second_name}" lon = _midpoint_longitude(natal_points[first_name]["longitude"], natal_points[second_name]["longitude"]) midpoints[key] = _point(lon) result: dict[str, Any] = { "technique": "midpoints", "status": "used", "method": "shortest_arc_direct_midpoints", "orb_degrees": float(orb), "natal_midpoints": midpoints, "boundary": "Midpoint geometry only; hits are conjunction/opposition contacts, not interpretations.", } if target_date: transit = calculate_transit_to_natal(target_date=target_date, **birth) hits: list[dict[str, Any]] = [] for transit_name, transit_point in transit["transit_planets"].items(): for midpoint_name, midpoint in midpoints.items(): separation = abs(transit_point["longitude"] - midpoint["longitude"]) separation = min(separation, 360.0 - separation) for aspect, exact in {"conjunction": 0.0, "opposition": 180.0}.items(): hit_orb = abs(separation - exact) if hit_orb <= orb: hits.append({ "transit_planet": transit_name, "midpoint": midpoint_name, "aspect": aspect, "orb": round(hit_orb, 6), "separation": round(separation, 6), }) result["target_date"] = target_date result["transit_midpoint_hits"] = sorted(hits, key=lambda row: (row["orb"], row["transit_planet"], row["midpoint"])) return result def calculate_lunar_return(*, start_date: str, **birth: Any) -> dict[str, Any]: """Find the next exact tropical lunar return after a local start date.""" natal_chart = build_tropical_natal_chart(**birth) natal_moon = natal_chart["natal"]["planets"]["moon"]["longitude"] start_jd, _ = _target_jd(start_date, birth["timezone"]) return_jd = swe.mooncross_ut(natal_moon, start_jd, swe.FLG_SWIEPH) return_local = _jd_to_local(return_jd, birth["timezone"]) return_birth = { **birth, "year": return_local.year, "month": return_local.month, "day": return_local.day, "hour": return_local.hour, "minute": return_local.minute, "second": return_local.second, } return_chart = build_tropical_natal_chart(**return_birth) returned_moon = return_chart["natal"]["planets"]["moon"]["longitude"] delta = abs(_longitude(returned_moon - natal_moon)) delta = min(delta, 360.0 - delta) return { "technique": "lunar_return", "status": "used", "method": "Swiss Ephemeris mooncross_ut tropical longitude", "start_date": start_date, "return_julian_day_ut": round(return_jd, 8), "return_local_time": return_local.isoformat(), "natal_moon_longitude": natal_moon, "return_moon_longitude": returned_moon, "moon_longitude_delta": round(delta, 8), "return_chart": return_chart, "boundary": "Exact lunar return time and chart only; monthly topics require separate audited interpretation.", } def calculate_lunar_return_series(*, target_date: str, months: int = 1, **birth: Any) -> dict[str, Any]: """Return the next exact lunar return from each of the prior calendar-month anchors.""" if not 1 <= int(months) <= 12: raise ValueError("months must be between 1 and 12") target = datetime.fromisoformat(target_date[:10]) returns = [] for offset in range(int(months) - 1, -1, -1): anchor = target - timedelta(days=31 * offset) returns.append(calculate_lunar_return(start_date=anchor.date().isoformat(), **birth)) return { "technique": "lunar_return_series", "target_date": target_date, "months": int(months), "returns": returns, "boundary": "Calendar-month anchor series; each item is a native exact lunar return calculation.", } def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: int = 370, **birth: Any) -> dict[str, Any]: """Scan daily transit-to-natal aspect activity and group consecutive windows.""" start = datetime.fromisoformat(start_date) end = datetime.fromisoformat(end_date) if end < start: raise ValueError("end_date must be on or after start_date") days = (end.date() - start.date()).days + 1 if days > max_days: raise ValueError(f"duration scan range exceeds max_days={max_days}") daily_hits: list[dict[str, Any]] = [] active: dict[tuple[str, str, str], dict[str, Any]] = {} windows: list[dict[str, Any]] = [] for offset in range(days): current = (start + timedelta(days=offset)).date().isoformat() transit = calculate_transit_to_natal(target_date=current, **birth) keys = set() for aspect in transit["aspects"]: key = (aspect["transit_planet"], aspect["natal_point"], aspect["aspect"]) keys.add(key) if key not in active: active[key] = {"start_date": current, "min_orb": aspect["orb"]} else: active[key]["min_orb"] = min(active[key]["min_orb"], aspect["orb"]) for key in list(active): if key not in keys: row = active.pop(key) windows.append({ "transit_planet": key[0], "natal_point": key[1], "aspect": key[2], "start_date": row["start_date"], "end_date": (start + timedelta(days=offset - 1)).date().isoformat(), "min_orb": round(row["min_orb"], 6), }) daily_hits.append({"date": current, "hit_count": len(transit["aspects"]), "aspects": transit["aspects"]}) final_date = end.date().isoformat() for key, row in active.items(): windows.append({ "transit_planet": key[0], "natal_point": key[1], "aspect": key[2], "start_date": row["start_date"], "end_date": final_date, "min_orb": round(row["min_orb"], 6), }) windows = sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])) exact_hit_timeline = [ { "layer": "transits", "target": row["natal_point"], "aspect": row["aspect"], "window_start": row["start_date"], "exact_date": row["start_date"] if row["start_date"] == row["end_date"] else row["end_date"], "window_end": row["end_date"], "transit_planet": row["transit_planet"], "min_orb": row["min_orb"], } for row in windows ] return { "technique": "transit_duration_scan", "status": "used", "method": "daily local-midnight transit snapshots grouped into consecutive aspect windows", "start_date": start_date, "end_date": end_date, "days_scanned": days, "daily_hits": daily_hits, "windows": windows, "exact_hit_timeline": exact_hit_timeline, "boundary": "Daily scan only; exact ingress/egress times require sub-daily root finding.", } def calculate_parans_status(*, target_date: str | None = None, **birth: Any) -> dict[str, Any]: if not target_date: return {"technique": "parans", "status": "blocked", "reason": "target_date_required"} start_jd, target_local = _target_jd(target_date, birth["timezone"]) geopos = (float(birth["longitude"]), float(birth["latitude"]), 0.0) modes = { "rise": swe.CALC_RISE, "upper_culmination": swe.CALC_MTRANSIT, "set": swe.CALC_SET, } events: list[dict[str, Any]] = [] for planet, planet_id in _PLANETS.items(): for angle, mode in modes.items(): try: status, values = swe.rise_trans(start_jd, planet_id, mode, geopos, 0.0, 15.0, swe.FLG_SWIEPH) except (swe.Error, TypeError, ValueError): continue if status != 0: continue event_local = _jd_to_local(float(values[0]), birth["timezone"]) if event_local.date() != target_local.date(): continue events.append({ "planet": planet, "angle": angle, "julian_day_ut": round(float(values[0]), 8), "local_time": event_local.isoformat(), }) events.sort(key=lambda row: (row["julian_day_ut"], row["planet"], row["angle"])) pairs: list[dict[str, Any]] = [] for index, first in enumerate(events): for second in events[index + 1:]: delta_minutes = (second["julian_day_ut"] - first["julian_day_ut"]) * 1440.0 if delta_minutes > 4.0: break if first["planet"] == second["planet"]: continue pairs.append({ "first": {key: first[key] for key in ("planet", "angle", "local_time")}, "second": {key: second[key] for key in ("planet", "angle", "local_time")}, "separation_minutes": round(delta_minutes, 4), }) return { "technique": "parans", "status": "used", "target_date": target_date, "method": "Swiss Ephemeris rise_trans latitude-aware angular events", "event_count": len(events), "events": events, "paran_pairs_within_4_minutes": pairs, "boundary": "Geometric rise/culmination/set simultaneity only; no interpretation or predictive claim is inferred.", } def calculate_annual_profection(*, target_date: str, **birth: Any) -> dict[str, Any]: """Calculate annual profection house, sign, and yearly ruler from the tropical ascendant.""" natal_chart = build_tropical_natal_chart(**birth) target_local = datetime.fromisoformat(target_date[:10]) birth_month = int(birth["month"]) birth_day = int(birth["day"]) years_elapsed = target_local.year - int(birth["year"]) if (target_local.month, target_local.day) < (birth_month, birth_day): years_elapsed -= 1 asc_sign = natal_chart["natal"]["ascendant"]["sign"] asc_index = _SIGNS.index(asc_sign) profected_house = years_elapsed % 12 + 1 profected_sign = _SIGNS[(asc_index + years_elapsed) % 12] year_lord = _RULERS[profected_sign] year_lord_natal = natal_chart["natal"]["planets"][year_lord] return { "technique": "annual_profection", "status": "used", "target_date": target_date, "years_elapsed": years_elapsed, "natal_ascendant_sign": asc_sign, "profected_house": profected_house, "profected_sign": profected_sign, "year_lord": year_lord, "year_lord_natal_house": year_lord_natal.get("house"), "year_lord_natal_sign": year_lord_natal.get("sign"), "boundary": "Annual profection house/sign activation only; interpretation and event adjudication remain separate audited layers.", } def _lot_longitude(*, ascendant: float, sun: float, moon: float, is_day_chart: bool, lot: str) -> float: lot_key = lot.lower() if lot_key == "fortune": raw = ascendant + (moon - sun) if is_day_chart else ascendant + (sun - moon) elif lot_key == "spirit": raw = ascendant + (sun - moon) if is_day_chart else ascendant + (moon - sun) else: raise ValueError("lot must be spirit or fortune") return _longitude(raw) def _period_years_for_sign(sign: str) -> int: return _PLANETARY_YEARS[_RULERS[sign]] def _build_release_periods(start_sign_index: int, start_date: datetime, levels: int) -> list[dict[str, Any]]: periods: list[dict[str, Any]] = [] current_start = start_date for offset in range(12): sign = _SIGNS[(start_sign_index + offset) % 12] years = _period_years_for_sign(sign) current_end = current_start + timedelta(days=round(years * 365.242189)) period = { "level": 1, "sign": sign, "years": years, "start_date": current_start.date().isoformat(), "end_date": (current_end - timedelta(days=1)).date().isoformat(), } if levels >= 2: subperiods = [] sub_start = current_start for sub_offset in range(12): sub_sign = _SIGNS[(start_sign_index + offset + sub_offset) % 12] sub_years = years * _period_years_for_sign(sub_sign) / 12.0 sub_end = sub_start + timedelta(days=round(sub_years * 365.242189)) subperiods.append({ "level": 2, "sign": sub_sign, "years": round(sub_years, 6), "start_date": sub_start.date().isoformat(), "end_date": (sub_end - timedelta(days=1)).date().isoformat(), }) sub_start = sub_end period["subperiods"] = subperiods periods.append(period) current_start = current_end return periods def calculate_zodiacal_release(*, target_date: str, lot: str = "spirit", levels: int = 2, **birth: Any) -> dict[str, Any]: """Calculate a bounded L1/L2 zodiacal-release timeline from Fortune or Spirit.""" if levels not in {1, 2}: raise ValueError("levels must be 1 or 2") natal_chart = build_tropical_natal_chart(**birth) natal = natal_chart["natal"] ascendant = natal["ascendant"]["longitude"] sun = natal["planets"]["sun"]["longitude"] moon = natal["planets"]["moon"]["longitude"] is_day_chart = natal["planets"]["sun"]["house"] in {7, 8, 9, 10, 11, 12} lot_longitude = _lot_longitude( ascendant=ascendant, sun=sun, moon=moon, is_day_chart=is_day_chart, lot=lot, ) lot_sign = _SIGNS[int(lot_longitude // 30)] start_date = datetime( int(birth["year"]), int(birth["month"]), int(birth["day"]), ) periods = _build_release_periods(_SIGNS.index(lot_sign), start_date, levels) active_period = next( ( period for period in periods if period["start_date"] <= target_date <= period["end_date"] ), None, ) return { "technique": "zodiacal_release", "status": "partial", "target_date": target_date, "lot": lot.lower(), "lot_longitude": round(lot_longitude, 6), "lot_sign": lot_sign, "day_night_basis": "day_chart" if is_day_chart else "night_chart", "levels": levels, "periods": periods, "active_period": active_period, "boundary": "L1/L2 sign-period release scaffold from the Lot of Spirit/Fortune only; loosing-of-the-bond, peak periods, angularity weighting, and interpretation remain separate audited layers.", } def build_timing_techniques( *, transit_date: str | None = None, solar_return_year: int | None = None, secondary_progression_date: str | None = None, tertiary_progressed_moon_date: str | None = None, solar_arc_date: str | None = None, converse_secondary_progression_date: str | None = None, converse_solar_arc_date: str | None = None, midpoint_date: str | None = None, lunar_return_start_date: str | None = None, lunar_return_months: int = 1, duration_scan_start_date: str | None = None, duration_scan_end_date: str | None = None, parans_date: str | None = None, profection_date: str | None = None, zodiacal_release_date: str | None = None, zodiacal_release_lot: str = "spirit", zodiacal_release_levels: int = 2, **birth: Any, ) -> dict[str, Any]: """Materialize only the requested, independently auditable timing layers.""" techniques: dict[str, Any] = {} if transit_date: techniques["transits"] = calculate_transit_to_natal(target_date=transit_date, **birth) if solar_return_year is not None: techniques["solar_return"] = calculate_solar_return(target_year=int(solar_return_year), **birth) if secondary_progression_date: techniques["secondary_progressions"] = calculate_secondary_progressions( target_date=secondary_progression_date, **birth ) if tertiary_progressed_moon_date: techniques["tertiary_progressed_moon"] = calculate_tertiary_progressed_moon( target_date=tertiary_progressed_moon_date, **birth ) if solar_arc_date: techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth) if converse_secondary_progression_date: techniques["converse_secondary_progressions"] = calculate_converse_secondary_progressions( target_date=converse_secondary_progression_date, **birth ) if converse_solar_arc_date: techniques["converse_solar_arc_directions"] = calculate_converse_solar_arc_directions( target_date=converse_solar_arc_date, **birth ) if midpoint_date: techniques["midpoints"] = calculate_midpoints(target_date=midpoint_date, **birth) if lunar_return_start_date: techniques["lunar_return"] = calculate_lunar_return_series( target_date=lunar_return_start_date, months=int(lunar_return_months), **birth, ) if duration_scan_start_date and duration_scan_end_date: techniques["transit_duration_scan"] = calculate_transit_duration_scan( start_date=duration_scan_start_date, end_date=duration_scan_end_date, **birth, ) if parans_date: techniques["parans"] = calculate_parans_status(target_date=parans_date, **birth) if profection_date: techniques["annual_profection"] = calculate_annual_profection(target_date=profection_date, **birth) if zodiacal_release_date: techniques["zodiacal_release"] = calculate_zodiacal_release( target_date=zodiacal_release_date, lot=zodiacal_release_lot, levels=int(zodiacal_release_levels), **birth, ) return techniques