Skill: mizukaizen/astro-transits (version 1.0.0)

--- name: astro-transits description: Astrological transit calculator with natal chart support. Daily transits, weekly forecasts, void-of-course Moon, aspects, stations, and ingresses. Uses Swiss Ephemeris. version: 1.0.0 homepage: https://github.com/mizukaizen/openclaw-skills-public license: MIT metadata: {"clawdbot":{"emoji":"🌟","requires":{"bins":["python3"],"env":[]},"primaryEnv":""}} --- # Astro Transits Full astrological transit calculator for AI agents. Calculates planetary positions, aspects to any natal chart, Moon phases, void-of-course periods, stations, and ingresses. Uses Swiss Ephemeris (pyswisseph) with Moshier fallback — no API key needed. ## Setup Install the dependency: ```bash pip install pyswisseph ``` Generate a natal chart (one-time setup per user): ```bash python3 {baseDir}/scripts/natal_chart.py \ --date "1993-05-13" \ --time "01:20" \ --tz "Australia/Brisbane" \ --lat -27.2308 --lon 153.0972 \ --save natal.json ``` Parameters: - `--date` — Birth date (YYYY-MM-DD) - `--time` — Birth time in 24h format (HH:MM) - `--tz` — Timezone name (e.g. `America/New_York`, `Europe/London`, `Australia/Brisbane`) - `--lat` / `--lon` — Birth location coordinates - `--save` — Save chart to JSON file for reuse ## Daily Transits ```bash python3 {baseDir}/scripts/transits.py --chart natal.json python3 {baseDir}/scripts/transits.py --chart natal.json --date 2026-03-15 ``` Returns: current planetary positions, active aspects to natal chart (ranked by significance), Moon sign/phase, stations, and ingresses. Top 8 aspects shown with orb, house placement, and interpretive meaning. ## Weekly Forecast ```bash python3 {baseDir}/scripts/transits.py --chart natal.json --week ``` Returns: top 3 most significant transits for the coming week with peak dates and themes. ## Void-of-Course Moon ```bash python3 {baseDir}/scripts/voc_check.py ``` Returns JSON: `{"voc": true/false}` with end time, duration, and next Moon sign if currently void-of-course. No natal chart needed — this is a universal calculation. ## What It Covers - **Planets:** Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, True Node - **Aspects:** Conjunction, sextile, square, trine, opposition (with variable orbs for personal vs outer planets) - **Houses:** Placidus house system - **Events:** Planetary stations (retrograde/direct), sign ingresses - **Moon:** Phase, sign, void-of-course detection - **Interpretations:** Built-in aspect meanings for all planet-aspect combinations ## Notes - No API key or external service required — all calculations run locally - Swiss Ephemeris provides sub-arcsecond accuracy - Falls back to Moshier ephemeris if Swiss Eph data files are absent - Natal chart JSON can be generated once and reused indefinitely - Timezone handling uses Python's `zoneinfo` (Python 3.9+)

path: scripts/transits.py
content: #!/usr/bin/env python3
"""
transits.py — Daily astrological transit calculator.

Usage:
  python3 transits.py --chart natal.json
  python3 transits.py --chart natal.json --date 2026-03-15
  python3 transits.py --chart natal.json --week

First generate a natal chart:
  python3 natal_chart.py --date "1993-05-13" --time "01:20" --tz "Australia/Brisbane" --lat -27.2308 --lon 153.0972 --save natal.json

Requires: pyswisseph (pip install pyswisseph)
"""

import argparse
import json
import sys
import math
from datetime import datetime, timedelta, timezone

try:
    import swisseph as swe
except ImportError:
    print("ERROR: pyswisseph not installed. Run: pip install pyswisseph")
    sys.exit(1)

SIGNS = [
    "Aries", "Taurus", "Gemini", "Cancer",
    "Leo", "Virgo", "Libra", "Scorpio",
    "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]

TRANSIT_PLANETS = {
    "Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY,
    "Venus": swe.VENUS, "Mars": swe.MARS, "Jupiter": swe.JUPITER,
    "Saturn": swe.SATURN, "Uranus": swe.URANUS, "Neptune": swe.NEPTUNE,
    "Pluto": swe.PLUTO, "True Node": swe.TRUE_NODE,
}

PERSONAL_PLANETS = {"Sun", "Moon", "Mercury", "Venus", "Mars"}
ANGLE_POINTS = {"Ascendant", "Midheaven"}

ASPECTS = [
    (0, "conjunct", 8, 6, 10),
    (60, "sextile", 4, 4, 4),
    (90, "square", 7, 5, 7),
    (120, "trine", 7, 5, 7),
    (180, "opposite", 8, 6, 8),
]

ASPECT_MEANINGS = {
    ("Sun", "conjunct"): "vital identity, bold self-expression",
    ("Sun", "sextile"): "ease in showing up, creative flow",
    ("Sun", "square"): "identity tension, growth through friction",
    ("Sun", "trine"): "natural vitality, confidence flows",
    ("Sun", "opposite"): "awareness through contrast, visibility",
    ("Moon", "conjunct"): "emotional depth, instinct sharpened",
    ("Moon", "sextile"): "emotional ease, intuition opens",
    ("Moon", "square"): "emotional friction, feelings surface",
    ("Moon", "trine"): "inner peace, emotional support",
    ("Moon", "opposite"): "feeling vs reality, mirror moment",
    ("Mercury", "conjunct"): "sharp mind, key communications",
    ("Mercury", "sextile"): "clear thinking, good for dialogue",
    ("Mercury", "square"): "mental friction, rethink approach",
    ("Mercury", "trine"): "ideas flow, writing and speaking strong",
    ("Mercury", "opposite"): "listen as much as speak",
    ("Venus", "conjunct"): "beauty, connection, value aligned",
    ("Venus", "sextile"): "charm flows, good for relationships",
    ("Venus", "square"): "values tested, desire vs reality",
    ("Venus", "trine"): "harmony, creative magnetism",
    ("Venus", "opposite"): "relationship mirror, what you attract",
    ("Mars", "conjunct"): "energy surges, decisive action",
    ("Mars", "sextile"): "focused drive, productive momentum",
    ("Mars", "square"): "friction activates will, channel it",
    ("Mars", "trine"): "energy flows smoothly, act now",
    ("Mars", "opposite"): "others push back, assert clearly",
    ("Jupiter", "conjunct"): "expansion, luck, big picture opens",
    ("Jupiter", "sextile"): "opportunity knocks, take it",
    ("Jupiter", "square"): "overreach risk, but growth possible",
    ("Jupiter", "trine"): "growth comes easily, trust it",
    ("Jupiter", "opposite"): "excess vs balance, calibrate",
    ("Saturn", "conjunct"): "structure tested, commitment required",
    ("Saturn", "sextile"): "steady progress through discipline",
    ("Saturn", "square"): "hard work phase, results are real",
    ("Saturn", "trine"): "solid foundations, effort recognised",
    ("Saturn", "opposite"): "accountability moment, face reality",
    ("Uranus", "conjunct"): "sudden change, awakening arrives",
    ("Uranus", "sextile"): "innovation flows, fresh perspective",
    ("Uranus", "square"): "disruption demands flexibility",
    ("Uranus", "trine"): "inspired change, freedom opens",
    ("Uranus", "opposite"): "unexpected shifts, adapt or resist",
    ("Neptune", "conjunct"): "dissolution, dreams surface",
    ("Neptune", "sextile"): "intuition heightened, art inspired",
    ("Neptune", "square"): "confusion risk, ground yourself",
    ("Neptune", "trine"): "spiritual flow, imagination expands",
    ("Neptune", "opposite"): "illusion vs truth, clarity needed",
    ("Pluto", "conjunct"): "deep transformation, no going back",
    ("Pluto", "sextile"): "power used constructively",
    ("Pluto", "square"): "power struggle, ego death possible",
    ("Pluto", "trine"): "deep change, empowerment flows",
    ("Pluto", "opposite"): "power dynamics surface, own it",
    ("True Node", "conjunct"): "destiny activated, soul path opens",
    ("True Node", "sextile"): "karmic opportunity, say yes",
    ("True Node", "square"): "tension with direction, clarify",
    ("True Node", "trine"): "aligned with purpose, flow",
    ("True Node", "opposite"): "past vs future, choose forward",
}

PHASE_NAMES = [
    (0, "New Moon"), (45, "Waxing Crescent"), (90, "First Quarter"),
    (135, "Waxing Gibbous"), (180, "Full Moon"), (225, "Waning Gibbous"),
    (270, "Last Quarter"), (315, "Waning Crescent"), (360, "New Moon"),
]


def sign_of(deg):
    return SIGNS[int(deg / 30) % 12]

def deg_in_sign(deg):
    return deg % 30

def calc_planet(jd, planet_id):
    try:
        result, ret_flag = swe.calc_ut(jd, planet_id, swe.FLG_SWIEPH)
        if ret_flag < 0:
            raise RuntimeError("Swiss Eph error")
        return result[0]
    except Exception:
        result, _ = swe.calc_ut(jd, planet_id, swe.FLG_MOSEPH)
        return result[0]

def angular_diff(a, b):
    diff = abs(a - b) % 360
    return diff if diff <= 180 else 360 - diff

def house_of(lon, house_cusps):
    for i in range(12):
        c_start = house_cusps[i]
        c_end = house_cusps[(i + 1) % 12]
        if c_end < c_start:
            if lon >= c_start or lon < c_end:
                return i + 1
        else:
            if c_start <= lon < c_end:
                return i + 1
    return 1

def moon_phase(jd):
    sun_lon = calc_planet(jd, swe.SUN)
    moon_lon = calc_planet(jd, swe.MOON)
    angle = (moon_lon - sun_lon) % 360
    pct = round((1 - math.cos(math.radians(angle))) / 2 * 100, 1)
    for i, (thresh, name) in enumerate(PHASE_NAMES[:-1]):
        if angle < PHASE_NAMES[i + 1][0]:
            return pct, name
    return pct, "New Moon"

def significance(transit_planet, natal_point, orb, aspect_name):
    score = (10 - orb) * 10
    if transit_planet not in PERSONAL_PLANETS:
        score += 25
    if natal_point in ANGLE_POINTS:
        score += 40
    if natal_point in PERSONAL_PLANETS:
        score += 20
    if aspect_name in ("conjunct", "opposite"):
        score += 10
    return score

def check_stations(jd):
    stations = []
    for name, pid in TRANSIT_PLANETS.items():
        if name in ("Sun", "Moon", "True Node"):
            continue
        try:
            lon_before = calc_planet(jd - 1, pid)
            lon_after = calc_planet(jd + 1, pid)
            daily_motion = (lon_after - lon_before) / 2
            if abs(daily_motion) < 0.05:
                direction = "direct" if daily_motion > 0 else "retrograde"
                stations.append("%s stationing %s" % (name, direction))
        except Exception:
            pass
    return stations

def check_ingresses(jd):
    ingresses = []
    for name, pid in TRANSIT_PLANETS.items():
        try:
            lon_y = calc_planet(jd - 1, pid)
            lon_t = calc_planet(jd, pid)
            if int(lon_y / 30) != int(lon_t / 30):
                ingresses.append("%s enters %s" % (name, SIGNS[int(lon_t / 30) % 12]))
        except Exception:
            pass
    return ingresses

def theme_for(transit_planet, natal_point, aspect_name):
    p = transit_planet
    if p == "Pluto":
        return "transformation" if natal_point in ANGLE_POINTS else "depth"
    if p == "Jupiter":
        return "breakthrough" if natal_point in ANGLE_POINTS else "expansion"
    if p == "Saturn":
        return "tension" if aspect_name in ("square", "opposite") else "structure"
    if p == "Uranus":
        return "disruption" if aspect_name in ("square", "opposite") else "clarity"
    if p == "Neptune":
        return "vision" if aspect_name in ("conjunct", "trine", "sextile") else "release"
    if p == "Mars":
        return "friction" if aspect_name in ("square", "opposite") else "momentum"
    if p == "Mercury":
        return "clarity"
    if p == "Venus":
        return "connection"
    if p in ("Sun", "Moon"):
        return "vitality" if p == "Sun" else "release"
    return "momentum"


def run_daily(chart, target_date=None):
    natal = chart["positions"]
    cusps = chart["house_cusps"]

    if target_date:
        date = datetime.strptime(target_date, "%Y-%m-%d").date()
    else:
        date = datetime.now(timezone.utc).date()

    # Noon UTC
    jd = swe.julday(date.year, date.month, date.day, 12.0)

    transiting = {}
    for name, pid in TRANSIT_PLANETS.items():
        transiting[name] = calc_planet(jd, pid)

    moon_lon = transiting["Moon"]
    pct_illum, phase_name = moon_phase(jd)

    found = []
    for t_name, t_lon in transiting.items():
        for n_name, n_lon in natal.items():
            diff = angular_diff(t_lon, n_lon)
            for asp_angle, asp_name, orb_p, orb_o, orb_a in ASPECTS:
                asp_diff = abs(diff - asp_angle)
                if n_name in ANGLE_POINTS:
                    limit = orb_a
                elif t_name in PERSONAL_PLANETS or n_name in PERSONAL_PLANETS:
                    limit = orb_p
                else:
                    limit = orb_o
                if asp_diff <= limit:
                    orb = round(asp_diff, 1)
                    score = significance(t_name, n_name, orb, asp_name)
                    meaning = ASPECT_MEANINGS.get((t_name, asp_name), "notable activation")
                    found.append({
                        "transit": t_name, "aspect": asp_name,
                        "natal": n_name, "orb": orb,
                        "house": house_of(n_lon, cusps),
                        "score": score, "meaning": meaning,
                    })

    found.sort(key=lambda x: -x["score"])
    stations = check_stations(jd)
    ingresses = check_ingresses(jd)

    weekday = date.strftime("%A")
    date_str = date.strftime("%-d %B %Y")

    print("TRANSIT CONTEXT — %s %s" % (weekday, date_str))
    print("Moon: %s %.1f° — %s (%.1f%% illuminated)" % (
        sign_of(moon_lon), deg_in_sign(moon_lon), phase_name, pct_illum))
    print()
    print("Transits active today (ranked by significance):")

    for a in found[:8]:
        print("%s %s natal %s (orb %.1f°) [house %d] — %s" % (
            a["transit"], a["aspect"], a["natal"], a["orb"], a["house"], a["meaning"]))

    if not found:
        print("No major transits active today.")

    notable = "; ".join(stations + ingresses) or "None today"
    print()
    print("Notable: %s" % notable)


def run_weekly(chart, target_date=None):
    natal = chart["positions"]
    cusps = chart["house_cusps"]

    if target_date:
        today = datetime.strptime(target_date, "%Y-%m-%d").date()
    else:
        today = datetime.now(timezone.utc).date()

    # Next Monday
    days_until_monday = (7 - today.weekday()) % 7
    if days_until_monday == 0:
        days_until_monday = 7
    monday = today + timedelta(days=days_until_monday)
    week_dates = [monday + timedelta(days=i) for i in range(7)]

    transit_best = {}

    for date in week_dates:
        jd = swe.julday(date.year, date.month, date.day, 12.0)
        transiting = {}
        for pname, pid in TRANSIT_PLANETS.items():
            try:
                transiting[pname] = calc_planet(jd, pid)
            except Exception:
                pass

        for t_name, t_lon in transiting.items():
            for n_name, n_lon in natal.items():
                diff = angular_diff(t_lon, n_lon)
                for asp_angle, asp_name, orb_p, orb_o, orb_a in ASPECTS:
                    asp_diff = abs(diff - asp_angle)
                    if n_name in ANGLE_POINTS:
                        limit = orb_a
                    elif t_name in PERSONAL_PLANETS or n_name in PERSONAL_PLANETS:
                        limit = orb_p
                    else:
                        limit = orb_o
                    if asp_diff <= limit:
                        orb = round(asp_diff, 2)
                        key = (t_name, asp_name, n_name)
                        score = significance(t_name, n_name, orb, asp_name)
                        if key not in transit_best or orb < transit_best[key]["orb"]:
                            transit_best[key] = {
                                "orb": orb, "date": date, "score": score,
                                "transit": t_name, "aspect": asp_name, "natal": n_name,
                            }

    all_transits = list(transit_best.values())
    all_transits.sort(key=lambda x: -x["score"])

    sunday = week_dates[-1]
    print("WEEK AHEAD — %s to %s" % (monday.strftime("%-d %b"), sunday.strftime("%-d %b %Y")))

    for i, t in enumerate(all_transits[:3], 1):
        day = t["date"].strftime("%A")
        ds = t["date"].strftime("%-d %b")
        th = theme_for(t["transit"], t["natal"], t["aspect"])
        print("%d. %s %s natal %s — exact %s, %s — theme: %s" % (
            i, t["transit"], t["aspect"], t["natal"], day, ds, th))


def main():
    parser = argparse.ArgumentParser(description="Astrological transit calculator")
    parser.add_argument("--chart", required=True, help="Path to natal chart JSON (from natal_chart.py)")
    parser.add_argument("--date", help="Target date YYYY-MM-DD (default: today)")
    parser.add_argument("--week", action="store_true", help="Show weekly forecast instead of daily")
    args = parser.parse_args()

    with open(args.chart) as f:
        chart = json.load(f)

    if args.week:
        run_weekly(chart, args.date)
    else:
        run_daily(chart, args.date)


if __name__ == "__main__":
    main()


path: scripts/natal_chart.py
content: #!/usr/bin/env python3
"""
natal_chart.py — Calculate natal chart from birth data.

Usage:
  python3 natal_chart.py --date "1993-05-13" --time "01:20" --tz "Australia/Brisbane" --lat -27.2308 --lon 153.0972
  python3 natal_chart.py --config natal.json

Outputs JSON with planetary positions, house cusps, and metadata.
Requires: pyswisseph (pip install pyswisseph)
"""

import argparse
import json
import sys
import math
from datetime import datetime

try:
    import swisseph as swe
except ImportError:
    print(json.dumps({"error": "pyswisseph not installed. Run: pip install pyswisseph"}))
    sys.exit(1)

SIGNS = [
    "Aries", "Taurus", "Gemini", "Cancer",
    "Leo", "Virgo", "Libra", "Scorpio",
    "Sagittarius", "Capricorn", "Aquarius", "Pisces",
]

PLANETS = {
    "Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY,
    "Venus": swe.VENUS, "Mars": swe.MARS, "Jupiter": swe.JUPITER,
    "Saturn": swe.SATURN, "Uranus": swe.URANUS, "Neptune": swe.NEPTUNE,
    "Pluto": swe.PLUTO, "North Node": swe.TRUE_NODE,
}


def calc_planet(jd, planet_id):
    try:
        result, ret_flag = swe.calc_ut(jd, planet_id, swe.FLG_SWIEPH)
        if ret_flag < 0:
            raise RuntimeError("Swiss Eph error")
        return result[0]
    except Exception:
        result, _ = swe.calc_ut(jd, planet_id, swe.FLG_MOSEPH)
        return result[0]


def sign_of(deg):
    return SIGNS[int(deg / 30) % 12]


def birth_to_jd_utc(date_str, time_str, tz_str):
    """Convert local birth date/time to Julian Day in UTC."""
    try:
        from zoneinfo import ZoneInfo
    except ImportError:
        from backports.zoneinfo import ZoneInfo

    local_dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
    tz = ZoneInfo(tz_str)
    local_dt = local_dt.replace(tzinfo=tz)
    utc_dt = local_dt.astimezone(ZoneInfo("UTC"))
    hour_frac = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0
    return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour_frac)


def calculate_natal(date_str, time_str, tz_str, lat, lon):
    jd = birth_to_jd_utc(date_str, time_str, tz_str)

    # Planetary positions
    positions = {}
    for name, pid in PLANETS.items():
        deg = calc_planet(jd, pid)
        positions[name] = round(deg, 2)

    # South Node = opposite North Node
    positions["South Node"] = round((positions["North Node"] + 180) % 360, 2)

    # House cusps (Placidus)
    cusps, ascmc = swe.houses(jd, lat, lon, b'P')
    house_cusps = [round(c, 2) for c in cusps]
    positions["Ascendant"] = round(ascmc[0], 2)
    positions["Midheaven"] = round(ascmc[1], 2)

    # Build output
    chart = {
        "birth_data": {
            "date": date_str,
            "time": time_str,
            "timezone": tz_str,
            "latitude": lat,
            "longitude": lon,
        },
        "positions": positions,
        "house_cusps": house_cusps,
        "signs": {name: sign_of(deg) for name, deg in positions.items()},
    }
    return chart


def main():
    parser = argparse.ArgumentParser(description="Calculate natal chart")
    parser.add_argument("--config", help="Path to JSON config with birth data")
    parser.add_argument("--date", help="Birth date YYYY-MM-DD")
    parser.add_argument("--time", help="Birth time HH:MM (24h)")
    parser.add_argument("--tz", help="Timezone (e.g. Australia/Brisbane)")
    parser.add_argument("--lat", type=float, help="Birth latitude")
    parser.add_argument("--lon", type=float, help="Birth longitude")
    parser.add_argument("--save", help="Save chart to this file path")
    args = parser.parse_args()

    if args.config:
        with open(args.config) as f:
            cfg = json.load(f)
        date_str = cfg["date"]
        time_str = cfg["time"]
        tz_str = cfg["timezone"]
        lat = cfg["latitude"]
        lon = cfg["longitude"]
    elif args.date and args.time and args.tz and args.lat is not None and args.lon is not None:
        date_str = args.date
        time_str = args.time
        tz_str = args.tz
        lat = args.lat
        lon = args.lon
    else:
        parser.error("Provide either --config or all of --date --time --tz --lat --lon")
        return

    chart = calculate_natal(date_str, time_str, tz_str, lat, lon)

    if args.save:
        with open(args.save, "w") as f:
            json.dump(chart, f, indent=2)
        print("Natal chart saved to %s" % args.save)
    else:
        print(json.dumps(chart, indent=2))


if __name__ == "__main__":
    main()


path: scripts/voc_check.py
content: #!/usr/bin/env python3
"""
voc_check.py - Check if Moon is currently void-of-course.

Void-of-course: Moon has made its last major aspect to any planet before
changing signs. Major aspects: conjunction (0), sextile (60), square (90),
trine (120), opposition (180). Orb <= 1.0 degree for "applying" detection.

Output JSON:
  {"voc": true, "start_time_utc": "...", "end_time_utc": "...", "duration_hours": X.X, "end_sign": "Aries"}
  {"voc": false}
"""

import json
import sys
from datetime import datetime, timezone, timedelta

try:
    import swisseph as swe
except ImportError:
    print(json.dumps({"error": "pyswisseph not installed"}))
    sys.exit(1)

PLANETS = [swe.SUN, swe.MERCURY, swe.VENUS, swe.MARS, swe.JUPITER,
           swe.SATURN, swe.URANUS, swe.NEPTUNE, swe.PLUTO]
PLANET_NAMES = {swe.SUN: "Sun", swe.MERCURY: "Mercury", swe.VENUS: "Venus",
                swe.MARS: "Mars", swe.JUPITER: "Jupiter", swe.SATURN: "Saturn",
                swe.URANUS: "Uranus", swe.NEPTUNE: "Neptune", swe.PLUTO: "Pluto"}
ASPECTS = [0, 60, 90, 120, 180]
VOC_ORB = 1.0
SIGNS = ["Aries","Taurus","Gemini","Cancer","Leo","Virgo",
         "Libra","Scorpio","Sagittarius","Capricorn","Aquarius","Pisces"]

def get_pos(jd, planet):
    flags = swe.FLG_SWIEPH | swe.FLG_SPEED
    try:
        result, _ = swe.calc_ut(jd, planet, flags)
    except Exception:
        result, _ = swe.calc_ut(jd, planet, swe.FLG_MOSEPH | swe.FLG_SPEED)
    return result[0], result[3]  # longitude, speed

def angle_diff(a, b):
    diff = abs(a - b) % 360
    if diff > 180:
        diff = 360 - diff
    return diff

def nearest_aspect(moon_lon, planet_lon):
    diffs = [(abs(angle_diff(moon_lon, planet_lon) - asp), asp)
             for asp in ASPECTS]
    return min(diffs, key=lambda x: x[0])

def sign_of(lon):
    return int(lon / 30)

def sign_ingress_time(jd_start, current_sign_idx):
    """Find JD when Moon enters the next sign via bisection."""
    target_sign = (current_sign_idx + 1) % 12
    jd_end = jd_start + 3.5
    lo, hi = jd_start, jd_end
    for _ in range(50):
        mid = (lo + hi) / 2
        lon, _ = get_pos(mid, swe.MOON)
        if sign_of(lon) == target_sign:
            hi = mid
        else:
            lo = mid
        if hi - lo < 1/86400:
            break
    return hi

def has_applying_aspect_before_ingress(jd_now, jd_ingress):
    """Check if Moon makes any applying major aspect to any planet before ingress."""
    step = 15 / (24 * 60)  # 15 minutes in JD
    jd = jd_now
    while jd < jd_ingress:
        jd = min(jd + step, jd_ingress)
        moon_lon, _ = get_pos(jd, swe.MOON)
        for planet in PLANETS:
            planet_lon, _ = get_pos(jd, planet)
            diff, asp = nearest_aspect(moon_lon, planet_lon)
            if diff <= VOC_ORB:
                return True, jd, planet, asp
    return False, None, None, None

def main():
    now_utc = datetime.now(timezone.utc)
    jd_now = swe.julday(now_utc.year, now_utc.month, now_utc.day,
                        now_utc.hour + now_utc.minute/60.0 + now_utc.second/3600.0)

    moon_lon, moon_speed = get_pos(jd_now, swe.MOON)
    current_sign_idx = sign_of(moon_lon)

    jd_ingress = sign_ingress_time(jd_now, current_sign_idx)
    next_sign = SIGNS[(current_sign_idx + 1) % 12]

    has_aspect, aspect_jd, aspect_planet, aspect_type = has_applying_aspect_before_ingress(jd_now, jd_ingress)

    if has_aspect:
        print(json.dumps({"voc": False}))
        return

    # Moon is void-of-course
    J2000 = 2451545.0
    ingress_dt = datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + timedelta(days=jd_ingress - J2000)
    duration_hours = (jd_ingress - jd_now) * 24

    result = {
        "voc": True,
        "start_time_utc": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "end_time_utc": ingress_dt.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "duration_hours": round(duration_hours, 1),
        "end_sign": next_sign,
        "moon_sign": SIGNS[current_sign_idx]
    }
    print(json.dumps(result))

if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)