diff --git "a/demos/pages/1_VPP_\342\233\265.py" "b/demos/pages/1_VPP_\342\233\265.py"
index b7629e3..f4a3bc8 100644
--- "a/demos/pages/1_VPP_\342\233\265.py"
+++ "b/demos/pages/1_VPP_\342\233\265.py"
@@ -1,4 +1,3 @@
-import json
import logging
import os
import sys
@@ -6,54 +5,39 @@
import matplotlib.pyplot as plt
import numpy as np
+import pandas as pd
import streamlit as st
-from utils import footer, header
+from presets import PRESETS
+from utils import (
+ FIELD_HELP,
+ KITE_SAIL_TYPES,
+ JIB_SAIL_TYPES,
+ MAIN_SAIL_TYPES,
+ field_label,
+ footer,
+ header,
+ render_data_source,
+ render_environment_inputs,
+ render_keel_inputs,
+ render_roughness_input,
+ render_sail_type,
+ render_solver_method,
+ run_vpp_direct,
+ validate_ranges,
+)
sys.path.append(os.path.realpath("."))
-from src.api import app
-from src.UtilsMod import KNOTS_TO_MPS, _get_cross, _get_vmg, _polar, cols, stl
+from src.UtilsMod import KNOTS_TO_MPS, _get_cross, _get_vmg, _polar, cols, lab, stl
st.set_page_config(page_title="VPP", page_icon="⛵")
-def process_yacht_specifications(
- tws_range: List[int],
- twa_range: List[int],
- yacht: Dict,
- keel: Dict,
- rudder: Dict,
- main: Dict,
- jib: Dict,
- kite: Dict,
-):
- data = {
- "name": yacht["Name"],
- "yacht": yacht,
- "keel": keel,
- "rudder": rudder,
- "main": main,
- "jib": jib,
- "kite": kite,
- "tws_range": tws_range,
- "twa_range": twa_range,
- }
-
- logging.info("Starting VPP simulation")
- json_string = json.dumps(data)
- headers = {"content-type": "application/json", "Accept-Charset": "UTF-8"}
- client = app.test_client()
- response = client.post("/api/vpp/", data=json_string, headers=headers)
-
- logging.info("VPP simulation completed")
- return response
-
-
-def plot_single_polar(response: Dict[str, Any]) -> plt.Figure:
- name = response.json["name"]
- sails = response.json["sails"]
- twa_range = np.array(response.json["twa"])
- tws_range = np.array(response.json["tws"])
- results = np.array(response.json["results"])
+def plot_single_polar(data: Dict[str, Any]) -> plt.Figure:
+ name = data["name"]
+ sails = data["sails"]
+ twa_range = np.array(data["twa"])
+ tws_range = np.array(data["tws"])
+ results = np.array(data["results"])
n = 1
@@ -66,7 +50,7 @@ def plot_single_polar(response: Dict[str, Any]) -> plt.Figure:
for j in range(n):
lab = "_nolegend_"
if k == 0:
- lab = name + " " + f"{tws_range[i]/KNOTS_TO_MPS:.1f}"
+ lab = f"{tws_range[i]/KNOTS_TO_MPS:.1f}"
ax[j].plot(
twa_range[idx[0] : idx[1]] / 180 * np.pi,
@@ -94,27 +78,76 @@ def plot_single_polar(response: Dict[str, Any]) -> plt.Figure:
return fig
-yacht = {
- "Name": "YD41",
- "Lwl": 11.90,
- "Vol": 6.05,
- "Bwl": 3.18,
- "Tc": 0.4,
- "WSA": 28.20,
- "Tmax": 2.30,
- "Amax": 1.051,
- "Mass": 6500,
- "Ff": 1.5,
- "Fa": 1.5,
- "Boa": 4.2,
- "Loa": 12.5,
-}
-
-keel = {"Cu": 1.00, "Cl": 0.78, "Span": 1.90}
-rudder = {"Cu": 0.48, "Cl": 0.22, "Span": 1.15}
-main = {"Name": "MN1", "P": 16.60, "E": 5.60, "Roach": 0.1, "BAD": 1.0}
-jib = {"Name": "J1", "I": 16.20, "J": 5.10, "LPG": 5.40, "HBI": 1.8}
-kite = {"Name": "A2", "area": 150.0, "vce": 9.55}
+def plot_depowering_polar(data: Dict[str, Any]) -> plt.Figure:
+ """Plot flat and red depowering values on polar axes."""
+ name = data["name"]
+ sails = data["sails"]
+ twa_range = np.array(data["twa"])
+ tws_range = np.array(data["tws"])
+ results = np.array(data["results"])
+
+ fig, axes = plt.subplots(1, 2, subplot_kw=dict(polar=True), figsize=(12, 6))
+ for ax_i, (idx, title) in enumerate([(3, "Flat"), (4, "RED")]):
+ ax = axes[ax_i]
+ ax.set_xticks(np.linspace(0, np.pi, 5))
+ ax.set_theta_direction(-1)
+ ax.set_theta_offset(np.pi / 2.0)
+ ax.set_thetamin(0)
+ ax.set_thetamax(180)
+ ax.set_rmin(0.0)
+ ax.set_xlabel(r"TWA ($^\circ$)")
+ ax.set_ylabel(title, labelpad=-40)
+
+ for i in range(len(tws_range)):
+ for k in range(len(sails)):
+ cross = _get_cross(results[i, :, :, :], k)
+ label = "_nolegend_"
+ if k == 0:
+ label = f"{tws_range[i]/KNOTS_TO_MPS:.1f}"
+ ax.plot(
+ twa_range[cross[0] : cross[1]] / 180 * np.pi,
+ results[i, cross[0] : cross[1], k, idx],
+ color=cols[k % 7],
+ lw=np.where(i < 7, 1.5, 2.5),
+ linestyle=stl[i % 7],
+ label=label,
+ )
+ if ax_i == 0:
+ ax.legend(title=r"TWS (kts)", loc=1, bbox_to_anchor=(1.05, 1.05))
+ plt.tight_layout()
+ return fig
+
+
+def build_depowering_table(data: Dict[str, Any]) -> pd.DataFrame:
+ """Build a table of depowering values for the best sail at each TWS/TWA."""
+ sails = data["sails"]
+ twa_range = np.array(data["twa"])
+ tws_range = np.array(data["tws"])
+ results = np.array(data["results"])
+
+ rows = []
+ for i, tws in enumerate(tws_range):
+ tws_kts = tws / KNOTS_TO_MPS
+ for j, twa in enumerate(twa_range):
+ best_sail = int(np.argmax(results[i, j, :, 0]))
+ vb = results[i, j, best_sail, 0]
+ flat = results[i, j, best_sail, 3]
+ red = results[i, j, best_sail, 4]
+ if flat < 1.0 or red < 2.0:
+ rows.append(
+ {
+ "TWS (kts)": f"{tws_kts:.0f}",
+ "TWA (°)": f"{twa:.0f}",
+ "Sail": sails[best_sail],
+ "Vb (kts)": f"{vb:.2f}",
+ "Flat": f"{flat:.2f}",
+ "RED": f"{red:.2f}",
+ }
+ )
+ if not rows:
+ return pd.DataFrame({"Info": ["No depowering applied at these wind speeds"]})
+ return pd.DataFrame(rows)
+
header()
@@ -122,52 +155,107 @@ def plot_single_polar(response: Dict[str, Any]) -> plt.Figure:
"""
# Yacht VPP
- This is a 3 D.O.F. VPP for a mono hull displacement sailing yacht.
-
- The default parameters are pre-set particulars for the YD-41 yacht.
+ This is a 3 D.O.F. VPP for a mono hull displacement sailing yacht.
+ The performance model is based on the
+ [ORC VPP documentation](https://www.orc.org/rules/ORC%20VPP%20Documentation%202024.pdf).
"""
)
+with st.popover("ℹ️ What is a VPP?"):
+ st.markdown(
+ "A Velocity Prediction Program computes the "
+ "equilibrium speed of a sailing yacht at each combination of true "
+ "wind speed (TWS) and true wind angle (TWA). It balances "
+ "aerodynamic driving force against hydrodynamic resistance, side "
+ "force against keel lift, and heeling moment against righting moment."
+ )
+
+preset_name = st.selectbox("Yacht preset", list(PRESETS.keys()), index=1)
+preset = PRESETS[preset_name]
+yacht = dict(preset["yacht"])
+keel = dict(preset["keel"])
+rudder = dict(preset["rudder"])
+main = dict(preset["main"])
+jib = dict(preset["jib"])
+kite = dict(preset["kite"])
+
st.subheader("Yacht particulars")
for key, value in yacht.items():
- yacht[key] = st.text_input(f"{key}:", value)
+ yacht[key] = st.text_input(field_label(key), value, help=FIELD_HELP.get(key, ""))
+roughness = render_roughness_input(key_prefix="vpp")
st.subheader("Keel")
-for key, value in keel.items():
- keel[key] = st.text_input(f"{key}:", value)
+keel = render_keel_inputs(keel, key_prefix="vpp")
st.subheader("Rudder")
for key, value in rudder.items():
- rudder[key] = st.text_input(f"{key}:", value)
+ rudder[key] = st.text_input(field_label(key), value, help=FIELD_HELP.get(key, ""))
st.subheader("Main Sail")
+main_sail_type = render_sail_type("Main sail", MAIN_SAIL_TYPES, key_prefix="vpp_main")
for key, value in main.items():
- main[key] = st.text_input(f"{key}:", value)
+ main[key] = st.text_input(field_label(key), value, help=FIELD_HELP.get(key, ""))
st.subheader("Jib")
+jib_sail_type = render_sail_type("Jib", JIB_SAIL_TYPES, key_prefix="vpp_jib")
for key, value in jib.items():
- jib[key] = st.text_input(f"{key}:", value)
+ jib[key] = st.text_input(field_label(key), value, help=FIELD_HELP.get(key, ""))
st.subheader("Kite (Spinnaker)")
+kite_sail_type = render_sail_type("Kite", KITE_SAIL_TYPES, key_prefix="vpp_kite")
for key, value in kite.items():
- kite[key] = st.text_input(f"{key}:", value)
+ kite[key] = st.text_input(field_label(key), value, help=FIELD_HELP.get(key, ""))
-st.subheader("Environment")
-twa_slider = st.slider(
- "True wind angle (TWA) range", 35.0, 175.0, (35.0, 175.0), step=2.0
-)
-twa_range = np.arange(twa_slider[0], twa_slider[1], 2.0).tolist()
+tws_range, twa_range, env_params = render_environment_inputs(key_prefix="vpp")
-tws_slider = st.slider("True wind speed (TWS) range", 2.0, 25.0, (8.0, 12.0), step=2.0)
-tws_range = np.arange(tws_slider[0], tws_slider[1], 2.0).tolist()
+st.subheader("Solver Settings")
+solver_method = render_solver_method(key_prefix="vpp")
+data_source = render_data_source(key_prefix="vpp")
if st.button("Process Specifications"):
- with st.spinner("Running optimisation, this can take a minute or two."):
- response = process_yacht_specifications(
- tws_range, twa_range, yacht, keel, rudder, main, jib, kite
- )
- fig = plot_single_polar(response)
- st.pyplot(fig)
+ if validate_ranges(tws_range, twa_range):
+ config = {"yacht": yacht, "keel": keel, "rudder": rudder, "main": main, "jib": jib, "kite": kite}
+ sail_types = {"main": main_sail_type, "jib": jib_sail_type, "kite": kite_sail_type}
+ env_params["roughness"] = roughness
+ with st.status("Running VPP optimisation...", expanded=True) as status:
+ def _on_tws(i, tws_kts, n_tws):
+ st.write(f"TWS {tws_kts:.0f} kts complete ({i + 1}/{n_tws})")
+
+ result, error = run_vpp_direct(
+ config, tws_range, twa_range, method=solver_method,
+ data_source=data_source, sail_types=sail_types,
+ env_params=env_params, progress_callback=_on_tws,
+ )
+ status.update(label="Optimisation complete!", state="complete", expanded=False)
+ if error:
+ st.error(f"Simulation failed: {error}")
+ logging.error("VPP failed: %s", error)
+ else:
+ with st.popover("ℹ️ What is a polar plot?"):
+ st.markdown(
+ "The polar plot shows boat speed (radial "
+ "axis) vs true wind angle. Each curve is a different wind "
+ "speed. Dots mark the best VMG (velocity made good) angles "
+ "upwind and downwind."
+ )
+ fig = plot_single_polar(result)
+ st.pyplot(fig)
+
+ st.subheader("Depowering (Flat & RED)")
+ with st.popover("ℹ️ What is depowering?"):
+ st.markdown(
+ "*Flat* controls how much the sails are "
+ "flattened (1.0 = full power, 0.62 = maximum depower). "
+ "*RED* is the reef/reduction factor (2.0 = full sail, "
+ "lower = reefed). The VPP depowers automatically when "
+ "heel exceeds the limit."
+ )
+ dep_fig = plot_depowering_polar(result)
+ st.pyplot(dep_fig)
+
+ with st.expander("Depowering data table"):
+ df = build_depowering_table(result)
+ st.dataframe(df, use_container_width=True)
footer()
diff --git "a/demos/pages/2_Compare_\342\232\226\357\270\217.py" "b/demos/pages/2_Compare_\342\232\226\357\270\217.py"
new file mode 100644
index 0000000..8637cc1
--- /dev/null
+++ "b/demos/pages/2_Compare_\342\232\226\357\270\217.py"
@@ -0,0 +1,335 @@
+import copy
+import os
+import sys
+from typing import Any, Dict, List
+
+import numpy as np
+import pandas as pd
+import plotly.graph_objects as go
+import streamlit as st
+from presets import PRESETS
+from utils import (
+ FIELD_HELP,
+ KITE_SAIL_TYPES,
+ JIB_SAIL_TYPES,
+ MAIN_SAIL_TYPES,
+ field_label,
+ footer,
+ header,
+ render_data_source,
+ render_environment_inputs,
+ render_keel_inputs,
+ render_roughness_input,
+ render_sail_type,
+ render_solver_method,
+ run_vpp_direct,
+ validate_ranges,
+)
+
+sys.path.append(os.path.realpath("."))
+from src.UtilsMod import KNOTS_TO_MPS
+
+st.set_page_config(page_title="Compare", page_icon="⚖️", layout="wide")
+
+SECTIONS = [
+ ("Yacht", "yacht"),
+ ("Keel", "keel"),
+ ("Rudder", "rudder"),
+ ("Main Sail", "main"),
+ ("Jib", "jib"),
+ ("Kite", "kite"),
+]
+
+SECTION_TITLES = {k: title for title, k in SECTIONS}
+
+# Colours for up to 6 configs (Plotly named colors)
+CONFIG_COLORS = [
+ "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b",
+]
+PLOTLY_DASH = ["solid", "dash", "dot", "dashdot", "longdash", "longdashdot"]
+
+
+def render_config_tab(key_prefix: str, default_index: int = 1, baseline: Dict = None):
+ """Render preset selector and editable fields. Returns config dict.
+
+ If baseline is provided, fields that differ from the baseline are
+ highlighted with a coloured background via custom CSS.
+ """
+ preset_name = st.selectbox(
+ "Preset", list(PRESETS.keys()), index=default_index, key=f"{key_prefix}_preset"
+ )
+ preset = PRESETS[preset_name]
+ config = {}
+ changed_fields = []
+
+ sail_type_options = {"main": MAIN_SAIL_TYPES, "jib": JIB_SAIL_TYPES, "kite": KITE_SAIL_TYPES}
+ sail_types = {}
+ for title, section_key in SECTIONS:
+ section = copy.deepcopy(preset[section_key])
+ with st.expander(title, expanded=False):
+ if section_key == "keel":
+ section = render_keel_inputs(section, key_prefix=key_prefix)
+ else:
+ if section_key in sail_type_options:
+ sail_types[section_key] = render_sail_type(
+ title, sail_type_options[section_key],
+ key_prefix=f"{key_prefix}_{section_key}",
+ )
+ for field, value in section.items():
+ input_key = f"{key_prefix}_{section_key}_{field}"
+ section[field] = st.text_input(field_label(field), value, key=input_key, help=FIELD_HELP.get(field, ""))
+
+ # Track which fields differ from baseline
+ if baseline is not None:
+ for field, value in section.items():
+ base_val = str(baseline.get(section_key, {}).get(field, ""))
+ if str(value) != base_val:
+ changed_fields.append((section_key, field, value, base_val))
+
+ config[section_key] = section
+ config["_roughness"] = render_roughness_input(key_prefix=key_prefix)
+ config["_sail_types"] = sail_types
+
+ if baseline is not None and changed_fields:
+ lines = []
+ for sec, fld, new_val, old_val in changed_fields:
+ section_title = SECTION_TITLES.get(sec, sec)
+ label = field_label(fld)
+ lines.append(f"- **{section_title}** {label}: `{old_val}` → `{new_val}`")
+ changes_md = "\n".join(lines)
+ if len(changed_fields) <= 4:
+ st.caption("Changes vs Config 1:")
+ st.markdown(changes_md)
+ else:
+ with st.expander(f"Changes vs Config 1 ({len(changed_fields)} fields)"):
+ st.markdown(changes_md)
+
+ return config
+
+
+def plot_comparison_polar(responses: List) -> go.Figure:
+ """Overlay N VPP results on the same polar plot with distinct colours."""
+ twa = np.array(responses[0]["twa"])
+ tws = np.array(responses[0]["tws"])
+
+ fig = go.Figure()
+ for ci, resp in enumerate(responses):
+ results = np.array(resp["results"])
+ name = resp["name"]
+ color = CONFIG_COLORS[ci % len(CONFIG_COLORS)]
+
+ for i in range(len(tws)):
+ tws_kts = tws[i] / KNOTS_TO_MPS
+ speed = np.max(results[i, :, :, 0], axis=1)
+
+ show_legend = i == 0
+ fig.add_trace(go.Scatterpolar(
+ theta=twa,
+ r=speed,
+ mode="lines",
+ name=f"{name} (#{ci + 1})",
+ legendgroup=f"config_{ci}",
+ showlegend=show_legend,
+ line=dict(
+ color=color,
+ width=2,
+ dash=PLOTLY_DASH[i % len(PLOTLY_DASH)],
+ ),
+ hovertemplate=(
+ f"{name} TWS={tws_kts:.0f}kts
"
+ "TWA=%{theta:.0f}°
"
+ "Speed=%{r:.2f}kts"
+ ),
+ ))
+
+ fig.update_layout(
+ polar=dict(
+ angularaxis=dict(
+ direction="clockwise",
+ rotation=90,
+ dtick=15,
+ ticksuffix="°",
+ ),
+ radialaxis=dict(
+ angle=90,
+ title="Speed (kts)",
+ ),
+ sector=[-90, 90],
+ ),
+ legend=dict(title="Configuration"),
+ height=600,
+ )
+ return fig
+
+
+def build_delta_table(responses: List) -> pd.DataFrame:
+ """Build a table showing speed differences vs the first config."""
+ twa = np.array(responses[0]["twa"])
+ tws = np.array(responses[0]["tws"])
+ base_results = np.array(responses[0]["results"])
+ base_name = responses[0]["name"]
+
+ rows = []
+ for i, tw in enumerate(tws):
+ tws_kts = tw / KNOTS_TO_MPS
+ speed_base = np.max(base_results[i, :, :, 0], axis=1)
+ for j, angle in enumerate(twa):
+ row = {
+ "TWS (kts)": f"{tws_kts:.0f}",
+ "TWA (°)": f"{angle:.0f}",
+ f"#{1} {base_name} (kts)": f"{speed_base[j]:.2f}",
+ }
+ for ci, resp in enumerate(responses[1:], start=2):
+ res = np.array(resp["results"])
+ name = resp["name"]
+ speed = np.max(res[i, :, :, 0], axis=1)[j]
+ delta = speed - speed_base[j]
+ pct = (delta / speed_base[j] * 100) if speed_base[j] > 0 else 0.0
+ row[f"#{ci} {name} (kts)"] = f"{speed:.2f}"
+ row[f"Δ#{ci} (kts)"] = f"{delta:+.2f}"
+ row[f"Δ#{ci} (%)"] = f"{pct:+.1f}"
+ rows.append(row)
+ return pd.DataFrame(rows)
+
+
+def _build_vmg_section(responses: List, point: str, sign: int) -> pd.DataFrame:
+ """Build a VMG table for one point of sail (upwind or downwind)."""
+ twa = np.array(responses[0]["twa"])
+ tws = np.array(responses[0]["tws"])
+
+ rows = []
+ for i, tw in enumerate(tws):
+ tws_kts = tw / KNOTS_TO_MPS
+ row = {"TWS (kts)": f"{tws_kts:.0f}"}
+ base_vmg = None
+ for ci, resp in enumerate(responses, start=1):
+ res = np.array(resp["results"])
+ name = resp["name"]
+ speed = np.max(res[i, :, :, 0], axis=1)
+ vmg = sign * speed * np.cos(twa / 180 * np.pi)
+ idx = np.argmax(vmg)
+ row[f"#{ci} {name} TWA"] = f"{twa[idx]:.0f}°"
+ row[f"#{ci} {name} VMG (kts)"] = f"{vmg[idx]:.2f}"
+ if ci == 1:
+ base_vmg = vmg[idx]
+ else:
+ delta = vmg[idx] - base_vmg
+ pct = (delta / base_vmg * 100) if base_vmg > 0 else 0.0
+ # seconds per nautical mile difference
+ spm_base = (3600.0 / base_vmg) if base_vmg > 0 else 0.0
+ spm_new = (3600.0 / vmg[idx]) if vmg[idx] > 0 else 0.0
+ delta_spm = spm_new - spm_base
+ row[f"Δ#{ci} (kts)"] = f"{delta:+.2f}"
+ row[f"Δ#{ci} (%)"] = f"{pct:+.1f}%"
+ row[f"Δ#{ci} (s/NM)"] = f"{delta_spm:+.1f}"
+ rows.append(row)
+ return pd.DataFrame(rows)
+
+
+# --- Session state for dynamic tabs ---
+
+if "num_configs" not in st.session_state:
+ st.session_state.num_configs = 2
+
+
+# --- Page layout ---
+
+header()
+
+st.markdown(
+ """
+ # Compare Configurations
+
+ Set up multiple configurations and compare their performance.
+ Change any parameter — sail dimensions, keel shape, crew weight — and
+ see the effect on boat speed and VMG. Fields that differ from Config 1
+ are listed below each tab.
+"""
+)
+
+with st.popover("ℹ️ How to use"):
+ st.markdown(
+ "Set up two or more configurations with different "
+ "parameters (e.g. different keel shapes, sail areas, or hull dimensions). "
+ "The comparison overlay shows all polars on the same plot, and the delta "
+ "table quantifies speed differences at each TWS/TWA point."
+ )
+
+# Add / remove config buttons
+btn_cols = st.columns([1, 1, 6])
+with btn_cols[0]:
+ if st.button("+ Add config"):
+ if st.session_state.num_configs < 6:
+ st.session_state.num_configs += 1
+ st.rerun()
+with btn_cols[1]:
+ if st.button("- Remove last"):
+ if st.session_state.num_configs > 2:
+ st.session_state.num_configs -= 1
+ st.rerun()
+
+num = st.session_state.num_configs
+tab_labels = [f"Config {i + 1}" for i in range(num)]
+tabs = st.tabs(tab_labels)
+
+configs = []
+for idx, tab in enumerate(tabs):
+ with tab:
+ # First config has no baseline; others compare against Config 1
+ if idx == 0:
+ cfg = render_config_tab(f"cfg{idx}", default_index=1)
+ else:
+ cfg = render_config_tab(
+ f"cfg{idx}", default_index=1, baseline=configs[0] if configs else None
+ )
+ configs.append(cfg)
+
+tws_range, twa_range, env_params = render_environment_inputs(key_prefix="cmp")
+
+st.subheader("Solver Settings")
+solver_method = render_solver_method(key_prefix="cmp")
+data_source = render_data_source(key_prefix="cmp")
+
+if st.button("Compare"):
+ if validate_ranges(tws_range, twa_range):
+ responses = []
+ failed = False
+ with st.status(f"Running {num} VPP simulations...", expanded=True) as status:
+ for ci, cfg in enumerate(configs):
+ sail_types = cfg.pop("_sail_types", None)
+ cfg_roughness = cfg.pop("_roughness", 150e-6)
+ cfg_env = dict(env_params, roughness=cfg_roughness)
+ cfg_name = cfg.get("yacht", {}).get("Name", f"Config {ci + 1}")
+ st.write(f"**{cfg_name}** (config {ci + 1} of {num})")
+
+ def _on_tws(i, tws_kts, n_tws, _name=cfg_name):
+ st.write(f" {_name}: TWS {tws_kts:.0f} kts complete ({i + 1}/{n_tws})")
+
+ result, error = run_vpp_direct(
+ cfg, tws_range, twa_range, method=solver_method,
+ data_source=data_source, sail_types=sail_types,
+ env_params=cfg_env, progress_callback=_on_tws,
+ )
+ if error:
+ st.error(f"Config {ci + 1} failed: {error}")
+ failed = True
+ break
+ responses.append(result)
+ status.update(label="Simulations complete!", state="complete", expanded=False)
+
+ if not failed and len(responses) == num:
+ st.subheader("Overlaid polars")
+ fig = plot_comparison_polar(responses)
+ st.plotly_chart(fig, use_container_width=True)
+
+ st.subheader("VMG comparison")
+ st.markdown("**Upwind**")
+ st.dataframe(_build_vmg_section(responses, "Upwind", 1), use_container_width=True, hide_index=True)
+ st.markdown("**Downwind**")
+ st.dataframe(_build_vmg_section(responses, "Downwind", -1), use_container_width=True, hide_index=True)
+
+ with st.expander("Full speed delta table"):
+ delta_df = build_delta_table(responses)
+ st.dataframe(delta_df, use_container_width=True)
+
+footer()
diff --git a/demos/presets.py b/demos/presets.py
new file mode 100644
index 0000000..e00a0b9
--- /dev/null
+++ b/demos/presets.py
@@ -0,0 +1,48 @@
+"""Shared yacht preset configurations for the Streamlit UI."""
+
+PRESETS = {
+ "YD41": {
+ "yacht": {
+ "Name": "YD41",
+ "Lwl": 11.90,
+ "Vol": 6.05,
+ "Bwl": 3.18,
+ "Tc": 0.4,
+ "WSA": 28.20,
+ "Tmax": 2.30,
+ "Amax": 1.051,
+ "Mass": 6500,
+ "Ff": 1.5,
+ "Fa": 1.5,
+ "Boa": 4.2,
+ "Loa": 12.5,
+ },
+ "keel": {"Cu": 1.00, "Cl": 0.78, "Span": 1.90},
+ "rudder": {"Cu": 0.48, "Cl": 0.22, "Span": 1.15},
+ "main": {"Name": "MN1", "P": 16.60, "E": 5.60, "Roach": 0.1, "BAD": 1.0},
+ "jib": {"Name": "J1", "I": 16.20, "J": 5.10, "LPG": 5.40, "HBI": 1.8},
+ "kite": {"Name": "A2", "area": 150.0, "vce": 9.55},
+ },
+ "Daring (5.5m)": {
+ "yacht": {
+ "Name": "Daring",
+ "Lwl": 7.01,
+ "Vol": 1.95,
+ "Bwl": 1.70,
+ "Tc": 0.45,
+ "WSA": 11.5,
+ "Tmax": 1.35,
+ "Amax": 0.38,
+ "Mass": 2000,
+ "Ff": 0.75,
+ "Fa": 0.55,
+ "Boa": 1.98,
+ "Loa": 9.90,
+ },
+ "keel": {"type": "short", "Length": 1.2, "Depth": 0.90, "Tc_ratio": 0.15},
+ "rudder": {"Cu": 0.32, "Cl": 0.18, "Span": 0.75},
+ "main": {"Name": "MN1", "P": 10.80, "E": 3.30, "Roach": 0.1, "BAD": 0.80},
+ "jib": {"Name": "J1", "I": 8.50, "J": 2.70, "LPG": 2.70, "HBI": 0.50},
+ "kite": {"Name": "S1", "area": 50.0, "vce": 4.50},
+ },
+}
diff --git a/demos/utils.py b/demos/utils.py
index 2ea5bce..0ffb351 100644
--- a/demos/utils.py
+++ b/demos/utils.py
@@ -1,7 +1,17 @@
+import json
+import logging
+import os
+import sys
+from typing import Dict, List, Tuple
+
+import numpy as np
import streamlit as st
import streamlit.components.v1 as components
import subprocess
+sys.path.append(os.path.realpath("."))
+from src.api import app
+
def get_git_hash():
try:
git_hash = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).strip().decode('utf-8')
@@ -27,6 +37,345 @@ def header():
return components.html(header)
+FIN_KEEL_DEFAULTS = {"Cu": 1.00, "Cl": 0.78, "Span": 1.90}
+SHORT_KEEL_DEFAULTS = {"Length": 1.2, "Depth": 0.90, "Tc_ratio": 0.15}
+
+
+def render_keel_inputs(keel: dict, key_prefix: str = "") -> dict:
+ """Render keel type selector and matching parameter inputs.
+
+ Pops the ``type`` key from *keel*, shows a selectbox, then renders
+ only the fields appropriate for that keel type. Returns a new dict
+ with the selected type and parameter values.
+ """
+ keel_type = keel.pop("type", "fin")
+ keel_type = st.selectbox(
+ "Keel type",
+ ["fin", "short"],
+ index=["fin", "short"].index(keel_type),
+ key=f"{key_prefix}_keel_type",
+ )
+ defaults = SHORT_KEEL_DEFAULTS if keel_type == "short" else FIN_KEEL_DEFAULTS
+ result = {}
+ for field, default in defaults.items():
+ input_key = f"{key_prefix}_keel_{field}"
+ result[field] = st.text_input(field_label(field), keel.get(field, default), key=input_key, help=FIELD_HELP.get(field, ""))
+ result["type"] = keel_type
+ return result
+
+
+def render_solver_method(key_prefix: str = "") -> str:
+ """Render solver method selectbox, return selected method string."""
+ return st.selectbox(
+ "Solver method",
+ ["iterative", "5dof"],
+ index=0,
+ key=f"{key_prefix}_solver_method",
+ help="'iterative' = 3-DOF with depowering loop; '5dof' = scipy SLSQP 5-DOF optimizer",
+ )
+
+
+def render_data_source(key_prefix: str = "") -> str:
+ """Render data source selectbox, return selected data source string."""
+ return st.selectbox(
+ "Sail coefficient data source",
+ ["orc"],
+ index=0,
+ key=f"{key_prefix}_data_source",
+ help="Coefficient data directory under dat/",
+ )
+
+
+FIELD_HELP = {
+ # Yacht hull
+ "Name": "Yacht design name (label only).",
+ "Lwl": "Waterline length (m). Longer = faster hull speed.",
+ "Vol": "Displaced volume of the canoe body (m³).",
+ "Bwl": "Waterline beam (m). Wider = more initial stability.",
+ "Tc": "Canoe body draft (m). Depth of hull excluding keel.",
+ "WSA": "Wetted surface area of the canoe body (m²). Drives viscous drag.",
+ "Tmax": "Maximum draft including keel (m).",
+ "Amax": "Maximum cross-section area (m²).",
+ "Mass": "Total displacement mass including keel (kg).",
+ "Ff": "Freeboard height at the bow (m).",
+ "Fa": "Freeboard height at the stern (m).",
+ "Boa": "Beam overall (m).",
+ "Loa": "Length overall (m).",
+ # Fin keel
+ "Cu": "Root (upper) chord length (m).",
+ "Cl": "Tip (lower) chord length (m).",
+ "Span": "Appendage span / depth (m).",
+ # Short keel
+ "Length": "Fore-aft keel length along hull bottom (m).",
+ "Depth": "Keel depth below canoe body (m).",
+ "Tc_ratio": "Thickness-to-chord ratio (e.g. 0.15 = 15%).",
+ # Main sail
+ "P": "Luff length / mast height above boom (m).",
+ "E": "Foot length along the boom (m).",
+ "Roach": "Sail roach as fraction of triangle area (0.0–0.3).",
+ "BAD": "Boom above deck height (m).",
+ # Jib
+ "I": "Forestay height above deck (m).",
+ "J": "Base of foretriangle — mast to forestay at deck (m).",
+ "LPG": "Longest perpendicular of genoa/jib (m). Larger = more overlap.",
+ "HBI": "Height of jib tack above deck (m).",
+ # Kite
+ "area": "Spinnaker sail area (m²).",
+ "vce": "Vertical centre of effort above deck (m).",
+}
+
+FIELD_LABELS = {
+ # Yacht hull
+ "Name": "Name",
+ "Lwl": r"$L_{wl}$ (m)",
+ "Vol": r"$\nabla$ (m³)",
+ "Bwl": r"$B_{wl}$ (m)",
+ "Tc": r"$T_c$ (m)",
+ "WSA": r"$S_{wet}$ (m²)",
+ "Tmax": r"$T_{max}$ (m)",
+ "Amax": r"$A_{max}$ (m²)",
+ "Mass": r"$\Delta m$ (kg)",
+ "Ff": r"$F_f$ (m)",
+ "Fa": r"$F_a$ (m)",
+ "Boa": r"$B_{oa}$ (m)",
+ "Loa": r"$L_{oa}$ (m)",
+ # Fin keel
+ "Cu": r"$C_u$ (m)",
+ "Cl": r"$C_l$ (m)",
+ "Span": r"$b$ (m)",
+ # Short keel
+ "Length": r"$L_{keel}$ (m)",
+ "Depth": r"$D_{keel}$ (m)",
+ "Tc_ratio": r"$t/c$",
+ # Main sail
+ "P": r"$P$ (m)",
+ "E": r"$E$ (m)",
+ "Roach": "Roach",
+ "BAD": r"$BAD$ (m)",
+ # Jib
+ "I": r"$I$ (m)",
+ "J": r"$J$ (m)",
+ "LPG": r"$LPG$ (m)",
+ "HBI": r"$HBI$ (m)",
+ # Kite
+ "area": r"$A_{kite}$ (m²)",
+ "vce": r"$VCE$ (m)",
+}
+
+
+def field_label(key: str) -> str:
+ """Return the mathematical display label for a field, falling back to the key."""
+ return FIELD_LABELS.get(key, key)
+
+
+MAIN_SAIL_TYPES = ["main", "main_low"]
+JIB_SAIL_TYPES = ["jib", "jib_low"]
+KITE_SAIL_TYPES = ["kite", "sym_kite", "asym_cl_kite", "asym_pole_kite"]
+
+SAIL_TYPE_HELP = {
+ "main": "ORC high-performance mainsail",
+ "main_low": "ORC low-performance mainsail (lower CL)",
+ "jib": "ORC high-performance jib",
+ "jib_low": "ORC low-performance jib (lower CL)",
+ "kite": "Default asymmetric spinnaker",
+ "sym_kite": "ORC symmetric spinnaker (higher CL)",
+ "asym_cl_kite": "ORC asymmetric spinnaker, centerline tack",
+ "asym_pole_kite": "ORC asymmetric spinnaker, pole tack",
+}
+
+
+def render_sail_type(label: str, options: list, key_prefix: str = "") -> str:
+ """Render a sail type selectbox and return the selected type."""
+ return st.selectbox(
+ f"{label} type",
+ options,
+ index=0,
+ key=f"{key_prefix}_sail_type",
+ help=", ".join(f"{o}: {SAIL_TYPE_HELP[o]}" for o in options),
+ )
+
+
+def run_vpp(
+ config: Dict,
+ tws_range: List[float],
+ twa_range: List[float],
+ method: str = "iterative",
+ data_source: str = "orc",
+ sail_types: Dict[str, str] = None,
+ env_params: Dict = None,
+):
+ """Post a yacht configuration to the VPP API and return the response.
+
+ Parameters
+ ----------
+ sail_types : dict, optional
+ Mapping of sail section to sail_type, e.g.
+ ``{"main": "main_low", "jib": "jib", "kite": "sym_kite"}``.
+ env_params : dict, optional
+ Environment parameters (roughness, Hs, Ts).
+ """
+ data = _build_vpp_data(config, tws_range, twa_range, method, data_source, sail_types, env_params)
+ logging.info("Starting VPP simulation")
+ json_string = json.dumps(data)
+ headers = {"content-type": "application/json", "Accept-Charset": "UTF-8"}
+ client = app.test_client()
+ response = client.post("/api/vpp/", data=json_string, headers=headers)
+ logging.info("VPP simulation completed")
+ return response
+
+
+def _build_vpp_data(
+ config: Dict,
+ tws_range: List[float],
+ twa_range: List[float],
+ method: str = "iterative",
+ data_source: str = "orc",
+ sail_types: Dict[str, str] = None,
+ env_params: Dict = None,
+) -> Dict:
+ """Build the VPP request data dict from a config."""
+ main = dict(config["main"])
+ jib = dict(config["jib"])
+ kite = dict(config["kite"])
+ if sail_types:
+ if "main" in sail_types:
+ main["sail_type"] = sail_types["main"]
+ if "jib" in sail_types:
+ jib["sail_type"] = sail_types["jib"]
+ if "kite" in sail_types:
+ kite["sail_type"] = sail_types["kite"]
+ data = {
+ "name": config["yacht"]["Name"],
+ "yacht": config["yacht"],
+ "keel": config["keel"],
+ "rudder": config["rudder"],
+ "main": main,
+ "jib": jib,
+ "kite": kite,
+ "tws_range": tws_range,
+ "twa_range": twa_range,
+ "method": method,
+ "data_source": data_source,
+ }
+ if env_params:
+ data.update(env_params)
+ return data
+
+
+def run_vpp_direct(
+ config: Dict,
+ tws_range: List[float],
+ twa_range: List[float],
+ method: str = "iterative",
+ data_source: str = "orc",
+ sail_types: Dict[str, str] = None,
+ env_params: Dict = None,
+ progress_callback=None,
+):
+ """Run VPP directly (bypassing Flask).
+
+ Parameters
+ ----------
+ progress_callback : callable, optional
+ Called as ``progress_callback(tws_index, tws_kts, n_tws)`` after
+ each wind speed is completed.
+
+ Returns (result_dict, error_string). result_dict has keys:
+ name, tws, twa, sails, results. On error, result_dict is None.
+ """
+ from src.api import data_to_vpp
+
+ data = _build_vpp_data(config, tws_range, twa_range, method, data_source, sail_types, env_params)
+
+ try:
+ vpp, method = data_to_vpp(data)
+ except (KeyError, TypeError, ValueError) as e:
+ logging.warning("Invalid VPP input: %s", e)
+ return None, str(e)
+
+ try:
+ vpp.run(verbose=True, method=method, progress_callback=progress_callback)
+ except Exception as e:
+ logging.exception("VPP simulation failed")
+ return None, str(e)
+
+ return vpp.results(), None
+
+
+def render_roughness_input(key_prefix: str = "") -> float:
+ """Render hull roughness number input. Returns roughness in metres."""
+ roughness_um = st.number_input(
+ r"Hull roughness $k_s$ ($\mu m$)",
+ min_value=0,
+ max_value=1000,
+ value=150,
+ step=10,
+ key=f"{key_prefix}_roughness",
+ help=(
+ "Mean hull roughness height in micrometres. "
+ "Typical values: **0** = hydraulically smooth, "
+ "**50** = racing finish, "
+ "**150** = new antifouling paint, "
+ "**300** = 1-year fouled hull, "
+ "**500+** = heavily fouled."
+ ),
+ )
+ return roughness_um * 1e-6
+
+
+def render_environment_inputs(key_prefix: str = "") -> Tuple[List[float], List[float], Dict]:
+ """Render TWA/TWS/wave sliders.
+
+ Returns (tws_range, twa_range, env_params) where env_params is a dict
+ with keys ``Hs``, ``Ts``.
+ """
+ st.subheader("Environment")
+ twa_slider = st.slider(
+ r"True wind angle $\theta_{tw}$ (TWA) range",
+ 35.0, 175.0, (35.0, 175.0), step=1.0,
+ key=f"{key_prefix}_twa",
+ )
+ twa_range = np.arange(twa_slider[0], twa_slider[1] + 1.0, 1.0).tolist()
+
+ tws_slider = st.slider(
+ r"True wind speed $V_{tw}$ (TWS) range",
+ 2.0, 25.0, (8.0, 12.0), step=1.0,
+ key=f"{key_prefix}_tws",
+ )
+ tws_range = np.arange(tws_slider[0], tws_slider[1] + 1.0, 1.0).tolist()
+
+ Hs = st.slider(
+ r"Significant wave height $H_s$ (m)",
+ 0.0, 3.0, 0.0, step=0.1,
+ key=f"{key_prefix}_Hs",
+ help="Wave height. 0 = flat water. Typical coastal: 0.5–1.5 m.",
+ )
+
+ Ts = st.slider(
+ r"Modal wave period $T_s$ (s)",
+ 0.0, 12.0, 0.0 if Hs == 0 else 5.0, step=0.5,
+ key=f"{key_prefix}_Ts",
+ help="Peak wave period. Typical: 4–8 s for wind waves, 8–12 s for swell.",
+ )
+
+ env_params = {
+ "Hs": Hs,
+ "Ts": Ts,
+ }
+ return tws_range, twa_range, env_params
+
+
+def validate_ranges(tws_range: List[float], twa_range: List[float]) -> bool:
+ """Show error messages if ranges are empty. Returns True if valid."""
+ if not tws_range:
+ st.error("TWS range is empty. Make sure the min and max wind speeds are not equal.")
+ return False
+ if not twa_range:
+ st.error("TWA range is empty. Make sure the min and max wind angles are not equal.")
+ return False
+ return True
+
+
def footer():
git_hash = get_git_hash()
footer = f"""
diff --git a/tests/test_streamlit.py b/tests/test_streamlit.py
new file mode 100644
index 0000000..e60c009
--- /dev/null
+++ b/tests/test_streamlit.py
@@ -0,0 +1,217 @@
+"""Streamlit app tests — verify pages load and widgets render correctly.
+
+These tests check the UI structure (widgets, sections, buttons) but NOT
+the mathematical models — those are covered by test_hydro, test_vpp, etc.
+"""
+
+import os
+import sys
+
+import pytest
+from streamlit.testing.v1 import AppTest
+
+# The demo pages import `from presets import ...` and `from utils import ...`
+# which requires demos/ on sys.path. AppTest runs pages as scripts in
+# a subprocess, so we inject the path via PYTHONPATH.
+DEMOS_DIR = os.path.join(os.path.dirname(__file__), "..", "demos")
+PAGES_DIR = os.path.join(DEMOS_DIR, "pages")
+
+
+@pytest.fixture(autouse=True)
+def _patch_pythonpath(monkeypatch):
+ """Ensure demos/ is importable by child processes and this process."""
+ abs_demos = os.path.abspath(DEMOS_DIR)
+ abs_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+ existing = os.environ.get("PYTHONPATH", "")
+ monkeypatch.setenv("PYTHONPATH", f"{abs_demos}{os.pathsep}{abs_root}{os.pathsep}{existing}")
+ if abs_demos not in sys.path:
+ sys.path.insert(0, abs_demos)
+ if abs_root not in sys.path:
+ sys.path.insert(0, abs_root)
+
+
+def _load_page(filename, timeout=30):
+ """Load a Streamlit page via AppTest."""
+ path = os.path.join(PAGES_DIR, filename)
+ at = AppTest.from_file(path, default_timeout=timeout)
+ at.run()
+ return at
+
+
+# ──────────────────────────────────────────────
+# VPP page
+# ──────────────────────────────────────────────
+
+class TestVPPPage:
+ def test_vpp_page_loads_without_error(self):
+ at = _load_page("1_VPP_⛵.py")
+ assert not at.exception, f"Page raised: {at.exception}"
+
+ def test_vpp_page_has_title(self):
+ at = _load_page("1_VPP_⛵.py")
+ markdown_texts = [m.value for m in at.markdown]
+ assert any("Yacht VPP" in t for t in markdown_texts)
+
+ def test_vpp_page_has_preset_selector(self):
+ at = _load_page("1_VPP_⛵.py")
+ assert len(at.selectbox) > 0, "No selectbox found"
+ # First selectbox should be the yacht preset
+ options = at.selectbox[0].options
+ assert "YD41" in options
+
+ def test_vpp_page_has_process_button(self):
+ at = _load_page("1_VPP_⛵.py")
+ labels = [b.label for b in at.button]
+ assert "Process Specifications" in labels
+
+ def test_vpp_page_has_environment_sliders(self):
+ at = _load_page("1_VPP_⛵.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("TWA" in l for l in slider_labels), "Missing TWA slider"
+ assert any("TWS" in l for l in slider_labels), "Missing TWS slider"
+ number_labels = [n.label for n in at.number_input]
+ assert any("roughness" in l.lower() for l in number_labels), "Missing roughness input"
+ assert any("H_s" in l for l in slider_labels), "Missing Hs slider"
+ assert any("T_s" in l for l in slider_labels), "Missing Ts slider"
+
+ def test_vpp_page_has_solver_settings(self):
+ at = _load_page("1_VPP_⛵.py")
+ selectbox_labels = [s.label for s in at.selectbox]
+ assert any("Solver" in l for l in selectbox_labels), "Missing solver selectbox"
+
+ def test_vpp_page_has_sail_type_selectors(self):
+ at = _load_page("1_VPP_⛵.py")
+ selectbox_labels = [s.label for s in at.selectbox]
+ assert any("Main" in l and "type" in l for l in selectbox_labels)
+ assert any("Jib" in l and "type" in l.lower() for l in selectbox_labels)
+ assert any("Kite" in l and "type" in l.lower() for l in selectbox_labels)
+
+ def test_vpp_page_has_popover_labels(self):
+ at = _load_page("1_VPP_⛵.py")
+ # Popovers aren't directly queryable via AppTest, but their trigger
+ # labels appear in the rendered button elements.
+ button_labels = [b.label for b in at.button]
+ assert any("What is a VPP" in l for l in button_labels) or len(at.button) >= 1
+
+ def test_field_help_covers_all_yacht_fields(self):
+ """FIELD_HELP should have entries for all yacht, keel, rudder, sail fields."""
+ from utils import FIELD_HELP
+ expected = [
+ "Lwl", "Vol", "Bwl", "Tc", "WSA", "Tmax", "Amax", "Mass",
+ "Ff", "Fa", "Boa", "Loa",
+ "Cu", "Cl", "Span", "Length", "Depth", "Tc_ratio",
+ "P", "E", "Roach", "BAD",
+ "I", "J", "LPG", "HBI",
+ "area", "vce",
+ ]
+ for key in expected:
+ assert key in FIELD_HELP, f"Missing help for field '{key}'"
+ assert len(FIELD_HELP[key]) > 5, f"Help for '{key}' too short"
+
+
+# ──────────────────────────────────────────────
+# Compare page
+# ──────────────────────────────────────────────
+
+class TestComparePage:
+ def test_compare_page_loads_without_error(self):
+ at = _load_page("2_Compare_⚖️.py")
+ assert not at.exception, f"Page raised: {at.exception}"
+
+ def test_compare_page_has_title(self):
+ at = _load_page("2_Compare_⚖️.py")
+ markdown_texts = [m.value for m in at.markdown]
+ assert any("Compare" in t for t in markdown_texts)
+
+ def test_compare_page_has_config_buttons(self):
+ at = _load_page("2_Compare_⚖️.py")
+ labels = [b.label for b in at.button]
+ assert "+ Add config" in labels
+ assert "- Remove last" in labels
+ assert "Compare" in labels
+
+ def test_compare_page_has_preset_selectors(self):
+ at = _load_page("2_Compare_⚖️.py")
+ # Should have at least 2 preset selectors (one per config tab)
+ preset_boxes = [s for s in at.selectbox if "Preset" in s.label]
+ assert len(preset_boxes) >= 2, f"Expected 2+ preset selectors, got {len(preset_boxes)}"
+
+ def test_compare_page_has_environment_sliders(self):
+ at = _load_page("2_Compare_⚖️.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("TWA" in l for l in slider_labels)
+ assert any("TWS" in l for l in slider_labels)
+
+ def test_compare_page_has_buttons(self):
+ at = _load_page("2_Compare_⚖️.py")
+ # Verify page rendered fully (buttons present implies layout loaded)
+ assert len(at.button) >= 3
+
+
+# ──────────────────────────────────────────────
+# Match Race page
+# ──────────────────────────────────────────────
+
+class TestMatchRacePage:
+ def test_match_race_page_loads_without_error(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ assert not at.exception, f"Page raised: {at.exception}"
+
+ def test_match_race_page_has_title(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ markdown_texts = [m.value for m in at.markdown]
+ assert any("Match Race" in t for t in markdown_texts)
+
+ def test_match_race_page_has_race_button(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ labels = [b.label for b in at.button]
+ assert "Race!" in labels
+
+ def test_match_race_page_has_boat_preset_selectors(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ preset_boxes = [s for s in at.selectbox if "Preset" in s.label]
+ assert len(preset_boxes) >= 2, "Expected 2 boat preset selectors"
+
+ def test_match_race_page_has_environment_sliders(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("wind speed" in l.lower() for l in slider_labels), "Missing TWS slider"
+ assert any("current" in l.lower() for l in slider_labels), "Missing current slider"
+
+ def test_match_race_page_has_race_parameter_sliders(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("Leg distance" in l for l in slider_labels)
+ assert any("Tack penalty" in l for l in slider_labels)
+ assert any("Gybe penalty" in l for l in slider_labels)
+
+ def test_match_race_page_has_wind_model_sliders(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("Wind shift" in l for l in slider_labels)
+ assert any("TWS" in l and "sigma" in l.lower() for l in slider_labels)
+ assert any("mean-reversion" in l.lower() for l in slider_labels)
+
+ def test_match_race_page_has_stochastic_sliders(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ slider_labels = [s.label for s in at.slider]
+ assert any("Trim noise" in l for l in slider_labels)
+ assert any("Tack penalty" in l and "sigma" in l.lower() for l in slider_labels)
+
+ def test_match_race_page_has_monte_carlo_selector(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ mc_boxes = [s for s in at.selectbox if "Monte Carlo" in s.label]
+ assert len(mc_boxes) >= 1
+ assert "100" in mc_boxes[0].options
+
+ def test_match_race_page_has_subheaders(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ # Verify key sections rendered (subheaders indicate layout completeness)
+ markdown_texts = [m.value for m in at.markdown]
+ assert any("Match Race" in t for t in markdown_texts)
+
+ def test_match_race_page_has_leg_pairs_selector(self):
+ at = _load_page("3_Match_Race_🏁.py")
+ leg_boxes = [s for s in at.selectbox if "leg pairs" in s.label.lower()]
+ assert len(leg_boxes) >= 1
+ assert "1" in leg_boxes[0].options