mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 13:34:38 +00:00
Merge pull request #8 from migatu/feat/engine-houses
Silnik: osie (Asc/MC) i systemy domow (LOG-05)
This commit is contained in:
@@ -6,7 +6,7 @@ i nie w bazie.
|
||||
|
||||
## API
|
||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||
- `POST /chart/positions` → `{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01)
|
||||
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
||||
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
|
||||
|
||||
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
|
||||
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
|
||||
pozycje.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine.base import EphemerisEngine
|
||||
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
|
||||
def _fmt(name: str, lon: float) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"sign": SIGNS[sign_index(lon)],
|
||||
"in_sign": in_sign(lon),
|
||||
"decimal": round(norm360(lon), 6),
|
||||
}
|
||||
|
||||
|
||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
|
||||
positions = engine.positions(moment)
|
||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
|
||||
if not hasattr(engine, "sidereal"):
|
||||
return result
|
||||
|
||||
ramc, eps = engine.sidereal(moment)
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system)
|
||||
|
||||
result["house_system"] = system
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc),
|
||||
"MC": _fmt("MC", mc),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||
}
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
|
||||
return result
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Osie i domy — czysta matematyka sferyczna (LOG-05).
|
||||
|
||||
Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki
|
||||
(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów
|
||||
dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika —
|
||||
silnik dostarcza tylko RAMC i ε.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||
|
||||
WHOLE_SIGN = "whole_sign"
|
||||
EQUAL = "equal"
|
||||
PORPHYRY = "porphyry"
|
||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||
|
||||
|
||||
def mean_obliquity(tt_jd: float) -> float:
|
||||
"""Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów."""
|
||||
t = (tt_jd - 2451545.0) / 36525.0
|
||||
arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3
|
||||
return arcsec / 3600.0
|
||||
|
||||
|
||||
def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
||||
r, e = math.radians(ramc_deg), math.radians(eps_deg)
|
||||
mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e))
|
||||
return norm360(math.degrees(mc))
|
||||
|
||||
|
||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||
asc = math.atan2(
|
||||
math.cos(r),
|
||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||
)
|
||||
return norm360(math.degrees(asc))
|
||||
|
||||
|
||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||
"""Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części."""
|
||||
span = (b - a) % 360.0
|
||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||
|
||||
|
||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||
if system == WHOLE_SIGN:
|
||||
start = sign_index(asc) * 30.0
|
||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||
if system == EQUAL:
|
||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||
if system == PORPHYRY:
|
||||
dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0)
|
||||
c = [0.0] * 12
|
||||
c[0], c[3], c[6], c[9] = asc, ic, dsc, mc
|
||||
c[1], c[2] = _trisect(asc, ic) # domy 2,3
|
||||
c[4], c[5] = _trisect(ic, dsc) # domy 5,6
|
||||
c[7], c[8] = _trisect(dsc, mc) # domy 8,9
|
||||
c[10], c[11] = _trisect(mc, asc) # domy 11,12
|
||||
return c
|
||||
raise ValueError(f"nieznany system domów: {system}")
|
||||
|
||||
|
||||
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
||||
lon = norm360(lon)
|
||||
for i in range(12):
|
||||
start = cusp_list[i]
|
||||
end = cusp_list[(i + 1) % 12]
|
||||
span = (end - start) % 360.0
|
||||
offset = (lon - start) % 360.0
|
||||
if offset < span:
|
||||
return i + 1
|
||||
return 12
|
||||
@@ -88,5 +88,17 @@ class SkyfieldEngine(EphemerisEngine):
|
||||
)
|
||||
return out
|
||||
|
||||
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
|
||||
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
|
||||
|
||||
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||
"""
|
||||
from app.engine.houses import mean_obliquity
|
||||
|
||||
t = self.ts.from_datetime(moment.when_utc)
|
||||
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||
eps = mean_obliquity(t.tt)
|
||||
return ramc, eps
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||
|
||||
@@ -37,6 +37,7 @@ class PositionsRequest(BaseModel):
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
|
||||
|
||||
@app.post("/api/query", response_model=QueryResponse)
|
||||
@@ -49,13 +50,13 @@ def query(req: QueryRequest) -> QueryResponse:
|
||||
|
||||
@app.post("/chart/positions")
|
||||
def chart_positions(req: PositionsRequest) -> dict:
|
||||
"""Pozycje obiektów dla danego momentu (LOG-01), liczone aktywnym silnikiem."""
|
||||
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05), aktywnym silnikiem."""
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
positions = engine.positions(moment, req.objects)
|
||||
return {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
return build_chart(engine, moment, req.house_system)
|
||||
|
||||
|
||||
@app.post("/chart/compare")
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05).
|
||||
|
||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||
"""
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
|
||||
def test_chart_angles_match_reference(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
assert chart["angles"]["Asc"]["sign"] == "Cancer"
|
||||
assert chart["angles"]["MC"]["sign"] == "Pisces"
|
||||
|
||||
|
||||
def test_chart_house_assignments_match_reference(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
by = {p["name"]: p for p in chart["positions"]}
|
||||
expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5,
|
||||
"Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5}
|
||||
for name, house in expected.items():
|
||||
assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}"
|
||||
|
||||
|
||||
def test_house_systems_available(own_engine, reference_moment):
|
||||
for system in ("whole_sign", "equal", "porphyry"):
|
||||
chart = build_chart(own_engine, reference_moment, system)
|
||||
assert chart["house_system"] == system
|
||||
assert len(chart["cusps"]) == 12
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||
from app.engine import houses as H
|
||||
|
||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
|
||||
|
||||
|
||||
def _near(a, b, tol=0.05):
|
||||
return abs(((a - b + 180) % 360) - 180) < tol
|
||||
|
||||
|
||||
def test_asc_mc_match_reference():
|
||||
asc = H.compute_asc(RAMC, EPS, LAT)
|
||||
mc = H.compute_mc(RAMC, EPS)
|
||||
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
|
||||
assert _near(mc, 352.59) # Pisces 22°35'
|
||||
|
||||
|
||||
def test_whole_sign_starts_on_sign_boundary():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
|
||||
assert cusps[0] == 90.0 # dom 1 = 0° Raka
|
||||
assert cusps[1] == 120.0
|
||||
|
||||
|
||||
def test_equal_cusps_are_30_apart_from_asc():
|
||||
cusps = H.cusps(112.18, 352.59, H.EQUAL)
|
||||
assert abs(cusps[0] - 112.18) < 1e-9
|
||||
assert abs(cusps[1] - 142.18) < 1e-9
|
||||
|
||||
|
||||
def test_porphyry_angles_on_cusps():
|
||||
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
|
||||
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
|
||||
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
|
||||
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
|
||||
|
||||
|
||||
def test_assign_house_whole_sign():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||
@@ -24,10 +24,21 @@ class LogicClient:
|
||||
return r.json()
|
||||
|
||||
def positions(
|
||||
self, when_utc_iso: str, lat: float, lon: float, objects: list[str] | None = None
|
||||
self,
|
||||
when_utc_iso: str,
|
||||
lat: float,
|
||||
lon: float,
|
||||
objects: list[str] | None = None,
|
||||
house_system: str = "whole_sign",
|
||||
) -> dict[str, Any]:
|
||||
"""Pozycje obiektów dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "objects": objects}
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
"when_utc": when_utc_iso,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"objects": objects,
|
||||
"house_system": house_system,
|
||||
}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||
r.raise_for_status()
|
||||
|
||||
@@ -59,13 +59,17 @@ def chart_compute(
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system
|
||||
)
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block nav_chart %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wpisz podstawowe dane momentu — program policzy pozycje obiektów (silnik efemeryd warstwy logicznej).</p>
|
||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||
|
||||
<form method="post" action="/">
|
||||
<div class="grid">
|
||||
@@ -17,20 +17,25 @@
|
||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
<details class="loc">
|
||||
<summary>Lokalizacja (opcjonalnie — przyda się później do osi i domów)</summary>
|
||||
<div class="grid">
|
||||
<label>Szerokość (lat)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon, + na wschód)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="grid">
|
||||
<label>Szerokość (lat, + N)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Policz pozycje</button>
|
||||
<button type="submit">Policz horoskop</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -42,12 +47,26 @@
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||
obiektów: {{ result.positions | length }}
|
||||
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||
{% set a = result.angles[key] %}
|
||||
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Absolutna</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -56,13 +75,27 @@
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.sign }}</td>
|
||||
<td class="mono">{{ p.in_sign }}</td>
|
||||
<td class="mono">{{ p.absolute }}</td>
|
||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
<table>
|
||||
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in result.cusps %}
|
||||
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
|
||||
Reference in New Issue
Block a user