672 lines
26 KiB
Python
672 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Anchor data analysis — generates an interactive HTML report from track.db.
|
||
|
||
Reads estimation_log, summary_points, and raw_points tables, merges them
|
||
into a unified time series, and produces 7 analyses with Chart.js charts.
|
||
|
||
Usage: python3 analyze_anchor.py
|
||
Output: anchor_report.html (same directory)
|
||
"""
|
||
|
||
import json
|
||
import math
|
||
import os
|
||
import sqlite3
|
||
import sys
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
# Allow importing catenary from parent directory
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||
from catenary import catenary_distance, wind_force_lbs
|
||
|
||
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "track.db")
|
||
OUT_PATH = os.path.join(os.path.dirname(__file__), "anchor_report.html")
|
||
|
||
CHAIN_LENGTH_FT = 150.0
|
||
FREEBOARD_FT = 4.0
|
||
WINDAGE_AREA = 120.0
|
||
DRAG_COEFF = 1.0
|
||
CHAIN_WEIGHT = 2.0 # lb/ft for 5/16" G4
|
||
MS_TO_KTS = 1.94384 # wind speed stored in m/s, catenary math expects knots
|
||
|
||
EARTH_RADIUS_FT = 20902231.0
|
||
NM_TO_FT = 6076.12
|
||
|
||
|
||
def to_local_xy(lat, lon, ref_lat, ref_lon):
|
||
lat_rad = math.radians(ref_lat)
|
||
dx = (lon - ref_lon) * 60.0 * NM_TO_FT * math.cos(lat_rad)
|
||
dy = (lat - ref_lat) * 60.0 * NM_TO_FT
|
||
return dx, dy
|
||
|
||
|
||
def load_data():
|
||
con = sqlite3.connect(DB_PATH)
|
||
|
||
est = pd.read_sql("SELECT * FROM estimation_log ORDER BY ts", con)
|
||
summary = pd.read_sql("SELECT * FROM summary_points ORDER BY ts", con)
|
||
raw = pd.read_sql("SELECT * FROM raw_points ORDER BY ts", con)
|
||
|
||
con.close()
|
||
return est, summary, raw
|
||
|
||
|
||
def build_unified(est, summary, raw):
|
||
"""Merge sensor data (summary + raw) with estimation_log on nearest timestamp."""
|
||
sensor = pd.concat([
|
||
summary[["ts", "lat", "lon", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]],
|
||
raw[["ts", "lat", "lon", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]],
|
||
], ignore_index=True).sort_values("ts").drop_duplicates("ts").reset_index(drop=True)
|
||
|
||
sensor["ws"] = sensor["ws"] * MS_TO_KTS
|
||
|
||
merged = pd.merge_asof(
|
||
est.sort_values("ts"),
|
||
sensor[["ts", "hdg", "cog", "spd", "ws", "wd", "dist", "depth"]],
|
||
on="ts",
|
||
direction="nearest",
|
||
tolerance=120.0,
|
||
)
|
||
return merged, sensor
|
||
|
||
|
||
def ts_to_hours(ts_series, t0):
|
||
return (ts_series - t0) / 3600.0
|
||
|
||
|
||
def compute_analyses(merged, sensor):
|
||
t0 = sensor["ts"].min()
|
||
anchor_lat = merged["est_lat"].dropna().iloc[-1]
|
||
anchor_lon = merged["est_lon"].dropna().iloc[-1]
|
||
|
||
results = {}
|
||
|
||
# --- 1. Vessel Track / Swing Circle ---
|
||
track = merged.dropna(subset=["vessel_lat", "vessel_lon"]).copy()
|
||
xy = track.apply(
|
||
lambda r: to_local_xy(r["vessel_lat"], r["vessel_lon"], anchor_lat, anchor_lon),
|
||
axis=1, result_type="expand",
|
||
)
|
||
track = track.assign(x_ft=xy[0], y_ft=xy[1])
|
||
arc_rows = track[track["arc_valid"] == 1]
|
||
arc_radius = arc_rows["arc_radius_ft"].median() if len(arc_rows) else None
|
||
arc_center_x, arc_center_y = 0.0, 0.0
|
||
if len(arc_rows):
|
||
cxy = arc_rows.apply(
|
||
lambda r: to_local_xy(r["arc_center_lat"], r["arc_center_lon"], anchor_lat, anchor_lon),
|
||
axis=1, result_type="expand",
|
||
)
|
||
arc_center_x = cxy[0].median()
|
||
arc_center_y = cxy[1].median()
|
||
|
||
n_track = min(len(track), 4000)
|
||
step = max(1, len(track) // n_track)
|
||
t_sub = track.iloc[::step]
|
||
hours_track = ts_to_hours(t_sub["ts"], t0).tolist()
|
||
|
||
results["track"] = {
|
||
"x": t_sub["x_ft"].round(1).tolist(),
|
||
"y": t_sub["y_ft"].round(1).tolist(),
|
||
"hours": [round(h, 2) for h in hours_track],
|
||
"arc_radius": round(arc_radius, 1) if arc_radius else None,
|
||
"arc_cx": round(arc_center_x, 1),
|
||
"arc_cy": round(arc_center_y, 1),
|
||
}
|
||
|
||
# --- 2. Weathervaning: Heading vs Wind Direction ---
|
||
wv = merged.dropna(subset=["hdg", "wd"]).copy()
|
||
wv["offset"] = (wv["hdg"] - wv["wd"]) % 360
|
||
wv.loc[wv["offset"] > 180, "offset"] = wv["offset"] - 360
|
||
n_wv = min(len(wv), 3000)
|
||
step_wv = max(1, len(wv) // n_wv)
|
||
wv_sub = wv.iloc[::step_wv]
|
||
|
||
offset_vals = wv["offset"].values
|
||
mean_offset = float(np.mean(offset_vals))
|
||
std_offset = float(np.std(offset_vals))
|
||
|
||
hist_counts, hist_edges = np.histogram(offset_vals, bins=36, range=(-180, 180))
|
||
hist_labels = [f"{int(hist_edges[i])}°" for i in range(len(hist_counts))]
|
||
|
||
results["weathervane"] = {
|
||
"hours": [round(h, 2) for h in ts_to_hours(wv_sub["ts"], t0)],
|
||
"hdg": wv_sub["hdg"].round(1).tolist(),
|
||
"wd": wv_sub["wd"].round(1).tolist(),
|
||
"offset_hours": [round(h, 2) for h in ts_to_hours(wv_sub["ts"], t0)],
|
||
"offset": wv_sub["offset"].round(1).tolist(),
|
||
"hist_labels": hist_labels,
|
||
"hist_counts": hist_counts.tolist(),
|
||
"mean_offset": round(mean_offset, 1),
|
||
"std_offset": round(std_offset, 1),
|
||
}
|
||
|
||
# --- 3. Catenary Model Validation ---
|
||
cat_data = merged.dropna(subset=["depth", "cat_dist_ft", "ws"]).copy()
|
||
n_cat = min(len(cat_data), 2000)
|
||
step_cat = max(1, len(cat_data) // n_cat)
|
||
cat_sub = cat_data.iloc[::step_cat]
|
||
|
||
depth_range = np.linspace(cat_data["depth"].min(), cat_data["depth"].max(), 50)
|
||
ws_median = float(cat_data["ws"].median())
|
||
force = wind_force_lbs(ws_median, WINDAGE_AREA, DRAG_COEFF)
|
||
theory_dist = []
|
||
for d in depth_range:
|
||
r = catenary_distance(CHAIN_LENGTH_FT, d, FREEBOARD_FT, force, CHAIN_WEIGHT)
|
||
theory_dist.append(round(r.total_distance_ft, 1))
|
||
|
||
results["catenary"] = {
|
||
"depth": cat_sub["depth"].round(2).tolist(),
|
||
"measured_dist": cat_sub["cat_dist_ft"].round(1).tolist(),
|
||
"theory_depth": [round(float(d), 2) for d in depth_range],
|
||
"theory_dist": theory_dist,
|
||
"ws_median": round(ws_median, 1),
|
||
}
|
||
|
||
# --- 4. Depth (Tide) Time Series ---
|
||
tide = sensor.dropna(subset=["depth"]).copy()
|
||
n_tide = min(len(tide), 3000)
|
||
step_tide = max(1, len(tide) // n_tide)
|
||
tide_sub = tide.iloc[::step_tide]
|
||
|
||
dist_ts = merged.dropna(subset=["cat_dist_ft"]).copy()
|
||
n_dist = min(len(dist_ts), 3000)
|
||
step_dist = max(1, len(dist_ts) // n_dist)
|
||
dist_sub = dist_ts.iloc[::step_dist]
|
||
|
||
results["tide"] = {
|
||
"depth_hours": [round(h, 2) for h in ts_to_hours(tide_sub["ts"], t0)],
|
||
"depth": tide_sub["depth"].round(2).tolist(),
|
||
"dist_hours": [round(h, 2) for h in ts_to_hours(dist_sub["ts"], t0)],
|
||
"dist": dist_sub["cat_dist_ft"].round(1).tolist(),
|
||
}
|
||
|
||
# --- 5. Wind Effects on Swing Radius ---
|
||
wind_fx = merged.dropna(subset=["ws", "cat_dist_ft", "depth"]).copy()
|
||
n_wfx = min(len(wind_fx), 2000)
|
||
step_wfx = max(1, len(wind_fx) // n_wfx)
|
||
wfx_sub = wind_fx.iloc[::step_wfx]
|
||
|
||
corr_ws_dist = float(wind_fx[["ws", "cat_dist_ft"]].corr().iloc[0, 1])
|
||
|
||
results["wind_fx"] = {
|
||
"ws": wfx_sub["ws"].round(1).tolist(),
|
||
"dist": wfx_sub["cat_dist_ft"].round(1).tolist(),
|
||
"depth": wfx_sub["depth"].round(1).tolist(),
|
||
"corr": round(corr_ws_dist, 3),
|
||
}
|
||
|
||
# --- 6. Swing Dynamics / Oscillation ---
|
||
swing = merged.dropna(subset=["hdg"]).copy()
|
||
swing = swing.sort_values("ts")
|
||
dt = swing["ts"].diff()
|
||
dh = swing["hdg"].diff()
|
||
dh = dh.where(dh.abs() < 180, dh - 360 * dh.abs() / dh)
|
||
swing = swing.assign(hdg_rate=dh / dt)
|
||
swing = swing.dropna(subset=["hdg_rate"])
|
||
|
||
reversals = swing["hdg_rate"].values
|
||
sign_changes = np.where(np.diff(np.sign(reversals)))[0]
|
||
if len(sign_changes) > 1:
|
||
reversal_ts = swing["ts"].iloc[sign_changes].values
|
||
periods = np.diff(reversal_ts)
|
||
mean_half_period = float(np.median(periods))
|
||
swing_period_min = round(2 * mean_half_period / 60, 1)
|
||
else:
|
||
swing_period_min = None
|
||
|
||
n_sw = min(len(swing), 3000)
|
||
step_sw = max(1, len(swing) // n_sw)
|
||
sw_sub = swing.iloc[::step_sw]
|
||
|
||
results["swing"] = {
|
||
"hours": [round(h, 2) for h in ts_to_hours(sw_sub["ts"], t0)],
|
||
"hdg": sw_sub["hdg"].round(1).tolist(),
|
||
"hdg_rate": sw_sub["hdg_rate"].clip(-5, 5).round(2).tolist(),
|
||
"swing_period_min": swing_period_min,
|
||
}
|
||
|
||
# --- 7. Polar Position Plot ---
|
||
polar = merged.dropna(subset=["vessel_lat", "vessel_lon"]).copy()
|
||
dx_arr = polar.apply(
|
||
lambda r: to_local_xy(r["vessel_lat"], r["vessel_lon"], anchor_lat, anchor_lon),
|
||
axis=1, result_type="expand",
|
||
)
|
||
polar = polar.assign(x_ft=dx_arr[0], y_ft=dx_arr[1])
|
||
polar["bearing"] = np.degrees(np.arctan2(polar["x_ft"], polar["y_ft"])) % 360
|
||
polar["radius"] = np.sqrt(polar["x_ft"] ** 2 + polar["y_ft"] ** 2)
|
||
|
||
n_pol = min(len(polar), 2000)
|
||
step_pol = max(1, len(polar) // n_pol)
|
||
pol_sub = polar.iloc[::step_pol]
|
||
|
||
wd_polar = merged.dropna(subset=["wd"]).copy()
|
||
n_wd = min(len(wd_polar), 500)
|
||
step_wd = max(1, len(wd_polar) // n_wd)
|
||
wd_sub = wd_polar.iloc[::step_wd]
|
||
|
||
results["polar"] = {
|
||
"bearing": pol_sub["bearing"].round(1).tolist(),
|
||
"radius": pol_sub["radius"].round(1).tolist(),
|
||
"wd": wd_sub["wd"].round(1).tolist(),
|
||
"wd_hours": [round(h, 2) for h in ts_to_hours(wd_sub["ts"], t0)],
|
||
}
|
||
|
||
# --- Summary statistics ---
|
||
results["stats"] = {
|
||
"total_hours": round((sensor["ts"].max() - sensor["ts"].min()) / 3600, 1),
|
||
"est_rows": len(merged),
|
||
"sensor_rows": len(sensor),
|
||
"depth_min": round(float(sensor["depth"].min()), 1),
|
||
"depth_max": round(float(sensor["depth"].max()), 1),
|
||
"ws_min": round(float(sensor["ws"].min()), 1),
|
||
"ws_max": round(float(sensor["ws"].max()), 1),
|
||
"mean_offset": results["weathervane"]["mean_offset"],
|
||
"std_offset": results["weathervane"]["std_offset"],
|
||
"corr_ws_dist": results["wind_fx"]["corr"],
|
||
"swing_period_min": results["swing"]["swing_period_min"],
|
||
"anchor_lat": round(anchor_lat, 7),
|
||
"anchor_lon": round(anchor_lon, 7),
|
||
}
|
||
|
||
return results
|
||
|
||
|
||
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Anchor Data Analysis Report</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1117; --surface: #1a1d27; --border: #2a2d3a;
|
||
--text: #e0e0e8; --muted: #8a8d9a; --accent: #4fc3f7;
|
||
--accent2: #f4845f; --accent3: #7ec8a0; --accent4: #c39af4;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--bg); color: var(--text);
|
||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||
padding: 2rem; line-height: 1.6;
|
||
}
|
||
h1 { color: var(--accent); font-size: 1.6rem; margin-bottom: 0.5rem; }
|
||
h2 { color: var(--accent); font-size: 1.1rem; margin: 2rem 0 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
|
||
.stats-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 0.75rem; margin: 1rem 0;
|
||
}
|
||
.stat-card {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 6px; padding: 0.75rem;
|
||
}
|
||
.stat-card .label { color: var(--muted); font-size: 0.75rem; text-transform: uppercase; }
|
||
.stat-card .value { color: var(--accent); font-size: 1.3rem; font-weight: 700; }
|
||
.chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin: 1rem 0; }
|
||
.chart-box {
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 1rem;
|
||
}
|
||
.chart-box.full { grid-column: 1 / -1; }
|
||
.chart-box canvas { width: 100% !important; }
|
||
.insight { color: var(--muted); font-size: 0.85rem; margin-top: 0.5rem; font-style: italic; }
|
||
@media (max-width: 900px) { .chart-row { grid-template-columns: 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Anchor Analysis Report</h1>
|
||
<p style="color:var(--muted); margin-bottom:1rem;">Chain: CHAIN_LEN ft • Depth: DEPTH_RANGE ft • Wind: WIND_RANGE kts • Duration: DURATION hrs</p>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat-card"><div class="label">Estimated Anchor</div><div class="value" style="font-size:0.95rem">ANCHOR_POS</div></div>
|
||
<div class="stat-card"><div class="label">Heading Offset (mean±std)</div><div class="value">OFFSET_STATS°</div></div>
|
||
<div class="stat-card"><div class="label">Wind↔Distance Corr</div><div class="value">CORR_WS</div></div>
|
||
<div class="stat-card"><div class="label">Swing Period</div><div class="value">SWING_PERIOD</div></div>
|
||
<div class="stat-card"><div class="label">Estimation Samples</div><div class="value">EST_ROWS</div></div>
|
||
<div class="stat-card"><div class="label">Sensor Samples</div><div class="value">SENSOR_ROWS</div></div>
|
||
</div>
|
||
|
||
<!-- 1. Track -->
|
||
<h2>1. Vessel Track / Swing Circle</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box full"><canvas id="trackChart" height="400"></canvas></div>
|
||
</div>
|
||
<p class="insight">Vessel positions in local X/Y (ft) relative to estimated anchor. Color encodes time progression (blue→yellow). Dashed circle = arc-fit radius.</p>
|
||
|
||
<!-- 2. Weathervane -->
|
||
<h2>2. Weathervaning: Heading vs Wind Direction</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box"><canvas id="wvTimeSeries" height="250"></canvas></div>
|
||
<div class="chart-box"><canvas id="wvHist" height="250"></canvas></div>
|
||
</div>
|
||
<p class="insight">Left: heading and wind direction over time. Right: distribution of heading offset (hdg − wind_dir). Mean offset reveals systematic bias from hull/keel asymmetry.</p>
|
||
|
||
<!-- 3. Catenary -->
|
||
<h2>3. Catenary Model Validation</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box full"><canvas id="catChart" height="300"></canvas></div>
|
||
</div>
|
||
<p class="insight">Measured catenary distance vs depth, overlaid with theoretical curve at median wind speed (WS_MED kts). Scatter shows tidal variation.</p>
|
||
|
||
<!-- 4. Tide -->
|
||
<h2>4. Depth (Tide) & Distance Time Series</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box full"><canvas id="tideChart" height="300"></canvas></div>
|
||
</div>
|
||
<p class="insight">Depth and distance from anchor over the full observation window. Inverse correlation shows tide pulling the vessel closer at high water (more chain hangs vertically).</p>
|
||
|
||
<!-- 5. Wind Effects -->
|
||
<h2>5. Wind Effects on Swing Radius</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box full"><canvas id="windChart" height="300"></canvas></div>
|
||
</div>
|
||
<p class="insight">Wind speed vs distance from anchor. Color encodes depth (darker = deeper). Correlation r = CORR_WS_2.</p>
|
||
|
||
<!-- 6. Swing Dynamics -->
|
||
<h2>6. Swing Dynamics / Oscillation</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box"><canvas id="swingHdg" height="250"></canvas></div>
|
||
<div class="chart-box"><canvas id="swingRate" height="250"></canvas></div>
|
||
</div>
|
||
<p class="insight">Left: heading over time. Right: heading rate of change (°/s, clipped ±5). Estimated swing period: SWING_PERIOD_2.</p>
|
||
|
||
<!-- 7. Polar -->
|
||
<h2>7. Polar Position Plot</h2>
|
||
<div class="chart-row">
|
||
<div class="chart-box full"><canvas id="polarChart" height="450"></canvas></div>
|
||
</div>
|
||
<p class="insight">Vessel bearing and distance from anchor in polar coordinates. Red markers show wind direction samples to compare occupied sectors with wind.</p>
|
||
|
||
<script>
|
||
const D = DATA_JSON;
|
||
|
||
// Color helpers
|
||
function hourColor(h, maxH) {
|
||
const t = h / maxH;
|
||
const r = Math.round(30 + 225 * t);
|
||
const g = Math.round(100 + 155 * t);
|
||
const b = Math.round(247 - 200 * t);
|
||
return `rgba(${r},${g},${b},0.55)`;
|
||
}
|
||
function depthColor(d, minD, maxD) {
|
||
const t = (d - minD) / (maxD - minD + 0.01);
|
||
const r = Math.round(79 + 176 * t);
|
||
const g = Math.round(195 - 100 * t);
|
||
const b = Math.round(247 - 200 * t);
|
||
return `rgba(${r},${g},${b},0.6)`;
|
||
}
|
||
|
||
const commonOpts = {
|
||
responsive: true,
|
||
plugins: { legend: { labels: { color: '#8a8d9a' } } },
|
||
scales: {
|
||
x: { ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
};
|
||
|
||
// 1. Track scatter
|
||
(function() {
|
||
const maxH = Math.max(...D.track.hours);
|
||
const colors = D.track.hours.map(h => hourColor(h, maxH));
|
||
const data = D.track.x.map((x, i) => ({ x, y: D.track.y[i] }));
|
||
const datasets = [{
|
||
label: 'Vessel position (ft)',
|
||
data, backgroundColor: colors,
|
||
pointRadius: 1.5, showLine: false,
|
||
}];
|
||
if (D.track.arc_radius) {
|
||
const pts = [];
|
||
for (let a = 0; a <= 360; a += 2) {
|
||
const rad = a * Math.PI / 180;
|
||
pts.push({ x: D.track.arc_cx + D.track.arc_radius * Math.cos(rad),
|
||
y: D.track.arc_cy + D.track.arc_radius * Math.sin(rad) });
|
||
}
|
||
datasets.push({
|
||
label: `Arc fit r=${D.track.arc_radius} ft`,
|
||
data: pts, borderColor: '#f4845f', borderDash: [6, 3],
|
||
pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5,
|
||
});
|
||
}
|
||
datasets.push({
|
||
label: 'Anchor', data: [{ x: 0, y: 0 }],
|
||
backgroundColor: '#ff5252', pointRadius: 7, pointStyle: 'crossRot',
|
||
showLine: false,
|
||
});
|
||
new Chart('trackChart', {
|
||
type: 'scatter', data: { datasets },
|
||
options: {
|
||
...commonOpts, aspectRatio: 1.2,
|
||
scales: {
|
||
x: { title: { display: true, text: 'East-West (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'North-South (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
})();
|
||
|
||
// 2a. Weathervane time series
|
||
new Chart('wvTimeSeries', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [
|
||
{ label: 'Heading', data: D.weathervane.hours.map((h, i) => ({ x: h, y: D.weathervane.hdg[i] })),
|
||
backgroundColor: 'rgba(79,195,247,0.3)', pointRadius: 1, showLine: false },
|
||
{ label: 'Wind Dir', data: D.weathervane.hours.map((h, i) => ({ x: h, y: D.weathervane.wd[i] })),
|
||
backgroundColor: 'rgba(244,132,95,0.3)', pointRadius: 1, showLine: false },
|
||
],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'Degrees', color: '#8a8d9a' }, min: 0, max: 360, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 2b. Heading offset histogram
|
||
new Chart('wvHist', {
|
||
type: 'bar',
|
||
data: {
|
||
labels: D.weathervane.hist_labels,
|
||
datasets: [{ label: 'Offset count', data: D.weathervane.hist_counts,
|
||
backgroundColor: 'rgba(79,195,247,0.6)', borderColor: 'rgba(79,195,247,0.9)', borderWidth: 1 }],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Heading − Wind (°)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a', maxRotation: 45 }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'Count', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 3. Catenary validation
|
||
new Chart('catChart', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [
|
||
{ label: 'Measured', data: D.catenary.depth.map((d, i) => ({ x: d, y: D.catenary.measured_dist[i] })),
|
||
backgroundColor: 'rgba(79,195,247,0.25)', pointRadius: 1.5, showLine: false },
|
||
{ label: `Theory (ws=${D.catenary.ws_median} kts)`,
|
||
data: D.catenary.theory_depth.map((d, i) => ({ x: d, y: D.catenary.theory_dist[i] })),
|
||
borderColor: '#f4845f', pointRadius: 0, showLine: true, fill: false, borderWidth: 2 },
|
||
],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Depth (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'Distance from anchor (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 4. Tide + distance
|
||
new Chart('tideChart', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [
|
||
{ label: 'Depth (ft)', data: D.tide.depth_hours.map((h, i) => ({ x: h, y: D.tide.depth[i] })),
|
||
borderColor: 'rgba(79,195,247,0.7)', backgroundColor: 'rgba(79,195,247,0.15)', pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5, yAxisID: 'y' },
|
||
{ label: 'Distance (ft)', data: D.tide.dist_hours.map((h, i) => ({ x: h, y: D.tide.dist[i] })),
|
||
borderColor: 'rgba(244,132,95,0.7)', backgroundColor: 'rgba(244,132,95,0.15)', pointRadius: 0, showLine: true, fill: false, borderWidth: 1.5, yAxisID: 'y1' },
|
||
],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { position: 'left', title: { display: true, text: 'Depth (ft)', color: '#4fc3f7' }, ticks: { color: '#4fc3f7' }, grid: { color: '#2a2d3a' } },
|
||
y1: { position: 'right', title: { display: true, text: 'Distance (ft)', color: '#f4845f' }, ticks: { color: '#f4845f' }, grid: { drawOnChartArea: false } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 5. Wind effects
|
||
(function() {
|
||
const minD = Math.min(...D.wind_fx.depth);
|
||
const maxD = Math.max(...D.wind_fx.depth);
|
||
const colors = D.wind_fx.depth.map(d => depthColor(d, minD, maxD));
|
||
new Chart('windChart', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [{ label: 'Wind vs Distance', data: D.wind_fx.ws.map((w, i) => ({ x: w, y: D.wind_fx.dist[i] })),
|
||
backgroundColor: colors, pointRadius: 2, showLine: false }],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Wind speed (kts)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'Distance from anchor (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
})();
|
||
|
||
// 6a. Heading over time
|
||
new Chart('swingHdg', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [{ label: 'Heading', data: D.swing.hours.map((h, i) => ({ x: h, y: D.swing.hdg[i] })),
|
||
backgroundColor: 'rgba(195,154,244,0.3)', pointRadius: 1, showLine: false }],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: 'Heading (°)', color: '#8a8d9a' }, min: 0, max: 360, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 6b. Heading rate
|
||
new Chart('swingRate', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [{ label: 'Heading rate (°/s)', data: D.swing.hours.map((h, i) => ({ x: h, y: D.swing.hdg_rate[i] })),
|
||
backgroundColor: 'rgba(126,200,160,0.3)', pointRadius: 1, showLine: false }],
|
||
},
|
||
options: {
|
||
...commonOpts,
|
||
scales: {
|
||
x: { title: { display: true, text: 'Hours', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
y: { title: { display: true, text: '°/s', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#2a2d3a' } },
|
||
},
|
||
},
|
||
});
|
||
|
||
// 7. Polar as XY scatter with polar feel
|
||
(function() {
|
||
const data = D.polar.bearing.map((b, i) => {
|
||
const rad = (90 - b) * Math.PI / 180;
|
||
return { x: D.polar.radius[i] * Math.cos(rad), y: D.polar.radius[i] * Math.sin(rad) };
|
||
});
|
||
const wdData = D.polar.wd.map(b => {
|
||
const rad = (90 - b) * Math.PI / 180;
|
||
return { x: 170 * Math.cos(rad), y: 170 * Math.sin(rad) };
|
||
});
|
||
const ringPts = (r) => {
|
||
const pts = [];
|
||
for (let a = 0; a <= 360; a += 2) { const rad = a * Math.PI / 180; pts.push({ x: r * Math.cos(rad), y: r * Math.sin(rad) }); }
|
||
return pts;
|
||
};
|
||
new Chart('polarChart', {
|
||
type: 'scatter',
|
||
data: {
|
||
datasets: [
|
||
{ label: 'Vessel position', data, backgroundColor: 'rgba(79,195,247,0.3)', pointRadius: 1.5, showLine: false },
|
||
{ label: 'Wind direction', data: wdData, backgroundColor: 'rgba(244,132,95,0.5)', pointRadius: 3, pointStyle: 'triangle', showLine: false },
|
||
{ label: '50 ft', data: ringPts(50), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
|
||
{ label: '100 ft', data: ringPts(100), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
|
||
{ label: '150 ft', data: ringPts(150), borderColor: '#2a2d3a', pointRadius: 0, showLine: true, fill: false, borderWidth: 0.7, borderDash: [3, 3] },
|
||
{ label: 'Anchor', data: [{ x: 0, y: 0 }], backgroundColor: '#ff5252', pointRadius: 7, pointStyle: 'crossRot', showLine: false },
|
||
],
|
||
},
|
||
options: {
|
||
...commonOpts, aspectRatio: 1,
|
||
plugins: {
|
||
legend: { labels: { color: '#8a8d9a', filter: (item) => !['50 ft', '100 ft', '150 ft'].includes(item.text) } },
|
||
},
|
||
scales: {
|
||
x: { title: { display: true, text: 'East-West (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#1a1d27' } },
|
||
y: { title: { display: true, text: 'North-South (ft)', color: '#8a8d9a' }, ticks: { color: '#8a8d9a' }, grid: { color: '#1a1d27' } },
|
||
},
|
||
},
|
||
});
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def render_html(results):
|
||
s = results["stats"]
|
||
html = HTML_TEMPLATE
|
||
html = html.replace("DATA_JSON", json.dumps(results))
|
||
html = html.replace("CHAIN_LEN", str(int(CHAIN_LENGTH_FT)))
|
||
html = html.replace("DEPTH_RANGE", f'{s["depth_min"]}–{s["depth_max"]}')
|
||
html = html.replace("WIND_RANGE", f'{s["ws_min"]}–{s["ws_max"]}')
|
||
html = html.replace("DURATION", str(s["total_hours"]))
|
||
html = html.replace("ANCHOR_POS", f'{s["anchor_lat"]}°N, {s["anchor_lon"]}°W')
|
||
html = html.replace("OFFSET_STATS", f'{s["mean_offset"]}±{s["std_offset"]}')
|
||
html = html.replace("CORR_WS_2", str(s["corr_ws_dist"]))
|
||
html = html.replace("CORR_WS", str(s["corr_ws_dist"]))
|
||
sp = s["swing_period_min"]
|
||
html = html.replace("SWING_PERIOD_2", f"{sp} min" if sp else "N/A")
|
||
html = html.replace("SWING_PERIOD", f"{sp} min" if sp else "N/A")
|
||
html = html.replace("WS_MED", str(results["catenary"]["ws_median"]))
|
||
html = html.replace("EST_ROWS", f'{s["est_rows"]:,}')
|
||
html = html.replace("SENSOR_ROWS", f'{s["sensor_rows"]:,}')
|
||
return html
|
||
|
||
|
||
def main():
|
||
print("Loading data from track.db ...")
|
||
est, summary, raw = load_data()
|
||
print(f" estimation_log: {len(est)} rows")
|
||
print(f" summary_points: {len(summary)} rows")
|
||
print(f" raw_points: {len(raw)} rows")
|
||
|
||
print("Building unified time series ...")
|
||
merged, sensor = build_unified(est, summary, raw)
|
||
print(f" merged: {len(merged)} rows, sensor: {len(sensor)} rows")
|
||
|
||
print("Computing analyses ...")
|
||
results = compute_analyses(merged, sensor)
|
||
|
||
print("Rendering HTML report ...")
|
||
html = render_html(results)
|
||
with open(OUT_PATH, "w") as f:
|
||
f.write(html)
|
||
print(f"Report written to {OUT_PATH}")
|
||
print(f" ({len(html):,} bytes)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|