diff --git a/references/oracle/western_oracle_adapter_contract.md b/references/oracle/western_oracle_adapter_contract.md index e533deff..805e57f2 100644 --- a/references/oracle/western_oracle_adapter_contract.md +++ b/references/oracle/western_oracle_adapter_contract.md @@ -32,7 +32,9 @@ To add only requested native time evidence, pass: { "western_timing": { "transit_date": "2026-07-09", - "solar_return_year": 2026 + "solar_return_year": 2026, + "secondary_progression_date": "2026-07-09", + "solar_arc_date": "2026-07-09" } } ``` @@ -40,8 +42,11 @@ To add only requested native time evidence, pass: `transit_date` produces a local-date transit-to-natal major-aspect snapshot. `solar_return_year` locates the exact tropical solar return and calculates its return chart at the supplied birthplace/location. Both are calculation data, -not event verdicts. Secondary progressions and solar arcs remain unavailable -until separately implemented and tested. +not event verdicts. `secondary_progression_date` uses one ephemeris day per +tropical year for progressed planets. `solar_arc_date` applies the true +secondary-progressed-Sun arc to natal planets/ASC/MC. Both remain `partial`: +progressed angles, converses, parans, midpoints, duration, and interpretation +are not asserted. ## Accepted Input diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 34dc7e14..bf4536b3 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -202,14 +202,16 @@ def _western_evidence_packet_from_body( **birth, transit_date=timing_request.get('transit_date'), solar_return_year=timing_request.get('solar_return_year'), + secondary_progression_date=timing_request.get('secondary_progression_date'), + solar_arc_date=timing_request.get('solar_arc_date'), ) if timing: packet['timing_techniques'] = timing packet['sections']['timing_techniques'] = {'status': 'used', 'source_path': 'western.native_timing'} packet['missing_sections'] = [item for item in packet['missing_sections'] if item != 'timing_techniques'] packet['boundary'] = ( - 'Native calculations include only requested transit snapshots and/or exact solar-return charts; ' - 'they do not infer duration, outcomes, progressions, solar arcs, returns beyond solar, or interpretation.' + 'Native calculations include only explicitly requested transit, solar-return, secondary-progression, ' + 'and solar-arc layers; they do not infer duration, outcomes, or interpretation.' ) return packet except Exception as exc: # pragma: no cover - defensive boundary diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py index 77821898..9a2e706f 100644 --- a/scripts/skill_release_package.py +++ b/scripts/skill_release_package.py @@ -48,7 +48,7 @@ Do not add private birth data, API keys, or desktop oracle screenshots to this p 如果没有 VedAstro official_raw_response,请标记 official_blocked 或 local_fallback。 如果我提供西方占星导出,请作为 western_oracle_payload 进入统一主链,不要把单边西占信号说成双系统互证。 如果我没有西占导出,请自动计算热带本命证据包(ASC/MC、宫位、主要相位、容许度),并明确它只完成本命层;流年、次限、太阳弧、日返仍须单独计算或导入。 -如需西占时间技术,请传 western_timing:`{"transit_date":"YYYY-MM-DD","solar_return_year":YYYY}`;当前支持指定日 transit 与精确太阳回归,不把未计算的次限/太阳弧标成已用。 +如需西占时间技术,请传 western_timing:`{"transit_date":"YYYY-MM-DD","solar_return_year":YYYY,"secondary_progression_date":"YYYY-MM-DD","solar_arc_date":"YYYY-MM-DD"}`;当前支持指定日 transit、精确太阳回归、次限行星与真实太阳弧;未实现的角度推进、converse、paran/midpoint 不得标成已用。 ## Highest Quality Mode diff --git a/scripts/western_timing_engine.py b/scripts/western_timing_engine.py index 0b6257e4..1a718279 100644 --- a/scripts/western_timing_engine.py +++ b/scripts/western_timing_engine.py @@ -113,10 +113,86 @@ def calculate_solar_return(*, target_year: int, **birth: Any) -> dict[str, Any]: } +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 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) + 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, + "aspects": _cross_aspects(planets, natal_points), + "boundary": "Progressed planets only. Progressed angles, lunar phases, stations, 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 build_timing_techniques( *, transit_date: str | None = None, solar_return_year: int | None = None, + secondary_progression_date: str | None = None, + solar_arc_date: str | None = None, **birth: Any, ) -> dict[str, Any]: """Materialize only the requested, independently auditable timing layers.""" @@ -125,4 +201,10 @@ def build_timing_techniques( 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 solar_arc_date: + techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth) return techniques diff --git a/tests/test_western_chart_engine.py b/tests/test_western_chart_engine.py index f09996ed..53bd88e1 100644 --- a/tests/test_western_chart_engine.py +++ b/tests/test_western_chart_engine.py @@ -80,7 +80,12 @@ def test_workflow_adds_only_explicit_western_timing_layers() -> None: packet = _western_evidence_packet_from_body( { "entry_mode": "direct_chart", - "western_timing": {"transit_date": "2026-07-09", "solar_return_year": 2026}, + "western_timing": { + "transit_date": "2026-07-09", + "solar_return_year": 2026, + "secondary_progression_date": "2026-07-09", + "solar_arc_date": "2026-07-09", + }, }, {"primary_theme": "career"}, birth_payload={ @@ -89,7 +94,9 @@ def test_workflow_adds_only_explicit_western_timing_layers() -> None: }, ) - assert set(packet["timing_techniques"]) == {"transits", "solar_return"} + assert set(packet["timing_techniques"]) == { + "transits", "solar_return", "secondary_progressions", "solar_arc_directions", + } assert packet["sections"]["timing_techniques"]["status"] == "used" diff --git a/tests/test_western_timing_engine.py b/tests/test_western_timing_engine.py index e97517f4..e243d0fe 100644 --- a/tests/test_western_timing_engine.py +++ b/tests/test_western_timing_engine.py @@ -4,6 +4,8 @@ from __future__ import annotations from scripts.western_timing_engine import ( build_timing_techniques, + calculate_secondary_progressions, + calculate_solar_arc_directions, calculate_solar_return, calculate_transit_to_natal, ) @@ -37,3 +39,21 @@ def test_timing_builder_only_contains_requested_techniques() -> None: timing = build_timing_techniques(**_BIRTH, transit_date="2026-07-09", solar_return_year=2026) assert set(timing) == {"transits", "solar_return"} + + +def test_secondary_progressions_use_declared_day_for_year_contract() -> None: + progressions = calculate_secondary_progressions(**_BIRTH, target_date="2026-07-09") + + assert progressions["technique"] == "secondary_progressions" + assert progressions["method"] == "one_ephemeris_day_per_tropical_year" + assert progressions["progressed_planets"]["sun"]["longitude"] != progressions["natal_sun_longitude"] + assert progressions["aspects"] + + +def test_solar_arc_uses_secondary_progressed_sun_arc() -> None: + directions = calculate_solar_arc_directions(**_BIRTH, target_date="2026-07-09") + + assert directions["technique"] == "solar_arc_directions" + assert directions["method"] == "secondary_progressed_sun_arc" + assert 0 < directions["solar_arc_degrees"] < 40 + assert directions["directed_points"]["sun"]["longitude"] != directions["natal_sun_longitude"]