From dd486d31d85ab40a2b41ad2fe257d416400bf297 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 15 Jan 2026 11:00:31 +0100 Subject: [PATCH 1/2] feat(eqdsk): add R,Z to flux coordinate interpolation and Sphinx docs Add methods to eqdsk_file class for converting cylindrical (R, Z) coordinates to flux coordinates (s_pol, theta): - psi_at_rz: interpolate poloidal flux at arbitrary (R, Z) points - spol_at_rz: compute normalized poloidal flux (0 at axis, 1 at LCFS) - theta_geometric_at_rz: compute geometric poloidal angle - rz_to_flux_coords: combined conversion to (s_pol, theta) Uses scipy RegularGridInterpolator with cubic interpolation. Automatically handles COCOS sign convention mismatches. Also adds: - Sphinx documentation with usage examples for plotting scalar data against flux coordinates - GitHub Actions workflow to build and deploy docs to GitHub Pages - Docs landing page at root, test dashboard at /test/ --- .github/workflows/main.yml | 53 +++++++- docs/.gitignore | 1 + docs/Makefile | 14 +++ docs/api.rst | 26 ++++ docs/conf.py | 38 ++++++ docs/eqdsk.rst | 143 ++++++++++++++++++++++ docs/index.rst | 27 +++++ docs/installation.rst | 24 ++++ pyproject.toml | 5 + python/libneo/eqdsk.py | 153 +++++++++++++++++++++--- test/python/test_eqdsk_interpolation.py | 137 +++++++++++++++++++++ 11 files changed, 604 insertions(+), 17 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 100644 docs/conf.py create mode 100644 docs/eqdsk.rst create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 test/python/test_eqdsk_interpolation.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ddfa6cee..f46482f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -114,9 +114,37 @@ jobs: build/test_artifacts/**/*.jpeg if-no-files-found: warn + docs: + name: Build Sphinx docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install docs dependencies + run: | + python -m pip install --upgrade pip + python -m pip install sphinx sphinx-rtd-theme scipy numpy + + - name: Build docs + run: | + cd docs && sphinx-build -b html . _build/html + + - name: Upload docs artifact + uses: actions/upload-artifact@v4 + with: + name: docs-html + path: docs/_build/html + dashboard: name: Build test dashboard - needs: GNU + needs: [GNU, docs] if: ${{ needs.GNU.result == 'success' }} runs-on: ubuntu-latest permissions: @@ -243,14 +271,33 @@ jobs: rm -f "$tmp_json" + - name: Download docs artifact + uses: actions/download-artifact@v4 + with: + name: docs-html + path: docs-html + + - name: Merge docs with dashboard + run: | + set -euo pipefail + mkdir -p site + if [ -d dashboard/test ]; then + mv dashboard/test site/test + else + mkdir -p site/test + fi + cp -r docs-html/* site/ + echo "Site structure:" + find site -type f | head -20 + - name: Configure Pages if: ${{ github.event_name == 'push' }} uses: actions/configure-pages@v5 - - name: Upload dashboard artifact + - name: Upload pages artifact uses: actions/upload-pages-artifact@v3 with: - path: './dashboard' + path: './site' deploy: name: Deploy to GitHub Pages diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..69fa449d --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..5c2dc9c3 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,14 @@ +# Minimal makefile for Sphinx documentation + +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 00000000..337ed8c6 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,26 @@ +API Reference +============= + +EQDSK Module +------------ + +.. automodule:: libneo.eqdsk + :members: + :undoc-members: + :show-inheritance: + +Flux Converter Module +--------------------- + +.. automodule:: libneo.flux_converter + :members: + :undoc-members: + :show-inheritance: + +VMEC Module +----------- + +.. automodule:: libneo.vmec + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 00000000..fe640e3f --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,38 @@ +"""Sphinx configuration for libneo documentation.""" + +import os +import sys + +sys.path.insert(0, os.path.abspath('../python')) + +project = 'libneo' +copyright = '2024, ITPcp' +author = 'ITPcp' + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx.ext.viewcode', + 'sphinx.ext.intersphinx', +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] + +autodoc_default_options = { + 'members': True, + 'undoc-members': True, + 'show-inheritance': True, +} + +napoleon_google_docstring = False +napoleon_numpy_docstring = True + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'scipy': ('https://docs.scipy.org/doc/scipy/', None), +} diff --git a/docs/eqdsk.rst b/docs/eqdsk.rst new file mode 100644 index 00000000..16f100ee --- /dev/null +++ b/docs/eqdsk.rst @@ -0,0 +1,143 @@ +EQDSK Equilibrium Files +======================= + +The ``eqdsk_file`` class provides tools for reading G-EQDSK equilibrium files +and converting between cylindrical (R, Z) coordinates and flux coordinates. + +Reading EQDSK Files +------------------- + +.. code-block:: python + + from libneo import eqdsk_file + + eq = eqdsk_file('equilibrium.geqdsk') + + # Access grid data + print(f"R range: {eq.R.min():.2f} - {eq.R.max():.2f} m") + print(f"Z range: {eq.Z.min():.2f} - {eq.Z.max():.2f} m") + print(f"Magnetic axis: R={eq.Rpsi0:.3f} m, Z={eq.Zpsi0:.3f} m") + +Coordinate Conversion +--------------------- + +Converting (R, Z) to Flux Coordinates +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The most common use case is converting arbitrary (R, Z) measurement points +to normalized poloidal flux coordinates for radial profile analysis: + +.. code-block:: python + + import numpy as np + from libneo import eqdsk_file + + eq = eqdsk_file('equilibrium.geqdsk') + + # Single point + R, Z = 1.8, 0.1 + s_pol, theta = eq.rz_to_flux_coords(R, Z) + print(f"s_pol = {s_pol:.3f}, theta = {theta:.3f} rad") + + # Array of measurement points + R_data = np.array([1.7, 1.8, 1.9, 2.0]) + Z_data = np.array([0.0, 0.1, -0.1, 0.0]) + s_pol, theta = eq.rz_to_flux_coords(R_data, Z_data) + +Interpolating Poloidal Flux +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Get the raw poloidal flux values at arbitrary (R, Z) points: + +.. code-block:: python + + # Scalar + psi = eq.psi_at_rz(1.8, 0.1) + + # Vectorized + psi = eq.psi_at_rz(R_data, Z_data) + + # On a 2D grid (for contour plots) + R_grid = np.linspace(1.5, 2.2, 50) + Z_grid = np.linspace(-0.5, 0.5, 50) + psi_2d = eq.psi_at_rz(R_grid, Z_grid, grid=True) + +Plotting Scalar Data vs Flux Coordinate +--------------------------------------- + +A typical workflow for plotting diagnostic data against normalized flux: + +.. code-block:: python + + import numpy as np + import matplotlib.pyplot as plt + from libneo import eqdsk_file + + # Load equilibrium + eq = eqdsk_file('equilibrium.geqdsk') + + # Your measurement data at (R, Z) locations + R_meas = np.array([1.65, 1.70, 1.75, 1.80, 1.85, 1.90]) + Z_meas = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + temperature = np.array([3.2, 2.8, 2.3, 1.7, 1.1, 0.5]) # keV + + # Convert to flux coordinates + s_pol, theta = eq.rz_to_flux_coords(R_meas, Z_meas) + rho_pol = np.sqrt(s_pol) # sqrt(s_pol) is common radial coordinate + + # Plot + plt.figure() + plt.plot(rho_pol, temperature, 'o-') + plt.xlabel(r'$\\rho_{pol}$') + plt.ylabel('Temperature [keV]') + plt.title('Temperature Profile') + plt.show() + +Flux Coordinate Definitions +--------------------------- + +Normalized Poloidal Flux (s_pol) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The normalized poloidal flux is defined as: + +.. math:: + + s_{pol} = \\frac{\\psi - \\psi_{axis}}{\\psi_{edge} - \\psi_{axis}} + +where: + +- :math:`s_{pol} = 0` at the magnetic axis +- :math:`s_{pol} = 1` at the last closed flux surface (LCFS/separatrix) + +Geometric Poloidal Angle (theta) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The geometric poloidal angle is defined as: + +.. math:: + + \\theta = \\arctan2(Z - Z_{axis}, R - R_{axis}) + +where: + +- :math:`\\theta = 0` at the outboard midplane (low-field side) +- :math:`\\theta = \\pi/2` at the top +- :math:`\\theta = -\\pi/2` at the bottom +- :math:`\\theta = \\pm\\pi` at the inboard midplane (high-field side) + +COCOS Conventions +----------------- + +EQDSK files from different sources may use different coordinate conventions +(COCOS). The ``eqdsk_file`` class automatically detects sign inconsistencies +between the header values and the 2D psi array, ensuring that ``spol_at_rz`` +always returns 0 at the axis and 1 at the LCFS regardless of the input file +convention. + +API Reference +------------- + +.. autoclass:: libneo.eqdsk.eqdsk_file + :members: psi_at_rz, spol_at_rz, theta_geometric_at_rz, rz_to_flux_coords + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 00000000..af5c7cf8 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,27 @@ +libneo Documentation +==================== + +libneo is a Fortran library with Python bindings for plasma physics calculations, +particularly for magnetic field representations, transport calculations, and +coordinate transformations in fusion plasmas. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + eqdsk + api + +Links +===== + +* `Test Dashboard `_ - Visual test results and artifacts +* `GitHub Repository `_ + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 00000000..c2ee0cfb --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,24 @@ +Installation +============ + +Requirements +------------ + +- Python 3.9+ +- NumPy +- SciPy (for interpolation features) + +Installation +------------ + +Install from source: + +.. code-block:: bash + + pip install -e . + +For development with all optional dependencies: + +.. code-block:: bash + + pip install -e ".[dev]" diff --git a/pyproject.toml b/pyproject.toml index 39f9bf84..92c3a685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,11 @@ dev = [ "pytest", "map2disc @ git+https://gitlab.mpcdf.mpg.de/gvec-group/map2disc.git", ] +docs = [ + "sphinx", + "sphinx-rtd-theme", + "scipy", +] [project.scripts] libneo-write-chartmap = "libneo.chartmap_cli:main" diff --git a/python/libneo/eqdsk.py b/python/libneo/eqdsk.py index a7cd0e79..71b45896 100644 --- a/python/libneo/eqdsk.py +++ b/python/libneo/eqdsk.py @@ -1,9 +1,133 @@ class eqdsk_file: - def __init__ (self, filename:str): + def __init__(self, filename: str): self.verbose = False + self._psi_interpolator = None self.generate_from_file(filename) + def _build_psi_interpolator(self): + from scipy.interpolate import RegularGridInterpolator + import numpy as np + self._psi_interpolator = RegularGridInterpolator( + (self.Z, self.R), self.PsiVs, method='cubic', bounds_error=False + ) + i_r_axis = np.argmin(np.abs(self.R - self.Rpsi0)) + i_z_axis = np.argmin(np.abs(self.Z - self.Zpsi0)) + self._psi_at_axis = self.PsiVs[i_z_axis, i_r_axis] + self._psi_at_edge = self.PsiedgeVs + if np.sign(self._psi_at_axis) != np.sign(self.PsiaxisVs): + self._psi_at_edge = -self.PsiedgeVs + + def psi_at_rz(self, R, Z, grid=False): + """ + Interpolate poloidal flux psi at arbitrary (R, Z) coordinates. + + Parameters + ---------- + R : float or array_like + Major radius coordinate(s) in meters. + Z : float or array_like + Vertical coordinate(s) in meters. + grid : bool, optional + If True, evaluate on the grid formed by R and Z arrays. + If False (default), evaluate at corresponding pairs (R[i], Z[i]). + + Returns + ------- + psi : float or ndarray + Poloidal flux value(s) at the given coordinates. + """ + import numpy as np + if self._psi_interpolator is None: + self._build_psi_interpolator() + + R = np.atleast_1d(R) + Z = np.atleast_1d(Z) + + if grid: + ZZ, RR = np.meshgrid(Z, R, indexing='ij') + points = np.column_stack([ZZ.ravel(), RR.ravel()]) + result = self._psi_interpolator(points).reshape(ZZ.shape) + else: + points = np.column_stack([Z, R]) + result = self._psi_interpolator(points) + if result.size == 1: + result = result.item() + + return result + + def spol_at_rz(self, R, Z): + """ + Compute normalized poloidal flux coordinate s_pol at (R, Z). + + s_pol = (psi - psi_axis) / (psi_edge - psi_axis) + + Parameters + ---------- + R : float or array_like + Major radius coordinate(s) in meters. + Z : float or array_like + Vertical coordinate(s) in meters. + + Returns + ------- + s_pol : float or ndarray + Normalized poloidal flux, 0 at axis, 1 at separatrix. + """ + if self._psi_interpolator is None: + self._build_psi_interpolator() + psi = self.psi_at_rz(R, Z) + return (psi - self._psi_at_axis) / (self._psi_at_edge - self._psi_at_axis) + + def theta_geometric_at_rz(self, R, Z): + """ + Compute geometric poloidal angle at (R, Z) relative to magnetic axis. + + theta = atan2(Z - Z_axis, R - R_axis) + + Parameters + ---------- + R : float or array_like + Major radius coordinate(s) in meters. + Z : float or array_like + Vertical coordinate(s) in meters. + + Returns + ------- + theta : float or ndarray + Geometric poloidal angle in radians, range (-pi, pi]. + theta=0 at outboard midplane, theta=pi/2 at top. + """ + import numpy as np + R = np.atleast_1d(R) + Z = np.atleast_1d(Z) + theta = np.arctan2(Z - self.Zpsi0, R - self.Rpsi0) + if theta.size == 1: + return theta.item() + return theta + + def rz_to_flux_coords(self, R, Z): + """ + Convert (R, Z) cylindrical coordinates to flux coordinates (s_pol, theta). + + Parameters + ---------- + R : float or array_like + Major radius coordinate(s) in meters. + Z : float or array_like + Vertical coordinate(s) in meters. + + Returns + ------- + s_pol : float or ndarray + Normalized poloidal flux coordinate. + theta : float or ndarray + Geometric poloidal angle in radians. + """ + s_pol = self.spol_at_rz(R, Z) + theta = self.theta_geometric_at_rz(R, Z) + return s_pol, theta + def generate_from_file(self, filename:str): """ @@ -171,19 +295,20 @@ def generate_from_file(self, filename:str): self.DZcond = self.PFcoilData[:,3] self.Icond = self.PFcoilData[:,4] - # Read Brn - self.Brn = np.empty(self.nrgr*self.nzgr) - for k in range(self.nrgr*self.nzgr): - self.Brn[k] = readblock(f) - - self.Brn = self.Brn.reshape(self.nzgr, self.nrgr) - - # Read Bzn - self.Bzn = np.empty(self.nrgr*self.nzgr) - for k in range(self.nrgr*self.nzgr): - self.Bzn[k] = readblock(f) - - self.Bzn = self.Bzn.reshape(self.nzgr, self.nrgr) + try: + # Read Brn (optional) + self.Brn = np.empty(self.nrgr*self.nzgr) + for k in range(self.nrgr*self.nzgr): + self.Brn[k] = readblock(f) + self.Brn = self.Brn.reshape(self.nzgr, self.nrgr) + + # Read Bzn (optional) + self.Bzn = np.empty(self.nrgr*self.nzgr) + for k in range(self.nrgr*self.nzgr): + self.Bzn[k] = readblock(f) + self.Bzn = self.Bzn.reshape(self.nzgr, self.nrgr) + except (ValueError, IndexError): + pass # Brn/Bzn data not available def sort_into_coilgroups(self, indices_coilgroupstarts:list, coilgroups:list, coiltags:list): diff --git a/test/python/test_eqdsk_interpolation.py b/test/python/test_eqdsk_interpolation.py new file mode 100644 index 00000000..89ffeb31 --- /dev/null +++ b/test/python/test_eqdsk_interpolation.py @@ -0,0 +1,137 @@ +""" +Tests for EQDSK psi interpolation and flux coordinate conversion. +""" + +import pytest +import numpy as np + +from libneo.eqdsk import eqdsk_file + + +TEST_EQDSK = "test/resources/input_efit_file.dat" + + +class TestPsiInterpolation: + + @pytest.fixture + def eq(self): + return eqdsk_file(TEST_EQDSK) + + def test_psi_at_magnetic_axis_equals_psi_axis(self, eq): + R_axis = eq.Rpsi0 + Z_axis = eq.Zpsi0 + psi = eq.psi_at_rz(R_axis, Z_axis) + i_r = np.argmin(np.abs(eq.R - R_axis)) + i_z = np.argmin(np.abs(eq.Z - Z_axis)) + psi_grid_at_axis = eq.PsiVs[i_z, i_r] + assert np.isclose(psi, psi_grid_at_axis, rtol=1e-2) + + def test_psi_at_grid_point_matches_grid_value(self, eq): + i_r = eq.nrgr // 2 + i_z = eq.nzgr // 2 + R_grid = eq.R[i_r] + Z_grid = eq.Z[i_z] + psi_interp = eq.psi_at_rz(R_grid, Z_grid) + psi_grid = eq.PsiVs[i_z, i_r] + assert np.isclose(psi_interp, psi_grid, rtol=1e-4) + + def test_psi_at_rz_vectorized(self, eq): + R_arr = np.array([eq.R[10], eq.R[20], eq.R[30]]) + Z_arr = np.array([eq.Z[50], eq.Z[60], eq.Z[70]]) + psi = eq.psi_at_rz(R_arr, Z_arr) + assert psi.shape == (3,) + for i in range(3): + psi_scalar = eq.psi_at_rz(R_arr[i], Z_arr[i]) + assert np.isclose(psi[i], psi_scalar) + + def test_psi_at_rz_2d_grid(self, eq): + R_arr = eq.R[10:13] + Z_arr = eq.Z[50:53] + psi = eq.psi_at_rz(R_arr, Z_arr, grid=True) + assert psi.shape == (3, 3) + + +class TestNormalizedFluxCoordinate: + + @pytest.fixture + def eq(self): + return eqdsk_file(TEST_EQDSK) + + def test_spol_at_axis_is_zero(self, eq): + R_axis = eq.Rpsi0 + Z_axis = eq.Zpsi0 + s_pol = eq.spol_at_rz(R_axis, Z_axis) + assert np.isclose(s_pol, 0.0, atol=1e-2) + + def test_spol_at_lcfs_is_one(self, eq): + R_lcfs = eq.Lcfs[0, 0] + Z_lcfs = eq.Lcfs[1, 0] + s_pol = eq.spol_at_rz(R_lcfs, Z_lcfs) + assert np.isclose(s_pol, 1.0, atol=0.05) + + def test_spol_at_rz_vectorized(self, eq): + R_arr = np.array([eq.R[30], eq.R[35], eq.R[40]]) + Z_arr = np.array([eq.Z[60], eq.Z[65], eq.Z[64]]) + s_pol = eq.spol_at_rz(R_arr, Z_arr) + assert s_pol.shape == (3,) + assert np.all(s_pol >= 0.0) + + def test_spol_monotonic_from_axis_outward(self, eq): + R_axis = eq.Rpsi0 + Z_axis = eq.Zpsi0 + R_line = np.linspace(R_axis, eq.R[-5], 20) + Z_line = np.full_like(R_line, Z_axis) + s_pol = eq.spol_at_rz(R_line, Z_line) + assert np.all(np.diff(s_pol) >= -1e-6) + + +class TestGeometricAngle: + + @pytest.fixture + def eq(self): + return eqdsk_file(TEST_EQDSK) + + def test_theta_at_outboard_midplane_is_zero(self, eq): + R_out = eq.Rpsi0 + 0.2 + Z_mid = eq.Zpsi0 + theta = eq.theta_geometric_at_rz(R_out, Z_mid) + assert np.isclose(theta, 0.0, atol=0.05) + + def test_theta_at_top_is_pi_half(self, eq): + R_axis = eq.Rpsi0 + Z_top = eq.Zpsi0 + 0.5 + theta = eq.theta_geometric_at_rz(R_axis, Z_top) + assert np.isclose(theta, np.pi / 2, atol=0.05) + + def test_theta_at_bottom_is_minus_pi_half(self, eq): + R_axis = eq.Rpsi0 + Z_bot = eq.Zpsi0 - 0.5 + theta = eq.theta_geometric_at_rz(R_axis, Z_bot) + assert np.isclose(theta, -np.pi / 2, atol=0.05) + + def test_theta_vectorized(self, eq): + R_arr = np.array([eq.Rpsi0 + 0.2, eq.Rpsi0, eq.Rpsi0 - 0.1]) + Z_arr = np.array([eq.Zpsi0, eq.Zpsi0 + 0.3, eq.Zpsi0]) + theta = eq.theta_geometric_at_rz(R_arr, Z_arr) + assert theta.shape == (3,) + + +class TestFluxCoordinateConversion: + + @pytest.fixture + def eq(self): + return eqdsk_file(TEST_EQDSK) + + def test_rz_to_flux_returns_spol_and_theta(self, eq): + R = eq.Rpsi0 + 0.2 + Z = eq.Zpsi0 + s_pol, theta = eq.rz_to_flux_coords(R, Z) + assert s_pol > 0.0 + assert np.isclose(theta, 0.0, atol=0.05) + + def test_rz_to_flux_vectorized(self, eq): + R_arr = np.array([eq.Rpsi0 + 0.1, eq.Rpsi0 + 0.2]) + Z_arr = np.array([eq.Zpsi0, eq.Zpsi0 + 0.1]) + s_pol, theta = eq.rz_to_flux_coords(R_arr, Z_arr) + assert s_pol.shape == (2,) + assert theta.shape == (2,) From c6fbbf80efd841c40d414c9363bbac1c8d09ebc4 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 15 Jan 2026 11:20:15 +0100 Subject: [PATCH 2/2] fix: suppress broken pipe error in dashboard merge step --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f46482f8..6fb1c346 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -288,7 +288,7 @@ jobs: fi cp -r docs-html/* site/ echo "Site structure:" - find site -type f | head -20 + find site -type f | head -20 || true - name: Configure Pages if: ${{ github.event_name == 'push' }}