Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff |
@@ -6,7 +6,7 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `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/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)
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|||||||
@@ -34,10 +34,29 @@ def separation(a: float, b: float) -> float:
|
|||||||
return min(d, 360.0 - d)
|
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(
|
def find_aspects(
|
||||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
) -> list[dict]:
|
) -> 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] = []
|
out: list[dict] = []
|
||||||
n = len(positions)
|
n = len(positions)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
@@ -51,9 +70,16 @@ def find_aspects(
|
|||||||
for asp, angle in MAJOR.items():
|
for asp, angle in MAJOR.items():
|
||||||
dev = abs(sep - angle)
|
dev = abs(sep - angle)
|
||||||
if dev <= allowed:
|
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"],
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
"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
|
break # jedna para = jeden aspekt
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ ASPECT_WEIGHT = {
|
|||||||
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
||||||
"trine": 0.85, "sextile": 0.65,
|
"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:
|
def score_facet(facet: dict) -> float:
|
||||||
@@ -28,5 +30,8 @@ def score_facet(facet: dict) -> float:
|
|||||||
orb = facet.get("orb")
|
orb = facet.get("orb")
|
||||||
allowed = facet.get("allowed") or 10.0
|
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
|
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)
|
return round(base, 2)
|
||||||
|
|||||||
@@ -167,10 +167,12 @@ def build_report(
|
|||||||
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||||
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||||
continue
|
continue
|
||||||
|
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
|
||||||
facets.append({
|
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}",
|
"token": f"{asp_tok} + {other_tok}",
|
||||||
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||||
|
"applying": asp.get("applying"),
|
||||||
"count": len(asp_samples), "samples": asp_samples,
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,58 @@ def test_luminary_bonus_widens_orb():
|
|||||||
def test_one_aspect_per_pair():
|
def test_one_aspect_per_pair():
|
||||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
assert len(find_aspects(pos)) == 1
|
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}"
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
|
||||||
|
// możliwe — lokalizację (lat/lon).
|
||||||
|
//
|
||||||
|
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
|
||||||
|
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
|
||||||
|
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const btn = document.getElementById('nowBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const note = document.getElementById('geoNote');
|
||||||
|
const say = msg => { if (note) note.textContent = msg; };
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say('Pobieram lokalizację…');
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
function (pos) {
|
||||||
|
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||||
|
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||||
|
say('Lokalizacja pobrana ✓');
|
||||||
|
},
|
||||||
|
function (err) {
|
||||||
|
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
|
||||||
|
},
|
||||||
|
{ timeout: 8000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Policz horoskop</button>
|
<button type="submit">Policz horoskop</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -86,10 +87,10 @@
|
|||||||
{% if result.aspects %}
|
{% if result.aspects %}
|
||||||
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in result.aspects %}
|
{% for a in result.aspects %}
|
||||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr>
|
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -110,22 +111,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script src="/static/now.js"></script>
|
||||||
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
|
|
||||||
document.getElementById('nowBtn').addEventListener('click', function () {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
document.querySelector('input[name=date]').value =
|
|
||||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
||||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
||||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
|
||||||
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
|
||||||
if (navigator.geolocation) {
|
|
||||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
|
||||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
|
||||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Szukaj interpretacji</button>
|
<button type="submit">Szukaj interpretacji</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -82,21 +83,5 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script src="/static/now.js"></script>
|
||||||
document.getElementById('nowBtn').addEventListener('click', function () {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
document.querySelector('input[name=date]').value =
|
|
||||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
||||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
||||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
|
||||||
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
|
||||||
if (navigator.geolocation) {
|
|
||||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
|
||||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
|
||||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user