From 967a5c587e547e1e3358a793c14a04979ea3d823 Mon Sep 17 00:00:00 2001 From: G0rocks Date: Thu, 19 Feb 2026 12:14:21 +0000 Subject: [PATCH 1/3] Ignore csv files --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c503985..2580bf0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ venv dev.py /.pytest_cache /__pycache__/ -.pytest_cache \ No newline at end of file +.pytest_cache +*.csv \ No newline at end of file From 554999fcbf7981d63249397a09d0c7a6ed7e921d Mon Sep 17 00:00:00 2001 From: G0rocks Date: Thu, 19 Feb 2026 12:15:26 +0000 Subject: [PATCH 2/3] add csv_write to UtilsMod.py and showcase in runVPP.py. Fixes #45 --- runVPP.py | 2 ++ src/UtilsMod.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/runVPP.py b/runVPP.py index ca737e6..7bf12d8 100755 --- a/runVPP.py +++ b/runVPP.py @@ -9,6 +9,7 @@ from src.SailMod import Jib, Kite, Main from src.VPPMod import VPP from src.YachtMod import Keel, Rudder, Yacht +import src.UtilsMod as Utils YD41 = Yacht( Name="YD41", @@ -41,5 +42,6 @@ vpp.run(verbose=False) vpp.write("results") +Utils.csv_write(vpp.tws_range, vpp.twa_range, vpp.store, "results/Vm.csv") vpp.polar(3, True) vpp.SailChart(True) diff --git a/src/UtilsMod.py b/src/UtilsMod.py index 57fe6b5..5960fc1 100644 --- a/src/UtilsMod.py +++ b/src/UtilsMod.py @@ -37,6 +37,60 @@ def json_write(data, fname): json.dump(data, json_file, ensure_ascii=False, indent=2, sort_keys=False) +# def write_csv(VPP, fname): +def csv_write(tws_range, twa_range, store, fname): + import csv + + # Init a counter for how many values have been used to average the boat speed for a given wind direction and speed: + avg_counter = np.zeros((37, 20)) + + with open(fname, mode="w", newline="") as file: + writer = csv.writer(file, delimiter=";") + # Write header with TWS in knots + writer.writerow(["TWA\TWS", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22", "24", "26", "28", "30", "32", "34", "36", "38", "40"]) + # Loop through every degree from 0°to 180° in 5° increments + for angle in range(0, 181, 5): + # Init row to write + row = [None] * 21 + # Set angle + row[0] = angle + # Go through each TWS and get the corresponding boat speed for the current angle + for i, tws in enumerate(tws_range): + # Change tws to knots + tws = tws / KNOTS_TO_MPS + for j, twa in enumerate(twa_range): + # Get closest wind speed and find column in openCPN. Note we are stuck using these until this issue (https://github.com/rgleason/polar_pi/issues/34) gets dealt with + closest_wind_speed_column = round(tws/2) + # If closest wind speed column is zero, increment by one so we don't overwrite the angle column. + if closest_wind_speed_column == 0: + closest_wind_speed_column += 1 + # Get closest angle and find see if it matches the current angle we are looking for + closest_angle = round(twa/5)*5 + # If the closest_angle matches the angle we're currently working with then get the speeds, otherwise, skip to next entry + if closest_angle == angle: + # Get boat speed for give wind angle and speed + boat_speed = store[i, j, 0, 0] + # Get row number + row_number = int(angle/5) + + # Update the row with the boat speed for this angle and wind speed + # If there has not been a value used yet, set the value to the boat speed, otherwise, average the current value with the new boat speed + if row[closest_wind_speed_column] is None: + row[closest_wind_speed_column] = boat_speed + else: + # Find out how many values have been used to make this average + num_values = avg_counter[row_number, closest_wind_speed_column] + # Average the current boat speed with the already logged average boat speed + row[closest_wind_speed_column] = (row[closest_wind_speed_column] * num_values + boat_speed) / (num_values + 1) + + # Update the average counter for this angle and wind speed + avg_counter[row_number, closest_wind_speed_column] += 1 + # Print out the speed of the vessel given the angle and wind speed + + # Write the row to the csv file + writer.writerow(row) + + def build_interp_func(fname, i=1, kind="linear"): """ build interpolatison function and returns it in a list From fbf21d8116872147147d3ab44b64fdfe253c4f8b Mon Sep 17 00:00:00 2001 From: marinlauber Date: Sat, 17 Oct 2026 12:10:57 +0200 Subject: [PATCH 3/3] feat: general csv_write for routing polars (OpenCPN) Implements the review on #52: - csv_write(VPP, tws_range=None, twa_range=None, fname="polar.csv") writes the best boat speed over the sail sets (no averaging) with the analysis TWS/TWA as header and rows by default - a custom tws_range (knots) and/or twa_range (degrees) linearly interpolates the results onto that grid, e.g. the 5 deg / 2 kn steps OpenCPN expects; points outside the analysis or not sailable are 0 - speeds are already in knots in the store, so no second unit conversion - VPP.write_csv() convenience method; runVPP.py writes polar.csv - .gitignore only ignores csv files at the repo root (dat/ holds csv data) - tests for the default grid, the interpolation and VPP.write_csv Fixes #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JN4y2NWa8kFMBZ5FV92jkj --- runVPP.py | 6 ++- src/UtilsMod.py | 89 +++++++++++++++++++++------------------------ src/VPPMod.py | 6 ++- tests/test_utils.py | 53 ++++++++++++++++++++++++++- 4 files changed, 102 insertions(+), 52 deletions(-) diff --git a/runVPP.py b/runVPP.py index 257124f..c806690 100755 --- a/runVPP.py +++ b/runVPP.py @@ -9,7 +9,6 @@ from src.SailMod import Jib, Kite, Main from src.VPPMod import VPP from src.YachtMod import Keel, Rudder, Yacht -import src.UtilsMod as Utils YD41 = Yacht( Name="YD41", @@ -42,6 +41,9 @@ vpp.run() vpp.write("results") -Utils.csv_write(vpp.tws_range, vpp.twa_range, vpp.store, "results/Vm.csv") +# polar csv for routing software, e.g. OpenCPN (5 deg TWA, 2 kn TWS steps) +vpp.write_csv( + "polar.csv", tws_range=np.arange(4.0, 22.0, 2.0), twa_range=np.arange(30.0, 181.0, 5.0) +) vpp.polar(3, True) vpp.SailChart(True) diff --git a/src/UtilsMod.py b/src/UtilsMod.py index 754b56b..08f2719 100644 --- a/src/UtilsMod.py +++ b/src/UtilsMod.py @@ -39,58 +39,51 @@ def json_write(data, fname): json.dump(data, json_file, ensure_ascii=False, indent=2, sort_keys=False) -# def write_csv(VPP, fname): -def csv_write(tws_range, twa_range, store, fname): +def csv_write(VPP, tws_range=None, twa_range=None, fname="polar.csv"): + """ + Export the best boat speed (knots) to a polar csv file (TWA rows, TWS + columns, ";" separated), the format read by routing software such as OpenCPN. + + Parameters + ---------- + VPP + A VPP (after run) or VPPResults object. + tws_range + Optional TWS (knots) to write. Default: the TWS of the VPP analysis. + twa_range + Optional TWA (degrees) to write. Default: the TWA of the VPP analysis. + fname + Name of the csv file, default "polar.csv". + + If tws_range or twa_range is given, the VPP results are linearly + interpolated onto it; points outside the analysed range, or where no sail + set can be carried, are written as 0. + """ + if not hasattr(VPP, "store"): + raise ValueError("VPP object must have a 'store' attribute to export to csv.") import csv - - # Init a counter for how many values have been used to average the boat speed for a given wind direction and speed: - avg_counter = np.zeros((37, 20)) + + # best boat speed over the sail sets, the store already holds knots + vpp_tws = np.asarray(VPP.tws_range) / KNOTS_TO_MPS + vpp_twa = np.asarray(VPP.twa_range) + speed = np.max(np.asarray(VPP.store)[:, :, :, 0], axis=2) + tws = vpp_tws if tws_range is None else np.asarray(tws_range, dtype=float) + twa = vpp_twa if twa_range is None else np.asarray(twa_range, dtype=float) + if tws_range is not None or twa_range is not None: + func = RegularGridInterpolator( + (vpp_tws, vpp_twa), speed, bounds_error=False, fill_value=0.0 + ) + grid = np.stack(np.meshgrid(tws, twa, indexing="ij"), axis=-1) + speed = func(grid) with open(fname, mode="w", newline="") as file: writer = csv.writer(file, delimiter=";") - # Write header with TWS in knots - writer.writerow(["TWA\TWS", "2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22", "24", "26", "28", "30", "32", "34", "36", "38", "40"]) - # Loop through every degree from 0°to 180° in 5° increments - for angle in range(0, 181, 5): - # Init row to write - row = [None] * 21 - # Set angle - row[0] = angle - # Go through each TWS and get the corresponding boat speed for the current angle - for i, tws in enumerate(tws_range): - # Change tws to knots - tws = tws / KNOTS_TO_MPS - for j, twa in enumerate(twa_range): - # Get closest wind speed and find column in openCPN. Note we are stuck using these until this issue (https://github.com/rgleason/polar_pi/issues/34) gets dealt with - closest_wind_speed_column = round(tws/2) - # If closest wind speed column is zero, increment by one so we don't overwrite the angle column. - if closest_wind_speed_column == 0: - closest_wind_speed_column += 1 - # Get closest angle and find see if it matches the current angle we are looking for - closest_angle = round(twa/5)*5 - # If the closest_angle matches the angle we're currently working with then get the speeds, otherwise, skip to next entry - if closest_angle == angle: - # Get boat speed for give wind angle and speed - boat_speed = store[i, j, 0, 0] - # Get row number - row_number = int(angle/5) - - # Update the row with the boat speed for this angle and wind speed - # If there has not been a value used yet, set the value to the boat speed, otherwise, average the current value with the new boat speed - if row[closest_wind_speed_column] is None: - row[closest_wind_speed_column] = boat_speed - else: - # Find out how many values have been used to make this average - num_values = avg_counter[row_number, closest_wind_speed_column] - # Average the current boat speed with the already logged average boat speed - row[closest_wind_speed_column] = (row[closest_wind_speed_column] * num_values + boat_speed) / (num_values + 1) - - # Update the average counter for this angle and wind speed - avg_counter[row_number, closest_wind_speed_column] += 1 - # Print out the speed of the vessel given the angle and wind speed - - # Write the row to the csv file - writer.writerow(row) + # header with TWS in knots + writer.writerow(["TWA\\TWS"] + [f"{round(ws, 2):g}" for ws in tws]) + for j, angle in enumerate(twa): + writer.writerow( + [f"{round(angle, 2):g}"] + [f"{speed[i, j]:.2f}" for i in range(len(tws))] + ) def build_interp_func(fname, i=1, kind="linear"): diff --git a/src/VPPMod.py b/src/VPPMod.py index 73a265e..511000c 100644 --- a/src/VPPMod.py +++ b/src/VPPMod.py @@ -9,7 +9,7 @@ from src.AeroMod import AeroMod from src.HydroMod import HydroMod -from src.UtilsMod import KNOTS_TO_MPS, json_write, polar_plot, sail_chart +from src.UtilsMod import KNOTS_TO_MPS, csv_write, json_write, polar_plot, sail_chart from src.YachtMod import Yacht as YachtClass logger = logging.getLogger(__name__) @@ -281,6 +281,10 @@ def results(self): def write(self, fname): json_write(self.results(), fname) + def write_csv(self, fname="polar.csv", tws_range=None, twa_range=None): + """Write the best boat speed to a polar csv file, see UtilsMod.csv_write.""" + csv_write(self, tws_range=tws_range, twa_range=twa_range, fname=fname) + def polar(self, n=1, save=False, fname="Polars.pdf"): polar_plot([self], n, save, fname) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2edba8e..c2a7849 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,10 @@ +from types import SimpleNamespace + import numpy as np from src.SailMod import Jib, Kite, Main -from src.UtilsMod import _get_cross +from src.UtilsMod import KNOTS_TO_MPS, _get_cross, csv_write +from src.VPPMod import VPP from src.YachtMod import Keel, Rudder, Yacht @@ -53,3 +56,51 @@ def test_get_cross_pad_is_symmetric(): def test_get_cross_no_pad(): assert tuple(_get_cross(_cross_store(), 1, pad=0)) == (4, 8) + + +def _fake_vpp(): + """2 TWS x 3 TWA x 2 sails; the store holds boat speed in knots, TWS in m/s.""" + store = np.zeros((2, 3, 2, 5)) + store[:, :, 0, 0] = [[5.0, 6.0, 0.0], [7.0, 8.0, 0.0]] # jib, not set downwind + store[:, :, 1, 0] = [[0.0, 5.5, 6.5], [0.0, 8.5, 9.5]] # kite + return SimpleNamespace( + tws_range=np.array([6.0, 10.0]) * KNOTS_TO_MPS, + twa_range=np.array([40.0, 90.0, 150.0]), + store=store, + ) + + +def _read_csv(path): + rows = [line.split(";") for line in path.read_text().splitlines()] + return rows[0], {float(r[0]): [float(v) for v in r[1:]] for r in rows[1:]} + + +def test_csv_write_default_grid(tmp_path): + fname = tmp_path / "polar.csv" + csv_write(_fake_vpp(), fname=fname) + header, rows = _read_csv(fname) + assert header == ["TWA\\TWS", "6", "10"] + # best sail at each point, in knots (no second unit conversion) + assert rows == {40.0: [5.0, 7.0], 90.0: [6.0, 8.5], 150.0: [6.5, 9.5]} + + +def test_csv_write_interpolates_custom_grid(tmp_path): + fname = tmp_path / "polar.csv" + csv_write(_fake_vpp(), tws_range=[6.0, 8.0, 12.0], twa_range=[40.0, 65.0], fname=fname) + header, rows = _read_csv(fname) + assert header == ["TWA\\TWS", "6", "8", "12"] + assert rows[40.0] == [5.0, 6.0, 0.0] # 12 kn is outside the analysis: 0 + assert np.allclose(rows[65.0], [5.5, 6.625, 0.0], atol=0.006) # halfway 40-90 deg + + +def test_vpp_write_csv(tmp_path): + vpp = VPP(Yacht=return_YD41_particulars()) + vpp.set_analysis(tws_range=np.array([8.0]), twa_range=np.array([45.0, 90.0])) + vpp.run() + fname = tmp_path / "polar.csv" + vpp.write_csv(fname) + header, rows = _read_csv(fname) + assert header == ["TWA\\TWS", "8"] + best = vpp.store[0, :, :, 0].max(axis=1) + assert np.allclose([rows[45.0][0], rows[90.0][0]], best, atol=0.006) + assert all(v > 0 for r in rows.values() for v in r)