diff --git a/.gitignore b/.gitignore index 55eab88..432f7bf 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ sphinx/_build/ # Project ORC_VPP_2019.pdf dev.py + +# exported polars (root only, dat/ holds tracked csv data) +/*.csv diff --git a/runVPP.py b/runVPP.py index 39559ec..c806690 100755 --- a/runVPP.py +++ b/runVPP.py @@ -41,5 +41,9 @@ vpp.run() vpp.write("results") +# 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 3b33893..08f2719 100644 --- a/src/UtilsMod.py +++ b/src/UtilsMod.py @@ -39,6 +39,53 @@ def json_write(data, fname): json.dump(data, json_file, ensure_ascii=False, indent=2, sort_keys=False) +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 + + # 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=";") + # 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"): """ build interpolation function and returns it in a list 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)