diff --git a/services/logic/README.md b/services/logic/README.md index 45c252b..2eb9c54 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -6,7 +6,7 @@ i nie w bazie. ## API - `POST /api/query` → `QueryRequest` → `QueryResponse` -- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne (LOG-06). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. +- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów) - `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B) - `GET /health` (sprawdza też warstwę bazodanową) diff --git a/services/logic/app/engine/aspects.py b/services/logic/app/engine/aspects.py index 354a0d6..9950f6e 100644 --- a/services/logic/app/engine/aspects.py +++ b/services/logic/app/engine/aspects.py @@ -34,10 +34,29 @@ def separation(a: float, b: float) -> float: return min(d, 360.0 - d) +def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None: + """Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)? + + Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu + (pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying. + dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności. + Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć). + """ + if sa is None or sb is None: + return None + dev_now = abs(separation(la, lb) - angle) + dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle) + return dev_next < dev_now + + def find_aspects( positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS ) -> list[dict]: - """positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów.""" + """positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę). + + Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma + applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). + """ out: list[dict] = [] n = len(positions) for i in range(n): @@ -51,9 +70,16 @@ def find_aspects( for asp, angle in MAJOR.items(): dev = abs(sep - angle) if dev <= allowed: - out.append({ + applying = _is_applying( + float(la), float(lb), a.get("speed"), b.get("speed"), angle + ) + row = { "obj1": a["name"], "obj2": b["name"], "aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2), - }) + } + if applying is not None: + row["applying"] = applying + row["as"] = "A" if applying else "S" + out.append(row) break # jedna para = jeden aspekt return out diff --git a/services/logic/app/scoring.py b/services/logic/app/scoring.py index 938e8da..bb7da53 100644 --- a/services/logic/app/scoring.py +++ b/services/logic/app/scoring.py @@ -18,6 +18,8 @@ ASPECT_WEIGHT = { "conjunction": 1.0, "opposition": 0.95, "square": 0.85, "trine": 0.85, "sextile": 0.65, } +# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3 +APPLYING_BONUS = 1.15 def score_facet(facet: dict) -> float: @@ -28,5 +30,8 @@ def score_facet(facet: dict) -> float: orb = facet.get("orb") allowed = facet.get("allowed") or 10.0 tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0 - return round(base * weight * (1.0 + tight), 2) + score = base * weight * (1.0 + tight) + if facet.get("applying"): + score *= APPLYING_BONUS + return round(score, 2) return round(base, 2) diff --git a/services/logic/app/significators.py b/services/logic/app/significators.py index cdfe2cb..9f3d64d 100644 --- a/services/logic/app/significators.py +++ b/services/logic/app/significators.py @@ -167,10 +167,12 @@ def build_report( asp_samples = _facet_samples(rows, [asp_tok, other_tok]) if not asp_samples: # pokazujemy tylko aspekty z trafieniami continue + as_suffix = f" ({asp['as']})" if asp.get("as") else "" facets.append({ - "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}", + "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}", "token": f"{asp_tok} + {other_tok}", "aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"), + "applying": asp.get("applying"), "count": len(asp_samples), "samples": asp_samples, }) diff --git a/services/logic/tests/test_aspects.py b/services/logic/tests/test_aspects.py index 00770a2..6890ca6 100644 --- a/services/logic/tests/test_aspects.py +++ b/services/logic/tests/test_aspects.py @@ -33,3 +33,58 @@ def test_luminary_bonus_widens_orb(): def test_one_aspect_per_pair(): pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] assert len(find_aspects(pos)) == 1 + + +def test_applying_when_faster_body_catches_up(): + # Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna + pos = [ + {"name": "Sun", "decimal": 40.0, "speed": 0.96}, + {"name": "Moon", "decimal": 35.0, "speed": 13.0}, + ] + a = find_aspects(pos)[0] + assert a["applying"] is True and a["as"] == "A" + + +def test_separating_when_moving_apart(): + # Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna + pos = [ + {"name": "Sun", "decimal": 40.0, "speed": 0.96}, + {"name": "Moon", "decimal": 45.0, "speed": 13.0}, + ] + a = find_aspects(pos)[0] + assert a["applying"] is False and a["as"] == "S" + + +def test_no_as_flag_without_speeds(): + pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] + assert "as" not in find_aspects(pos)[0] + + +# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa. +REFERENCE_AS = { + ("Sun", "Moon", "conjunction"): "A", + ("Sun", "Jupiter", "trine"): "A", + ("Sun", "Saturn", "opposition"): "A", + ("Sun", "Neptune", "trine"): "S", + ("Sun", "Pluto", "opposition"): "S", + ("Moon", "Mercury", "conjunction"): "S", + ("Moon", "Venus", "conjunction"): "S", + ("Moon", "Neptune", "trine"): "A", + ("Moon", "Pluto", "opposition"): "S", + ("Mercury", "Venus", "conjunction"): "S", + ("Mercury", "Neptune", "trine"): "S", + ("Mercury", "Pluto", "opposition"): "S", + ("Venus", "Neptune", "trine"): "A", + ("Venus", "Pluto", "opposition"): "A", + ("Jupiter", "Saturn", "sextile"): "A", + ("Neptune", "Pluto", "sextile"): "S", +} + + +def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment): + from app.engine.chart import build_chart + + chart = build_chart(own_engine, reference_moment) + got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]} + mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v} + assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}" diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 050bd97..9264432 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -87,10 +87,10 @@ {% if result.aspects %}
Aspekty główne ({{ result.aspects | length }})
- + {% for a in result.aspects %} - + {% endfor %}
Obiekt 1AspektObiekt 2Orb
Obiekt 1AspektObiekt 2OrbA/S
{{ a.obj1 }}{{ a.aspect }}{{ a.obj2 }}{{ '%.2f'|format(a.orb) }}°
{{ a.obj1 }}{{ a.aspect }}{{ a.obj2 }}{{ '%.2f'|format(a.orb) }}°{{ a['as'] if a['as'] is defined else '—' }}