Jelajahi Sumber

Add container packaging and bundle Swiss ephemeris data

Lukas Goldschmidt 3 minggu lalu
induk
melakukan
fc04e6af25
11 mengubah file dengan 364 tambahan dan 72 penghapusan
  1. 12 0
      .dockerignore
  2. 6 25
      .env.example
  3. 2 1
      .gitignore
  4. 23 0
      Dockerfile
  5. 11 1
      IMPLEMENTATION_PLAN.md
  6. 26 4
      README.md
  7. 36 0
      data/seas_18.se1
  8. 25 0
      docker-compose.yml
  9. 75 33
      src/ephemeris_mcp/ephemeris.py
  10. 131 6
      src/ephemeris_mcp/server.py
  11. 17 2
      tests/test_basic.py

+ 12 - 0
.dockerignore

@@ -0,0 +1,12 @@
+.git
+.venv
+__pycache__/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+logs/
+data/
+*.pyc
+*.pyo
+*.pyd
+*.sqlite3

+ 6 - 25
.env.example

@@ -1,27 +1,8 @@
-# Ephemeris MCP Configuration
-# Copy to .env and adjust as needed
-
-# Server
 EPHEMERIS_HOST=0.0.0.0
 EPHEMERIS_PORT=7015
-
-# Data & storage
-EPHEMERIS_DATA_DIR=./data
-EPHEMERIS_LOG_DIR=./logs
-EPHEMERIS_DB_PATH=./data/ephemeris.sqlite3
-
-# Cache TTLs (seconds)
-EPHEMERIS_CACHE_SOLAR=86400
-EPHEMERIS_CACHE_PLANETARY=3600
-EPHEMERIS_CACHE_LUNAR=3600
-EPHEMERIS_CACHE_SIDEREAL=3600
-EPHEMERIS_CACHE_SKY=300
-
-# Ephemeris data
-EPHEMERIS_EPH_FILE=de440s.bsp
-EPHEMERIS_EPH_URL=https://naif.jpl.nasa.gov/pub/naif/generic_kernels/spk/planets/de440s.bsp
-
-# Satellite tracking
-EPHEMERIS_TLE_AUTO_REFRESH=0
-EPHEMERIS_TLE_REFRESH_INTERVAL=3600
-EPHEMERIS_TLE_SOURCE=https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=tle
+EPHEMERIS_DATA_DIR=/app/data
+EPHEMERIS_LOG_DIR=/app/logs
+EPHEMERIS_DB_PATH=/app/data/ephemeris.sqlite3
+EPHEMERIS_DEFAULT_LAT=
+EPHEMERIS_DEFAULT_LON=
+EPHEMERIS_RELOAD=0

+ 2 - 1
.gitignore

@@ -5,7 +5,8 @@ __pycache__/
 .ruff_cache/
 *.pyc
 logs/
-data/
+data/*
+!data/seas_18.se1
 data/*.bsp
 uvicorn.log
 server.log

+ 23 - 0
Dockerfile

@@ -0,0 +1,23 @@
+FROM python:3.13-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+WORKDIR /app
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends build-essential \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt ./
+
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY main.py ./
+COPY src ./src
+
+RUN mkdir -p /app/data /app/logs
+
+EXPOSE 7015
+
+CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7015"]

+ 11 - 1
IMPLEMENTATION_PLAN.md

@@ -31,6 +31,7 @@ This is the first version worth committing as a usable baseline.
   - `get_sidereal_time`
   - `get_constellation_at_ecliptic`
   - `list_available_bodies`
+  - `get_sky_state`
 - docs point to the wiki and describe the shipped slice
 
 #### Explicitly out of scope
@@ -67,7 +68,8 @@ This is the first version worth committing as a usable baseline.
 
 ### Data files
 
-- JPL ephemeris data lives in `./data/`
+- Swiss Ephemeris data lives in `./data/`
+- `seas_18.se1` is required for Chiron
 - satellite support remains planned, not part of v0.1
 
 ---
@@ -84,6 +86,7 @@ This is the first version worth committing as a usable baseline.
 | A4 | `get_moon_phase` | `datetime?`, `lat?`, `lon?` | Quick moon-phase view for manual use |
 | A5 | `get_sidereal_time` | `datetime?`, `lat?`, `lon?` | GST, LST, obliquity of ecliptic |
 | A6 | `get_constellation_at_ecliptic` | `ecliptic_lon` | IAU constellation name/abbrev at that ecliptic longitude |
+| A7 | `get_sky_state` | `datetime?`, `lat?`, `lon?`, `elevation?`, `geocentric?` | Consolidated raw astronomical snapshot for downstream chart engines |
 
 ### Group B — Sky Objects & Satellite Tracking (serves satrack-mcp)
 
@@ -159,6 +162,13 @@ Accepts a comma-separated string, defaults to `"planets,moon,sun"` (the most pro
 - [ ] keep `README.md`, `AGENTS.md`, and wiki references aligned
 - [ ] add more operational notes if the runtime changes
 
+### Phase 5 — container packaging
+
+- [x] add a Dockerfile for the current service entrypoint
+- [x] add compose wiring for `data/` and `logs/`
+- [x] document the runtime env and required Swiss Ephemeris files
+- [ ] verify the container on the target main server
+
 ---
 
 ## Dependencies

+ 26 - 4
README.md

@@ -2,8 +2,9 @@
 
 Ephemeris MCP is a standalone celestial computation engine exposed through MCP.
 v0.1 ships the core sky-state slice: planetary positions, solar events, lunar
-state, moon phase, sidereal time, constellation lookup, discovery, health,
-and root. Satellite tooling remains planned for later slices.
+state, moon phase, sidereal time, constellation lookup, discovery, and a raw
+sky-state snapshot for downstream chart engines. Satellite tooling remains
+planned for later slices.
 
 The server binds to `0.0.0.0` and the current reachable instance is
 `192.168.0.249:7015`.
@@ -13,6 +14,7 @@ The server binds to `0.0.0.0` and the current reachable instance is
 - A shared sky-state service for time- and location-dependent calculations
 - A source of planetary positions, solar events, lunar state, sidereal time, and constellation lookup
 - A convenience moon-phase tool for quick manual checks
+- A raw sky-state snapshot tool for downstream chart engines
 - A utility layer for downstream consumers that want raw sky data
 
 ## What it is not
@@ -44,6 +46,8 @@ The current design and tool intent are described in:
 - `src/ephemeris_mcp/` - application code
 - `tests/` - automated checks
 - `run.sh` / `restart.sh` / `killserver.sh` - service helpers
+- `Dockerfile` / `docker-compose.yml` - container entry points
+- `.env.example` - optional runtime environment template
 - `tests.sh` - test runner
 - `data/` - local ephemeris/cache data
 - `logs/` - runtime logs
@@ -71,8 +75,8 @@ and a compact relative string:
   "phase_name": "Last Quarter",
   "next_major_phase": {
     "phase_name": "New Moon",
-    "at_utc": "2026-05-16T03:18:00Z",
-    "in_text": "5d 15h 18m"
+    "at_utc": "2026-05-16T20:01:04Z",
+    "in_text": "6d 8h 1m"
   }
 }
 ```
@@ -88,6 +92,24 @@ mcporter --config "$CONFIG" call ephemeris.get_planetary_positions --args '{"dat
 The server is exposed on the LAN at `192.168.0.249:7015` and mounts MCP at
 `/mcp/sse`.
 
+## Astro-client contract
+
+- `get_sky_state` is the preferred raw snapshot for downstream chart engines.
+- It contains planetary positions, lunar state, sidereal time, and solar events.
+- Houses, Placidus, and zodiac-sign placement stay outside this repo.
+
+## Data files
+
+- Chiron requires the Swiss Ephemeris main asteroid file `seas_18.se1` in `./data/`.
+- If that file is missing, `get_planetary_positions` will still return the other bodies, but Chiron will error until the file is present.
+
+## Docker
+
+- Build and run the service with `docker compose up --build`.
+- The compose file mounts `./data` and `./logs` into the container.
+- Keep `seas_18.se1` in `./data` before starting the container if you want Chiron in the response set.
+- The container listens on port `7015` and serves MCP at `/mcp/sse`.
+
 ## Working notes
 
 - Keep the server deterministic and easy to reason about.

File diff ditekan karena terlalu besar
+ 36 - 0
data/seas_18.se1


+ 25 - 0
docker-compose.yml

@@ -0,0 +1,25 @@
+services:
+  ephemeris-mcp:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    image: ephemeris-mcp:latest
+    container_name: ephemeris-mcp
+    restart: unless-stopped
+    ports:
+      - "7015:7015"
+    environment:
+      EPHEMERIS_HOST: 0.0.0.0
+      EPHEMERIS_PORT: 7015
+      EPHEMERIS_DATA_DIR: /app/data
+      EPHEMERIS_LOG_DIR: /app/logs
+      EPHEMERIS_DB_PATH: /app/data/ephemeris.sqlite3
+    volumes:
+      - ./data:/app/data
+      - ./logs:/app/logs
+    healthcheck:
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7015/health').read()"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+      start_period: 20s

+ 75 - 33
src/ephemeris_mcp/ephemeris.py

@@ -78,7 +78,7 @@ def get_planetary_positions(
     lat: float = 0.0,
     lon: float = 0.0,
     elevation: float = 0.0,
-    geocentric: bool = False,
+    geocentric: bool = True,
 ) -> list[dict]:
     """
     Compute positions of all major bodies for a given Julian Day.
@@ -96,23 +96,15 @@ def get_planetary_positions(
     """
     _ensure_initialized()
 
-    flags = swe.FLG_SWIEPH
+    flags = swe.FLG_SWIEPH | swe.FLG_SPEED
     if not geocentric:
+        swe.set_topo(lon, lat, elevation)
         flags |= swe.FLG_TOPOCTR
-        # Approximate light-time correction
-        flags |= swe.FLG_SPEED
 
     results = []
     for name, body_id in BODIES.items():
         try:
-            if geocentric:
-                xx = swe.calc_ut(jd, body_id, flags)
-            else:
-                xx = swe.calc_ut(jd, body_id, flags)
-                # For topocentric, we'd use swe.calc_ut with TOPOCTR flag
-                # and pass lat/lon/elevation via swe.set_topo()
-
-            # xx[0] = [longitude, latitude, distance, speed_long, speed_lat, speed_dist]
+            xx = swe.calc_ut(jd, body_id, flags)
             pos = xx[0]
 
             # Convert to equatorial
@@ -290,7 +282,7 @@ def get_lunar_state(
         set_jd = None
 
     phase_name = _phase_name_from_age(age_days)
-    next_major_phase = _next_major_phase_from_age_and_jd(age_days, jd)
+    next_major_phase = _next_major_phase_from_jd(jd)
 
     return {
         "phase_name": phase_name,
@@ -478,33 +470,34 @@ def _phase_name_from_age(age_days: float) -> str:
     return "Waning Crescent"
 
 
-def _next_major_phase_from_age_and_jd(age_days: float, jd: float) -> dict:
-    """Return the next major lunar phase and its timing."""
-    age = age_days % SYNODIC_MONTH_DAYS
+def _next_major_phase_from_jd(jd: float) -> dict:
+    """Return the next major lunar phase and its timing.
+
+    The phase time is refined with a binary search on the Sun-Moon elongation
+    so the result stays close to calendar-grade lunar tables.
+    """
+    current_angle = _moon_sun_elongation(jd)
     targets = (
-        ("First Quarter", SYNODIC_MONTH_DAYS / 4.0),
-        ("Full Moon", SYNODIC_MONTH_DAYS / 2.0),
-        ("Last Quarter", SYNODIC_MONTH_DAYS * 3.0 / 4.0),
-        ("New Moon", SYNODIC_MONTH_DAYS),
+        ("New Moon", 360.0),
+        ("First Quarter", 90.0),
+        ("Full Moon", 180.0),
+        ("Last Quarter", 270.0),
     )
 
-    next_name = None
-    next_age = None
-    delta_days = None
-    for name, target_age in targets:
-        if target_age > age:
+    next_name = "New Moon"
+    target_angle = 0.0
+    delta_deg = (360.0 - current_angle) % 360.0
+    for name, candidate_angle in targets:
+        if candidate_angle > current_angle:
             next_name = name
-            next_age = target_age
-            delta_days = target_age - age
+            target_angle = 0.0 if candidate_angle == 360.0 else candidate_angle
+            delta_deg = candidate_angle - current_angle
             break
 
-    if next_name is None or next_age is None or delta_days is None:
-        next_name = "New Moon"
-        next_age = SYNODIC_MONTH_DAYS
-        delta_days = SYNODIC_MONTH_DAYS - age
-
+    estimated_jd = jd + (delta_deg / 360.0) * SYNODIC_MONTH_DAYS
+    at_jd = _refine_phase_transition(jd, target_angle, estimated_jd)
     now_dt = _jd_to_utc_datetime(jd)
-    at_dt = now_dt + timedelta(days=delta_days)
+    at_dt = _jd_to_utc_datetime(at_jd)
     return {
         "phase_name": next_name,
         "at_utc": _utc_datetime_to_iso(at_dt),
@@ -512,6 +505,55 @@ def _next_major_phase_from_age_and_jd(age_days: float, jd: float) -> dict:
     }
 
 
+def _moon_sun_elongation(jd: float) -> float:
+    """Return the geocentric Moon-Sun elongation in degrees."""
+    sun_lon = swe.calc_ut(jd, swe.SUN, swe.FLG_SWIEPH)[0][0]
+    moon_lon = swe.calc_ut(jd, swe.MOON, swe.FLG_SWIEPH)[0][0]
+    return _normalize_degrees(moon_lon - sun_lon)
+
+
+def _phase_delta(angle: float, target: float) -> float:
+    """Return signed angular distance from target in the range [-180, 180)."""
+    return ((angle - target + 180.0) % 360.0) - 180.0
+
+
+def _refine_phase_transition(start_jd: float, target_angle: float, estimated_jd: float) -> float:
+    """Refine a lunar phase transition time with a bounded binary search."""
+    lower = max(start_jd, estimated_jd - 2.0)
+    upper = estimated_jd + 2.0
+
+    lower_delta = _phase_delta(_moon_sun_elongation(lower), target_angle)
+    upper_delta = _phase_delta(_moon_sun_elongation(upper), target_angle)
+
+    for _ in range(10):
+        if lower_delta == 0.0:
+            return lower
+        if upper_delta == 0.0:
+            return upper
+        if lower_delta < 0.0 < upper_delta or upper_delta < 0.0 < lower_delta:
+            break
+        lower = max(start_jd, lower - 2.0)
+        upper += 2.0
+        lower_delta = _phase_delta(_moon_sun_elongation(lower), target_angle)
+        upper_delta = _phase_delta(_moon_sun_elongation(upper), target_angle)
+    else:
+        return estimated_jd
+
+    for _ in range(60):
+        mid = (lower + upper) / 2.0
+        mid_delta = _phase_delta(_moon_sun_elongation(mid), target_angle)
+        if abs(mid_delta) < 1e-6:
+            return mid
+        if lower_delta < 0.0 < mid_delta or mid_delta < 0.0 < lower_delta:
+            upper = mid
+            upper_delta = mid_delta
+        else:
+            lower = mid
+            lower_delta = mid_delta
+
+    return (lower + upper) / 2.0
+
+
 def _ecl_to_equ(lambda_ecl: float, beta_ecl: float, jd: float) -> dict:
     """
     Convert ecliptic coordinates to equatorial (RA/dec) for a given epoch.

+ 131 - 6
src/ephemeris_mcp/server.py

@@ -69,6 +69,17 @@ def _parse_date(date_str: str) -> float:
     return julday(dt.year, dt.month, dt.day, 12.0)
 
 
+def _utc_date_from_datetime(dt_str: str | None) -> str:
+    """Convert an ISO datetime string to a UTC date string."""
+    if dt_str is None:
+        return __import__("datetime").datetime.now(timezone.utc).date().isoformat()
+
+    dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
+    if dt.tzinfo is None:
+        dt = dt.replace(tzinfo=timezone.utc)
+    return dt.astimezone(timezone.utc).date().isoformat()
+
+
 def _default_location(lat: float | None = None, lon: float | None = None) -> tuple[float, float]:
     resolved_lat = getattr(config, "DEFAULT_LAT", None) if lat is None else lat
     resolved_lon = getattr(config, "DEFAULT_LON", None) if lon is None else lon
@@ -84,6 +95,7 @@ def _tool_names() -> list[str]:
         "get_sidereal_time",
         "get_constellation_at_ecliptic",
         "list_available_bodies",
+        "get_sky_state",
     ]
 
 
@@ -95,7 +107,7 @@ def get_planetary_positions(
     lat: float | None = None,
     lon: float | None = None,
     elevation: float = 0.0,
-    geocentric: bool = False,
+    geocentric: bool = True,
 ) -> dict:
     """
     Get positions of all major solar system bodies.
@@ -113,23 +125,50 @@ def get_planetary_positions(
     lat, lon = _default_location(lat, lon)
     jd = _parse_datetime(datetime)
 
-    ck = cache_key("get_planetary_positions", datetime=datetime or "now", lat=lat, lon=lon, geocentric=geocentric)
+    ck = cache_key(
+        "get_planetary_positions",
+        cache_version=3,
+        datetime=datetime or "now",
+        elevation=elevation,
+        geocentric=geocentric,
+        lat=lat,
+        lon=lon,
+    )
     cached = cache.get(ck)
     if cached:
         logger.info(f"cache hit: {ck}")
-        return {"input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric}, **cached}
+        return {
+            "input": {
+                "datetime": datetime,
+                "lat": lat,
+                "lon": lon,
+                "elevation": elevation,
+                "geocentric": geocentric,
+            },
+            **cached,
+        }
 
     positions = _compute_planetary_positions(jd, lat, lon, elevation, geocentric)
 
     result = {
-        "input": {"datetime": datetime, "lat": lat, "lon": lon, "geocentric": geocentric},
+        "input": {
+            "datetime": datetime,
+            "lat": lat,
+            "lon": lon,
+            "elevation": elevation,
+            "geocentric": geocentric,
+        },
         "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
         "julian_day": round(jd, 6),
         "bodies": positions,
     }
     cache.set(
         ck,
-        {"timestamp_utc": result["timestamp_utc"], "julian_day": result["julian_day"], "bodies": positions},
+        {
+            "timestamp_utc": result["timestamp_utc"],
+            "julian_day": result["julian_day"],
+            "bodies": positions,
+        },
         config.CACHE_PLANETARY,
     )
     return result
@@ -196,7 +235,7 @@ def get_lunar_state(
     lat, lon = _default_location(lat, lon)
     jd = _parse_datetime(datetime)
 
-    ck = cache_key("get_lunar_state", datetime=datetime or "now", lat=lat, lon=lon)
+    ck = cache_key("get_lunar_state", cache_version=3, datetime=datetime or "now", lat=lat, lon=lon)
     cached = cache.get(ck)
     cached_next_major_phase = cached.get("lunar_state", {}).get("next_major_phase") if cached else None
     if cached_next_major_phase and cached_next_major_phase.get("phase_name") and cached_next_major_phase.get("at_utc") and cached_next_major_phase.get("in_text"):
@@ -327,10 +366,96 @@ def get_moon_phase(datetime: str | None = None, lat: float | None = None, lon: f
     }
 
 
+@mcp.tool()
+def get_sky_state(
+    datetime: str | None = None,
+    lat: float | None = None,
+    lon: float | None = None,
+    elevation: float = 0.0,
+    geocentric: bool = True,
+) -> dict:
+    """
+    Return a consolidated raw sky-state snapshot for downstream chart engines.
+
+    This stays purely descriptive: it combines the core astronomical outputs
+    already exposed elsewhere in the server without adding signs, houses, or
+    interpretation.
+    """
+    lat, lon = _default_location(lat, lon)
+    date = _utc_date_from_datetime(datetime)
+    jd = _parse_datetime(datetime)
+
+    ck = cache_key(
+        "get_sky_state",
+        cache_version=1,
+        datetime=datetime or "now",
+        date=date,
+        elevation=elevation,
+        geocentric=geocentric,
+        lat=lat,
+        lon=lon,
+    )
+    cached = cache.get(ck)
+    if cached:
+        return {
+            "input": {
+                "datetime": datetime,
+                "date": date,
+                "lat": lat,
+                "lon": lon,
+                "elevation": elevation,
+                "geocentric": geocentric,
+            },
+            **cached,
+        }
+
+    planetary_positions = get_planetary_positions(
+        datetime=datetime,
+        lat=lat,
+        lon=lon,
+        elevation=elevation,
+        geocentric=geocentric,
+    )
+    lunar_state = get_lunar_state(datetime=datetime, lat=lat, lon=lon)
+    sidereal_time = get_sidereal_time(datetime=datetime, lat=lat, lon=lon)
+    solar_events = get_solar_events(date=date, lat=lat, lon=lon)
+
+    result = {
+        "input": {
+            "datetime": datetime,
+            "date": date,
+            "lat": lat,
+            "lon": lon,
+            "elevation": elevation,
+            "geocentric": geocentric,
+        },
+        "timestamp_utc": datetime or __import__("datetime").datetime.now(timezone.utc).isoformat(),
+        "julian_day": round(jd, 6),
+        "planetary_positions": planetary_positions,
+        "solar_events": solar_events,
+        "lunar_state": lunar_state,
+        "sidereal_time": sidereal_time,
+    }
+    cache.set(
+        ck,
+        {
+            "timestamp_utc": result["timestamp_utc"],
+            "julian_day": result["julian_day"],
+            "planetary_positions": planetary_positions,
+            "solar_events": solar_events,
+            "lunar_state": lunar_state,
+            "sidereal_time": sidereal_time,
+        },
+        config.CACHE_SKY,
+    )
+    return result
+
+
 def create_app() -> FastAPI:
     """
     Build the FastAPI app for the v0.1 core slice.
     """
+    config.LOG_DIR.mkdir(parents=True, exist_ok=True)
     logging.basicConfig(
         filename=str(config.LOG_DIR / "server.log"),
         level=logging.INFO,

+ 17 - 2
tests/test_basic.py

@@ -11,6 +11,7 @@ from src.ephemeris_mcp.server import (
     get_moon_phase,
     get_lunar_state,
     get_planetary_positions,
+    get_sky_state,
     get_sidereal_time,
     get_solar_events,
     list_available_bodies,
@@ -55,6 +56,7 @@ def test_root_lists_core_tools() -> None:
         "get_sidereal_time",
         "get_constellation_at_ecliptic",
         "list_available_bodies",
+        "get_sky_state",
     ]
     assert data["mcp"] == {"sse": "/mcp/sse", "messages": "/mcp/messages"}
 
@@ -70,18 +72,31 @@ def test_list_available_bodies_shape() -> None:
 
 def test_tool_shapes_are_present() -> None:
     body_positions = get_planetary_positions(datetime="2026-05-10T12:00:00Z")
+    topocentric_positions = get_planetary_positions(
+        datetime="2026-05-10T12:00:00Z",
+        geocentric=False,
+        lat=47.0,
+        lon=8.0,
+        elevation=500.0,
+    )
     solar_events = get_solar_events(date="2026-05-10", lat=47.0, lon=8.0)
     lunar_state = get_lunar_state(datetime="2026-05-10T12:00:00Z")
     sidereal_time = get_sidereal_time(datetime="2026-05-10T12:00:00Z")
     constellation = get_constellation_at_ecliptic(120.0)
+    sky_state = get_sky_state(datetime="2026-05-10T12:00:00Z", lat=47.0, lon=8.0, elevation=500.0)
 
     assert body_positions["input"]["datetime"] == "2026-05-10T12:00:00Z"
     assert "bodies" in body_positions
+    assert sum(1 for body in body_positions["bodies"] if "error" in body) <= 1
+    assert sum(1 for body in topocentric_positions["bodies"] if "error" in body) <= 1
     assert solar_events["input"]["date"] == "2026-05-10"
     assert "events_jd" in solar_events
     assert "lunar_state" in lunar_state
     assert "greenwich_sidereal_time" in sidereal_time
     assert constellation["input"]["ecliptic_lon"] == 120.0
+    assert "planetary_positions" in sky_state
+    assert "solar_events" in sky_state
+    assert "sidereal_time" in sky_state
 
 
 def test_moon_phase_tool_shape() -> None:
@@ -90,8 +105,8 @@ def test_moon_phase_tool_shape() -> None:
     assert 0.0 <= moon_phase["illumination_fraction"] <= 1.0
     assert moon_phase["input"]["datetime"] == "2026-05-10T12:00:00Z"
     assert moon_phase["phase_name"] == "Last Quarter"
-    assert moon_phase["next_major_phase"]["phase_name"] in {"New Moon", "First Quarter", "Full Moon", "Last Quarter"}
-    assert "at_utc" in moon_phase["next_major_phase"]
+    assert moon_phase["next_major_phase"]["phase_name"] == "New Moon"
+    assert moon_phase["next_major_phase"]["at_utc"] == "2026-05-16T20:01:04Z"
     assert moon_phase["next_major_phase"]["in_text"]
     assert "d" in moon_phase["next_major_phase"]["in_text"] or "h" in moon_phase["next_major_phase"]["in_text"] or "m" in moon_phase["next_major_phase"]["in_text"]
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini