Add anonymised world map of active eggs
Some checks failed
Build & publish Docker images / Server image (push) Failing after 15s
Build & publish Docker images / Egg image (push) Failing after 17s

Server geolocates each egg by IP on first contact (ip-api.com), snaps
coordinates to the nearest 1-degree grid (~111 km) and adds a small
deterministic per-egg jitter so eggs in the same cell don't overlap.
Only country + rounded lat/lon are stored; exact IPs are never saved.

Front-end uses Leaflet with CartoDB dark tiles. Markers update on every
poll cycle and are coloured to match the current coherence index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hexadual
2026-04-30 01:32:19 -05:00
parent ee2249c5f8
commit 62b9995cd9
2 changed files with 190 additions and 3 deletions

View File

@@ -3,15 +3,18 @@ GCP Server — collects random trials from distributed eggs, runs Stouffer Z
network variance analysis every 60 seconds, and serves the dot website.
"""
import hashlib
import json as _json
import os
import sqlite3
import threading
import time
import urllib.request
from collections import defaultdict
from contextlib import contextmanager
import numpy as np
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from scipy import stats
@@ -47,6 +50,15 @@ def init_db():
n_samples INTEGER NOT NULL,
window_seconds INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS egg_locations (
egg_id TEXT PRIMARY KEY,
country TEXT,
country_code TEXT,
lat REAL,
lon REAL,
located_at INTEGER
);
""")
@@ -61,6 +73,60 @@ def db():
con.close()
# ---------------------------------------------------------------------------
# Geo-location (server-side, anonymised to 1° grid ≈ 111 km)
# ---------------------------------------------------------------------------
_geo_pending: set[str] = set()
_geo_lock = threading.Lock()
def _jitter(egg_id: str) -> tuple[float, float]:
"""Deterministic ±0.35° offset so eggs in the same cell don't stack."""
h = int(hashlib.md5(egg_id.encode()).hexdigest()[:8], 16)
return ((h & 0xFFFF) / 0xFFFF - 0.5) * 0.7, ((h >> 16 & 0xFFFF) / 0xFFFF - 0.5) * 0.7
def _fetch_geo(egg_id: str, ip: str):
try:
url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,lat,lon"
with urllib.request.urlopen(url, timeout=6) as resp:
data = _json.loads(resp.read())
if data.get("status") != "success":
return
lat = round(float(data["lat"])) + _jitter(egg_id)[0]
lon = round(float(data["lon"])) + _jitter(egg_id)[1]
with db() as con:
con.execute(
"INSERT OR REPLACE INTO egg_locations "
"(egg_id, country, country_code, lat, lon, located_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(egg_id, data["country"], data["countryCode"],
round(lat, 2), round(lon, 2), int(time.time())),
)
except Exception as exc:
print(f"[geo] {egg_id} {ip}: {exc}")
finally:
with _geo_lock:
_geo_pending.discard(egg_id)
def maybe_geolocate(egg_id: str, ip: str):
if not ip or ip in ("127.0.0.1", "::1"):
return
with _geo_lock:
if egg_id in _geo_pending:
return
with db() as con:
if con.execute(
"SELECT 1 FROM egg_locations WHERE egg_id = ?", (egg_id,)
).fetchone():
return
with _geo_lock:
_geo_pending.add(egg_id)
threading.Thread(target=_fetch_geo, args=(egg_id, ip), daemon=True).start()
# ---------------------------------------------------------------------------
# Statistical analysis
# ---------------------------------------------------------------------------
@@ -171,9 +237,8 @@ def startup():
@app.post("/api/data")
def receive_trial(data: TrialData):
def receive_trial(request: Request, data: TrialData):
now = int(time.time())
# Reject data more than 5 minutes out of sync
if abs(data.timestamp - now) > 300:
raise HTTPException(status_code=422, detail="timestamp out of range")
with db() as con:
@@ -181,6 +246,11 @@ def receive_trial(data: TrialData):
"INSERT INTO trials (egg_id, timestamp, trial) VALUES (?, ?, ?)",
(data.egg_id, data.timestamp, data.trial),
)
client_ip = (
request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or (request.client.host if request.client else "")
)
maybe_geolocate(data.egg_id, client_ip)
return {"status": "ok"}
@@ -214,6 +284,34 @@ def get_history(limit: int = 60):
]
@app.get("/api/map")
def get_map():
"""Anonymised egg positions for the world map (active in last 5 min)."""
cutoff = int(time.time()) - 300
with db() as con:
rows = con.execute(
"""
SELECT el.egg_id, el.country, el.country_code, el.lat, el.lon,
MAX(t.timestamp) AS last_active
FROM egg_locations el
JOIN trials t ON el.egg_id = t.egg_id
WHERE t.timestamp > ?
GROUP BY el.egg_id
""",
(cutoff,),
).fetchall()
return [
{
"country": r["country"],
"country_code": r["country_code"],
"lat": r["lat"],
"lon": r["lon"],
"last_active": r["last_active"],
}
for r in rows
]
@app.get("/api/eggs")
def get_active_eggs():
"""Return eggs that have submitted data in the last 2 minutes."""