ship-tracker / analytics.py
michalis13's picture
Update analytics.py
19bd940 verified
Raw
History Blame Contribute Delete
12.9 kB
"""Όλη η μη-UI λογική: καιρός, φυσική (ζόρι), οικονομικά, σκορ ανωμαλίας."""
import numpy as np
import requests
# =========================================================================
# ΚΑΙΡΟΣ (πραγματικά δεδομένα — Open-Meteo, δωρεάν, χωρίς key)
# =========================================================================
def get_weather(lat, lon):
"""Σημειακός καιρός για το panel ανάλυσης."""
out = {}
try:
r = requests.get("https://marine-api.open-meteo.com/v1/marine",
params={"latitude": lat, "longitude": lon,
"current": "wave_height,wave_period,wave_direction,"
"sea_surface_temperature,ocean_current_velocity"},
timeout=10).json().get("current", {})
out.update(wave_height=r.get("wave_height"), wave_period=r.get("wave_period"),
wave_direction=r.get("wave_direction"),
sst=r.get("sea_surface_temperature"),
current=r.get("ocean_current_velocity"))
except Exception:
pass
try:
r = requests.get("https://api.open-meteo.com/v1/forecast",
params={"latitude": lat, "longitude": lon,
"current": "wind_speed_10m,wind_direction_10m,wind_gusts_10m",
"wind_speed_unit": "kn"},
timeout=10).json().get("current", {})
out.update(wind_speed=r.get("wind_speed_10m"),
wind_direction=r.get("wind_direction_10m"),
wind_gusts=r.get("wind_gusts_10m"))
except Exception:
pass
return out
def get_marine_grid(lats, lons):
"""Φέρνει wave_height & ocean_current_velocity για ΠΛΕΓΜΑ σημείων.
Επιστρέφει (wave[H,W], current[H,W]) με NaN στη στεριά (όπου δεν υπάρχουν marine data).
Κάνει batching γιατί το API δέχεται πολλά σημεία ανά κλήση."""
H, W = len(lats), len(lons)
LAT = np.repeat(lats, W) # row-major
LON = np.tile(lons, H)
wave = np.full(H * W, np.nan)
curr = np.full(H * W, np.nan)
CHUNK = 120
for i in range(0, H * W, CHUNK):
la = LAT[i:i + CHUNK]; lo = LON[i:i + CHUNK]
try:
data = requests.get(
"https://marine-api.open-meteo.com/v1/marine",
params={"latitude": ",".join(f"{x:.4f}" for x in la),
"longitude": ",".join(f"{x:.4f}" for x in lo),
"current": "wave_height,ocean_current_velocity"},
timeout=20).json()
if isinstance(data, dict):
data = [data]
for j, d in enumerate(data):
cur = d.get("current", {})
wave[i + j] = cur.get("wave_height", np.nan)
curr[i + j] = cur.get("ocean_current_velocity", np.nan)
except Exception:
pass # αφήνει NaN -> θα θεωρηθεί αδιάβατο
return wave.reshape(H, W), curr.reshape(H, W)
def get_point_waves(lat_list, lon_list):
"""wave_height για ζεύγη σημείων (όχι πλέγμα). NaN σε στεριά/αποτυχία.
Κάνει batching γιατί το API δέχεται πολλά σημεία ανά κλήση."""
n = len(lat_list)
out = np.full(n, np.nan)
CHUNK = 100
for i in range(0, n, CHUNK):
la = lat_list[i:i + CHUNK]; lo = lon_list[i:i + CHUNK]
try:
data = requests.get(
"https://marine-api.open-meteo.com/v1/marine",
params={"latitude": ",".join(f"{x:.3f}" for x in la),
"longitude": ",".join(f"{x:.3f}" for x in lo),
"current": "wave_height"},
timeout=20).json()
if isinstance(data, dict):
data = [data]
for j, d in enumerate(data):
out[i + j] = d.get("current", {}).get("wave_height", np.nan)
except Exception:
pass
return out
def geocode_port(name):
"""Προσπαθεί να βρει συντεταγμένες λιμανιού από όνομα. Επιστρέφει (lat, lon) ή None."""
if not name:
return None
try:
r = requests.get("https://geocoding-api.open-meteo.com/v1/search",
params={"name": name, "count": 1}, timeout=10).json()
res = r.get("results")
if res:
return res[0]["latitude"], res[0]["longitude"]
except Exception:
pass
return None
# =========================================================================
# ΦΥΣΙΚΗ — εκτίμηση ζορίσματος (ΟΧΙ μέτρηση)
# =========================================================================
WEATHER_DRAG_K = 0.03
def _safe_type(t):
"""Μετατρέπει τον τύπο πλοίου σε int με ασφάλεια. None αν λείπει/NaN."""
if t is None:
return None
try:
if isinstance(t, float) and np.isnan(t):
return None
return int(t)
except (ValueError, TypeError):
return None
def estimate_cb(t):
t = _safe_type(t)
if not t:
return 0.68
return {6: 0.62, 7: 0.75, 8: 0.82}.get(t // 10, 0.68)
def estimate_displacement(L, B, T, cb):
if not (L and B and T):
return None
return L * B * T * cb * 1.025
def estimate_power_calm(disp_t, speed_kn, admiralty_c):
if not disp_t or not speed_kn or speed_kn <= 0:
return None
return (disp_t ** (2 / 3)) * (speed_kn ** 3) / admiralty_c
def estimate_resistance(power_kw, speed_kn):
if not power_kw or not speed_kn or speed_kn <= 0:
return None
return power_kw * 1000 / (speed_kn * 0.514444) / 1000
def weather_factor(wave_h):
return 1.0 if wave_h is None else 1.0 + WEATHER_DRAG_K * (wave_h ** 2)
# =========================================================================
# ΟΙΚΟΝΟΜΙΚΑ — εκτίμηση (ΟΧΙ ο πραγματικός ναύλος του πλοίου)
# =========================================================================
DWT_COEFF = 0.80
CO2_PER_FUEL = 3.114 # τόνοι CO2 ανά τόνο VLSFO
DEFAULT_RATE = {
"Capesize": 18000, "Panamax": 12000, "Supramax": 11000, "Handysize": 10000,
"VLCC": 40000, "Suezmax": 35000, "Aframax": 30000,
"Products/Small tanker": 18000, "Tanker": 25000, "Bulk (άγνωστο)": 12000,
"Άλλο": 12000,
}
def estimate_dwt(disp_t):
return disp_t * DWT_COEFF if disp_t else None
def vessel_class(dwt, ship_type):
st = _safe_type(ship_type)
if st and st // 10 == 8:
if not dwt:
return "Tanker"
if dwt >= 200000: return "VLCC"
if dwt >= 120000: return "Suezmax"
if dwt >= 80000: return "Aframax"
return "Products/Small tanker"
if st and st // 10 == 7:
if not dwt:
return "Bulk (άγνωστο)"
if dwt >= 100000: return "Capesize"
if dwt >= 65000: return "Panamax"
if dwt >= 40000: return "Supramax"
return "Handysize"
return "Άλλο"
def fuel_per_day(power_kw, sfoc):
return power_kw * sfoc * 24 / 1e6 if power_kw else None
def co2_per_day(fuel_t):
return fuel_t * CO2_PER_FUEL if fuel_t else None
def laden_status(draught, L):
if not draught or not L:
return "άγνωστο"
t_design = L / 14.0
return "έμφορτο (laden)" if draught >= 0.85 * t_design else "πιθανώς έρμα (ballast)"
# =========================================================================
# ΑΝΩΜΑΛΙΑ — μπλε (ομαλό) -> κόκκινο (στραβά), από τα συσσωρευμένα δεδομένα
# =========================================================================
def fleet_baseline(log):
moving = log[log["speed_kn"] > 1] if len(log) else log
return {
"speed_std": (moving["speed_kn"].std() if len(moving) else 1.0) or 1.0,
"speed_mean": (moving["speed_kn"].mean() if len(moving) else 8.0) or 8.0,
"n": len(log),
}
def behaviour_score(track, base):
pts = track.sort_values("timestamp")
speeds = pts["speed_kn"].dropna()
score, reasons = 0.0, []
base_std = base.get("speed_std") or 1.0
if len(speeds) >= 2:
if speeds.std() > 2.5 * base_std:
score += 0.35; reasons.append("ασταθής ταχύτητα")
if speeds.max() > 5 and speeds.min() < 0.5 and (speeds.max() - speeds.min()) > 5:
score += 0.30; reasons.append("απότομη ακινητοποίηση")
if len(pts) >= 3:
cc = pts["course"].dropna().values
if len(cc) >= 2:
d = np.abs(np.diff(cc)); d = np.minimum(d, 360 - d)
if d.mean() > 30:
score += 0.25; reasons.append("ελιγμοί/ζιγκ-ζαγκ")
if len(speeds) and speeds.iloc[-1] < 0.3:
score += 0.10; reasons.append("σταματημένο")
return min(score, 1.0), reasons
def color_for(s):
return [int(40 + s * 180), max(int(120 - s * 100), 20), max(int(255 - s * 230), 20)]
# =========================================================================
# EU ETS — κόστος ρύπων ανά ταξίδι (ΕΚΤΙΜΗΣΗ από δημόσιο σήμα)
# =========================================================================
import math
# Χώρες EU + EEA (Ισλανδία/Νορβηγία/Λίχτενσταϊν) — όπου ισχύει το EU ETS ναυτιλίας
EU_EEA = {"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR",
"HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK",
"SI", "ES", "SE", "IS", "NO", "LI"}
EUA_DEFAULT = 78.0 # €/τόνο CO2 (τρέχουσα τιμή ~77-80, Ιούν 2026)
# Κατανάλωση καυσίμου σε έμφορτη ταχύτητα υπηρεσίας (t/ημέρα), μέσος όρος κλάδου
FUEL_BY_CLASS = {
"VLCC": 80, "Suezmax": 62, "Aframax": 50, "Products/Small tanker": 28,
"Tanker": 45, "Capesize": 55, "Panamax": 38, "Supramax": 30,
"Handysize": 24, "Bulk (άγνωστο)": 35, "Άλλο": 38,
}
# Τυπική ταχύτητα υπηρεσίας (kn) — όταν το πλοίο είναι σταματημένο/αργό
SERVICE_SPEED = {
"VLCC": 14.0, "Suezmax": 13.5, "Aframax": 12.5, "Products/Small tanker": 12.0,
"Tanker": 12.5, "Capesize": 12.0, "Panamax": 12.5, "Supramax": 12.5,
"Handysize": 12.0, "Bulk (άγνωστο)": 12.0, "Άλλο": 12.0,
}
# Σταδιακή εφαρμογή EU ETS: ποσοστό εκπομπών που μετράει ανά έτος
PHASE_IN = {2024: 0.40, 2025: 0.70} # 2026 και μετά = 100%
def geocode_port_full(name):
"""Όνομα λιμανιού -> dict(lat, lon, country_code, name) ή None."""
if not name:
return None
try:
r = requests.get("https://geocoding-api.open-meteo.com/v1/search",
params={"name": name, "count": 1}, timeout=10).json()
res = r.get("results")
if res:
x = res[0]
return {"lat": x["latitude"], "lon": x["longitude"],
"country_code": x.get("country_code"), "name": x.get("name")}
except Exception:
pass
return None
def is_eu(country_code):
return (country_code or "").upper() in EU_EEA
def haversine_nm(lat1, lon1, lat2, lon2):
"""Απόσταση μεγίστου κύκλου σε ναυτικά μίλια."""
R = 3440.065
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def phase_in_factor(year):
return PHASE_IN.get(int(year), 1.0)
def ets_coverage(n_eu_ports):
"""0 λιμάνια ΕΕ -> 0%, 1 -> 50%, 2 -> 100%."""
return {0: 0.0, 1: 0.5, 2: 1.0}.get(int(n_eu_ports), 0.0)
def ets_estimate(dist_nm, speed_kn, fuel_t_day, n_eu_ports, eua, year):
"""Επιστρέφει dict με ημέρες, καύσιμο, CO2, κάλυψη, κόστος. None αν λείπουν δεδομένα."""
if not (dist_nm and speed_kn and fuel_t_day):
return None
days = dist_nm / (speed_kn * 24)
fuel = days * fuel_t_day
co2 = fuel * CO2_PER_FUEL
cov = ets_coverage(n_eu_ports)
yf = phase_in_factor(year)
covered = co2 * cov * yf
cost = covered * eua
return {"days": days, "fuel": fuel, "co2": co2, "coverage": cov,
"phase": yf, "covered": covered, "cost": cost}