Files
venus/dbus-anchor-alarm/analysis/analyze_anchor.py
2026-03-26 14:15:02 +00:00

672 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 &bull; Depth: DEPTH_RANGE ft &bull; Wind: WIND_RANGE kts &bull; 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&plusmn;std)</div><div class="value">OFFSET_STATS&deg;</div></div>
<div class="stat-card"><div class="label">Wind&harr;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&rarr;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 &minus; 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) &amp; 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()