Просмотр исходного кода

Add birthplace to person model + standalone composite chart tool

Person model:
- Added birthplace TEXT column to persons schema (nullable, non-destructive)
- add_person, person_manage tool: birthplace parameter
- update_person: birthplace field in dynamic update
- Storage test: verify birthplace round-trip

Composite chart:
- New calculate_composite_chart MCP tool (standalone, not just in synastry)
- Midpoint method for planetary positions
- Composite location = midpoint of birth locations
- Full chart output: planets in signs/houses, aspects, angles
- Added to _tool_names() and README

Tests: 100 passing (1 new composite chart test, birthplace in storage test, server test updated)
Lukas Goldschmidt 1 месяц назад
Родитель
Сommit
bcef4139cf
7 измененных файлов с 162 добавлено и 19 удалено
  1. 3 2
      README.md
  2. 1 0
      src/astro_mcp/server.py
  3. 11 15
      src/astro_mcp/storage.py
  4. 116 0
      src/astro_mcp/tools.py
  5. 1 0
      tests/test_server.py
  6. 2 2
      tests/test_storage.py
  7. 28 0
      tests/test_tools.py

+ 3 - 2
README.md

@@ -33,10 +33,11 @@ SSE transport at `http://localhost:7016/mcp/sse`
 |---|---|
 | `get_planetary_positions` | Planetary positions with zodiac signs, degrees, retrograde flags |
 | `calculate_natal_chart` | Complete natal chart (planets, houses, aspects, angles) |
-| `calculate_transit_chart` | Transit chart with natal-to-transit aspects |
+| `calculate_transit_chart` | Transit chart with natal-to-transit aspects + transit location |
 | `calculate_synastry_chart` | Relationship chart (interaspects, overlays, composite, davison) |
+| `calculate_composite_chart` | Composite chart via midpoint method (planets, houses, aspects, angles) |
 | `get_transit_preview` | Significant transit events for a person over a time range |
-| `person_manage` | CRUD operations for the persons database |
+| `person_manage` | CRUD for persons database (name, nickname, birth datetime/location/place, lat/lon) |
 | `list_house_systems` | List supported house systems |
 
 ## Dashboard

+ 1 - 0
src/astro_mcp/server.py

@@ -27,6 +27,7 @@ def _tool_names() -> list[str]:
         "calculate_natal_chart",
         "calculate_transit_chart",
         "calculate_synastry_chart",
+        "calculate_composite_chart",
         "get_transit_preview",
         "person_manage",
         "list_house_systems",

+ 11 - 15
src/astro_mcp/storage.py

@@ -42,6 +42,7 @@ def _init_db_sync() -> None:
             name        TEXT NOT NULL,
             nickname    TEXT UNIQUE,
             birth_datetime  TEXT NOT NULL,
+            birthplace  TEXT,
             latitude    REAL NOT NULL,
             longitude   REAL NOT NULL,
             elevation   REAL DEFAULT 0.0,
@@ -82,12 +83,9 @@ async def add_person(
     longitude: float,
     elevation: float = 0.0,
     nickname: str | None = None,
+    birthplace: str | None = None,
 ) -> dict[str, Any]:
-    """Add a new person to the database.
-
-    Returns:
-        The created person dict.
-    """
+    """Add a new person to the database."""
     await init_db()
     person_id = str(uuid.uuid4())[:8]
     now = datetime.now(timezone.utc).isoformat()
@@ -95,9 +93,9 @@ async def add_person(
     def _insert():
         conn = _get_conn()
         conn.execute(
-            """INSERT INTO persons (id, name, nickname, birth_datetime, latitude, longitude, elevation, created_at)
-               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
-            (person_id, name, nickname, birth_datetime, latitude, longitude, elevation, now),
+            """INSERT INTO persons (id, name, nickname, birth_datetime, birthplace, latitude, longitude, elevation, created_at)
+               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+            (person_id, name, nickname, birth_datetime, birthplace, latitude, longitude, elevation, now),
         )
         conn.commit()
         row = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
@@ -162,27 +160,22 @@ async def update_person(
     name: str | None = None,
     nickname: str | None = None,
     birth_datetime: str | None = None,
+    birthplace: str | None = None,
     latitude: float | None = None,
     longitude: float | None = None,
     elevation: float | None = None,
 ) -> dict[str, Any] | None:
-    """Update a person's data. Only provided fields are updated.
-
-    Returns:
-        Updated person dict, or None if not found.
-    """
+    """Update a person's data. Only provided fields are updated."""
     await init_db()
     now = datetime.now(timezone.utc).isoformat()
 
     def _update():
         conn = _get_conn()
-        # Check existence
         existing = conn.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
         if not existing:
             conn.close()
             return None
 
-        # Build dynamic update
         fields = []
         values = []
         if name is not None:
@@ -194,6 +187,9 @@ async def update_person(
         if birth_datetime is not None:
             fields.append("birth_datetime = ?")
             values.append(birth_datetime)
+        if birthplace is not None:
+            fields.append("birthplace = ?")
+            values.append(birthplace)
         if latitude is not None:
             fields.append("latitude = ?")
             values.append(latitude)

+ 116 - 0
src/astro_mcp/tools.py

@@ -708,6 +708,7 @@ async def person_manage(
     name: str | None = None,
     nickname: str | None = None,
     birth_datetime: str | None = None,
+    birthplace: str | None = None,
     latitude: float | None = None,
     longitude: float | None = None,
     elevation: float | None = None,
@@ -720,6 +721,7 @@ async def person_manage(
         name: Person's full name (required for add).
         nickname: Optional short name for quick lookup.
         birth_datetime: ISO 8601 UTC datetime (required for add).
+        birthplace: Optional birth place name (e.g., "Zurich, Switzerland").
         latitude: Birth latitude (required for add).
         longitude: Birth longitude (required for add).
         elevation: Birth elevation in meters.
@@ -741,6 +743,7 @@ async def person_manage(
             longitude=longitude,
             elevation=elevation if elevation is not None else 0.0,
             nickname=nickname,
+            birthplace=birthplace,
         )
         return {"action": "add", "person": person}
 
@@ -764,6 +767,7 @@ async def person_manage(
             name=name,
             nickname=nickname,
             birth_datetime=birth_datetime,
+            birthplace=birthplace,
             latitude=latitude,
             longitude=longitude,
             elevation=elevation,
@@ -784,6 +788,118 @@ async def person_manage(
         return {"error": f"unknown action: {action}. Use: add, get, list, update, delete"}
 
 
+# ── Tool: calculate_composite_chart ──────────────────────────────────
+
+@mcp.tool()
+async def calculate_composite_chart(
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+) -> dict[str, Any]:
+    """Calculate a composite chart (midpoint method) for two people.
+
+    The composite chart represents the relationship itself as a single chart,
+    calculated by taking the midpoint of each pair of planetary positions.
+
+    Args:
+        person1_datetime, person1_latitude, person1_longitude: Person 1 birth data.
+        person2_datetime, person2_latitude, person2_longitude: Person 2 birth data.
+        elevation: Birth elevation in meters.
+        house_system: House system (default: Placidus).
+        orb_limits: Optional orb configuration.
+
+    Returns:
+        Composite chart with planets, houses, aspects, and angles.
+    """
+    sky1 = await call_sky_state(datetime=person1_datetime, lat=person1_latitude, lon=person1_longitude, elevation=elevation)
+    sky2 = await call_sky_state(datetime=person2_datetime, lat=person2_latitude, lon=person2_longitude, elevation=elevation)
+
+    if "error" in sky1:
+        return {"error": f"person1: {sky1['error']}"}
+    if "error" in sky2:
+        return {"error": f"person2: {sky2['error']}"}
+
+    bodies1 = extract_bodies(sky1)
+    bodies2 = extract_bodies(sky2)
+
+    # Composite planets via midpoint method
+    composite_bodies = astrology.compute_composite_chart(
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies1],
+        [{"name": b["body"], "lon": b.get("ecliptic_lon", 0.0)} for b in bodies2],
+    )
+
+    # Composite location: midpoint of birth locations
+    comp_lat = (person1_latitude + person2_latitude) / 2
+    comp_lon = (person1_longitude + person2_longitude) / 2
+
+    # Use composite datetime for house calculation
+    davison = astrology.compute_davison_chart(0.0, 0.0, person1_datetime, person2_datetime)
+
+    # Get composite sky state for houses and angles
+    # Use a date near the midpoint for house calculation
+    comp_sky = await call_sky_state(
+        datetime=person1_datetime, lat=comp_lat, lon=comp_lon, elevation=elevation,
+    )
+    if "error" in comp_sky:
+        return {"error": f"composite ephemeris error: {comp_sky['error']}"}
+
+    sidereal = comp_sky.get("sidereal_time", {})
+    lst_hours = sidereal.get("local_sidereal_time", 0.0)
+    houses = astrology.calculate_houses(lst_hours, comp_lat, house_system)
+
+    # Build composite planet list with house placement
+    composite_planets = []
+    for cb in composite_bodies:
+        ecl_lon = cb["lon"]
+        zodiac = astrology.ecliptic_to_zodiac(ecl_lon)
+        house = astrology.get_house_placement(ecl_lon, houses)
+        composite_planets.append({
+            "body": cb["name"],
+            "sign": zodiac["sign"],
+            "sign_abbreviation": zodiac["abbreviation"],
+            "degree_within_sign": zodiac["degree"],
+            "absolute_lon": zodiac["absolute_lon"],
+            "house": house,
+        })
+
+    # Aspects
+    aspect_bodies = [{"name": p["body"], "lon": p["absolute_lon"]} for p in composite_planets]
+    aspects = astrology.compute_aspects(aspect_bodies, orb_limits)
+    formatted_aspects = []
+    for asp in aspects:
+        formatted_aspects.append({
+            "body1": asp["body1"],
+            "body2": asp["body2"],
+            "aspect": asp["aspect"],
+            "orb": asp["orb"],
+            "applying": asp["applying"],
+            "exactness": asp["exactness"],
+        })
+
+    # Angles
+    angles = astrology.calculate_angles(lst_hours, comp_lat)
+
+    return {
+        "input": {
+            "person1": {"datetime": person1_datetime, "latitude": person1_latitude, "longitude": person1_longitude},
+            "person2": {"datetime": person2_datetime, "latitude": person2_latitude, "longitude": person2_longitude},
+            "house_system": house_system,
+        },
+        "chart_type": "composite",
+        "planets": composite_planets,
+        "houses": houses,
+        "aspects": formatted_aspects,
+        "angles": angles,
+        "composite_location": {"latitude": comp_lat, "longitude": comp_lon},
+    }
+
+
 # ── Tool: list_house_systems ─────────────────────────────────────────
 
 @mcp.tool()

+ 1 - 0
tests/test_server.py

@@ -25,6 +25,7 @@ def test_root_lists_tools() -> None:
         "calculate_natal_chart",
         "calculate_transit_chart",
         "calculate_synastry_chart",
+        "calculate_composite_chart",
         "get_transit_preview",
         "person_manage",
         "list_house_systems",

+ 2 - 2
tests/test_storage.py

@@ -5,8 +5,6 @@ Tests for the person storage layer.
 from __future__ import annotations
 
 import asyncio
-import os
-import tempfile
 from pathlib import Path
 
 import pytest
@@ -31,12 +29,14 @@ class TestAddPerson:
             birth_datetime="2000-01-01T12:00:00Z",
             latitude=47.0,
             longitude=8.0,
+            birthplace="Zurich, Switzerland",
         ))
         assert result["name"] == "Test Person"
         assert result["birth_datetime"] == "2000-01-01T12:00:00Z"
         assert result["latitude"] == 47.0
         assert result["longitude"] == 8.0
         assert result["elevation"] == 0.0
+        assert result["birthplace"] == "Zurich, Switzerland"
         assert "id" in result
         assert "created_at" in result
 

+ 28 - 0
tests/test_tools.py

@@ -290,6 +290,34 @@ class TestCalculateSynastryChart:
         assert "planets" in result["composite_chart"]
 
 
+# ── Tests: calculate_composite_chart ─────────────────────────────────
+
+class TestCalculateCompositeChart:
+    def test_composite_chart(self):
+        from src.astro_mcp import tools
+        with patch("src.astro_mcp.tools.call_sky_state", new_callable=AsyncMock) as mock:
+            mock.return_value = make_mock_sky_state()
+            import asyncio
+            result = asyncio.run(
+                tools.calculate_composite_chart(
+                    person1_datetime="2000-01-01T12:00:00Z",
+                    person1_latitude=47.0,
+                    person1_longitude=8.0,
+                    person2_datetime="1995-06-15T08:00:00Z",
+                    person2_latitude=52.0,
+                    person2_longitude=13.0,
+                )
+            )
+        assert result["chart_type"] == "composite"
+        assert "planets" in result
+        assert "houses" in result
+        assert "aspects" in result
+        assert "angles" in result
+        assert "composite_location" in result
+        assert len(result["planets"]) > 0
+        assert len(result["houses"]) == 12
+
+
 # ── Tests: list_house_systems ───────────────────────────────────────
 
 class TestListHouseSystems: