"""Όλη η μη-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}