From a3bc5805c7058431683f07c5026e81eff64833d7 Mon Sep 17 00:00:00 2001 From: martin kilbinger Date: Wed, 25 Feb 2026 16:05:00 +0100 Subject: [PATCH 1/7] Add GitHub Action for PyPI publishing --- .github/workflows/publish.yml | 52 +++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4e21da1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,52 @@ +name: Publish Python distribution to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build package + run: python -m build + + - name: Store distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/cs_util + permissions: + id-token: write + + steps: + - name: Download distribution packages + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From 776209df5f7d12004a575786c52e8e04568fca45 Mon Sep 17 00:00:00 2001 From: martinkilbinger Date: Wed, 25 Feb 2026 16:42:30 +0100 Subject: [PATCH 2/7] removed broken action --- .github/workflows/create_release.yml | 29 ---------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/create_release.yml diff --git a/.github/workflows/create_release.yml b/.github/workflows/create_release.yml deleted file mode 100644 index 1600ffe..0000000 --- a/.github/workflows/create_release.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Create Release - -on: - push: - tags: - - '*' - -jobs: - create_release: - name: Create Release - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.x - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build and upload package - run: | - python -m build --sdist --wheel - twine upload dist/* From b6d89e56471faaca44fab846fe92d6a94a78dacf Mon Sep 17 00:00:00 2001 From: "martin.kilbinger" Date: Tue, 7 Apr 2026 09:46:54 +0200 Subject: [PATCH 3/7] skyproj not optional --- cs_util/plots.py | 126 +++-------------------------------------------- pyproject.toml | 6 ++- 2 files changed, 11 insertions(+), 121 deletions(-) diff --git a/cs_util/plots.py b/cs_util/plots.py index fe478ea..7daafc1 100644 --- a/cs_util/plots.py +++ b/cs_util/plots.py @@ -16,7 +16,11 @@ import matplotlib.pylab as plt import matplotlib.ticker as ticker import numpy as np -import skyproj +from typing import Any +try: + import skyproj +except ImportError: + skyproj = None from astropy import units as u from astropy.coordinates import SkyCoord @@ -502,123 +506,7 @@ def create_hsp_map(self, ra, dec): return hsp_map - def plot_area( - self, - hsp_map, - ra_0=0, - extend=[120, 270, 29, 70], - vmin=0, - vmax=60, - projection=None, - outpath=None, - title=None, - colorbar=True, - colorbar_label="Coverage depth", - ): - """Plot Area. - - Plot catalogue in an area on the sky. - - Parameters - ---------- - hsp_map : hsp_HealSparseMap - input map - ra_0 : float, optional - anchor point in R.A.; default is 0 - extend : list, optional - sky region, extend=[ra_low, ra_high, dec_low, dec_high]; - default is [120, 270, 29, 70] - vmin : float, optional - minimum pixel value to plot with color; default is 0 - vmax : float, optional - maximum pixel value to plot with color; default is 60 - projection : skyproj.McBrydeSkyproj - if ``None`` (default), a new plot is created - outpath : str, optional - output path, default is ``None`` - title : str, optional - print title if not ``None`` (default) - colorbar : bool, optional - add colorbar; default is ``True`` - colorbar_label : str, optional - colorbar label; default is "Coverage depth" - - Returns - -------- - skyproj.McBrydeSkyproj - projection instance - plt.axes.Axes - axes instance - - Raises - ------ - ValueError - if no object found in region - - """ - if not projection: - - # Create new figure and axes - fig, ax = plt.subplots(figsize=(10, 10)) - - # Create new projection - projection = skyproj.McBrydeSkyproj( - ax=ax, - lon_0=ra_0, - extent=extend, - autorescale=False, - ) - else: - ax = None - - im = None - try: - im, lon_raster, lat_raster, values_raster = projection.draw_hspmap( - hsp_map, lon_range=extend[0:2], lat_range=extend[2:], vmin=vmin, vmax=vmax - ) - except ValueError: - msg = "No object found in region to draw" - print(f"{msg}, continuing...") - - projection.draw_milky_way( - width=25, linewidth=1.5, color="black", linestyle="-" - ) - - # Use skyproj's own methods to enforce extent - projection.set_autorescale(False) - projection.set_extent(extend) - - # Set axis labels - if ax: - ax.set_xlabel("R.A. [deg]") - ax.set_ylabel("Dec [deg]") - else: - projection.ax.set_xlabel("R.A. [deg]") - projection.ax.set_ylabel("Dec [deg]") - - # Add colorbar if requested and image was drawn - if colorbar and im is not None: - plt.colorbar( - im, - ax=ax if ax else projection.ax, - label=colorbar_label, - orientation="horizontal", - location="top", - pad=0.05, - ) - - if title: - plt.title(title, pad=5) - - # Force extent again after all plotting operations to ensure it's respected - projection.set_autorescale(False) - projection.set_extent(extend) - - if outpath: - plt.savefig(outpath) - - return projection, ax - + def plot_region( self, hsp_map, @@ -639,7 +527,7 @@ def plot_region( input map region : dict region dictionary with keys 'ra_0', 'extend', 'vmin', 'vmax' - projection : skyproj.McBrydeSkyproj, optional + projection : Any, optional if ``None`` (default), a new plot is created outpath : str, optional output path, default is ``None`` diff --git a/pyproject.toml b/pyproject.toml index b72bf78..4a5fc27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,10 +22,12 @@ dependencies = [ "camb>=1.5.9", "healpy>=1.16.0", "healsparse>=1.8.0", - "skyproj>=1.0.0", -] + ] [project.optional-dependencies] +skyproj = [ + "skyproj>=1.0.0", +] lint = [ "black", ] From ce8d1593aedbe42ba5ea4bda88324409120e99ce Mon Sep 17 00:00:00 2001 From: "martin.kilbinger" Date: Tue, 7 Apr 2026 10:02:16 +0200 Subject: [PATCH 4/7] v0.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4a5fc27..426fc33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cs_util" -version = "0.1.9" +version = "0.2" description = "Utility library for CosmoStat" authors = [ { name = "Martin Kilbinger", email = "martin.kilbinger@cea.fr" }, From fc9580bd972710a1afec663fbcafdc4b30a26a58 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Wed, 10 Jun 2026 03:59:51 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20add=20size=20module=20=E2=80=94=20G?= =?UTF-8?q?aussian=20size-conversion=20web=20(T,=20sigma,=20r50,=20FWHM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source of truth for the size conversions used across the UNIONS / ShapePipe stack, so producers (ShapePipe ngmix writer) and consumers (sp_validation) stop re-deriving the factors locally: T = 2 sigma^2 (ngmix / DES area parameter) r50 = sqrt(2 ln 2) sigma (half-light radius, the primary size in the UNIONS shape-catalogue papers) FWHM = 2 sqrt(2 ln 2) sigma Primitives (T <-> sigma, sigma <-> r50, sigma <-> fwhm) plus the composites consumers actually call (T_to_r50, r50_to_T, T_to_fwhm). T_to_fwhm in particular replaces a dimensionally wrong local version in sp_validation (T / 1.17741 * 2.355, which treats the area T as if it were already sigma); here the area-to-length conversion carries the required square root. Constants are exact (sqrt(2 ln 2), 2 sqrt(2 ln 2)) rather than the truncated 1.1774 / 2.355 literals. Tests cover closed forms, unit-sigma values, round trips, FWHM = 2 r50, and array input. Co-Authored-By: Claude Fable 5 --- cs_util/size.py | 212 +++++++++++++++++++++++++++++++++++++ cs_util/tests/test_size.py | 79 ++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 cs_util/size.py create mode 100644 cs_util/tests/test_size.py diff --git a/cs_util/size.py b/cs_util/size.py new file mode 100644 index 0000000..bd41fe9 --- /dev/null +++ b/cs_util/size.py @@ -0,0 +1,212 @@ +"""SIZE. + +:Name: size.py + +:Description: Conversions between the Gaussian object-size measures + used across the UNIONS / ShapePipe stack. + + All conversions assume a circular Gaussian profile, for which the + measures are related through the scale parameter ``sigma``: + + - ``T = 2 sigma^2`` — the ngmix / DES area parameter (arcsec^2), + - ``r50 = sqrt(2 ln 2) sigma = 1.17741 sigma`` — the half-light + radius (the primary size in the UNIONS shape-catalogue papers), + - ``FWHM = 2 sqrt(2 ln 2) sigma = 2.35482 sigma``. + + These functions are the single source of truth for the size web; + producers (ShapePipe) and consumers (sp_validation) should import + from here rather than re-deriving the factors locally. + +:Author: Cail Daley + +""" + +import numpy as np + +# Half-light radius of a circular Gaussian per unit sigma: +# r50 = sqrt(2 ln 2) * sigma +SIGMA_TO_R50 = np.sqrt(2 * np.log(2)) + +# Full width at half maximum of a Gaussian per unit sigma: +# FWHM = 2 sqrt(2 ln 2) * sigma +SIGMA_TO_FWHM = 2 * np.sqrt(2 * np.log(2)) + + +def T_to_sigma(T): + """T To Sigma. + + Gaussian scale ``sigma = sqrt(T / 2)`` from the ngmix area + parameter ``T = 2 sigma^2``. + + Parameters + ---------- + T : float or numpy.ndarray + ngmix area parameter, ``T = 2 sigma^2`` + + Returns + ------- + float or numpy.ndarray + Gaussian scale ``sigma``, in units of ``sqrt(T)`` + + """ + return np.sqrt(T / 2) + + +def sigma_to_T(sigma): + """Sigma To T. + + ngmix area parameter ``T = 2 sigma^2`` from the Gaussian scale. + + Parameters + ---------- + sigma : float or numpy.ndarray + Gaussian scale + + Returns + ------- + float or numpy.ndarray + ngmix area parameter ``T``, in units of ``sigma^2`` + + """ + return 2 * sigma ** 2 + + +def sigma_to_r50(sigma): + """Sigma To R50. + + Half-light radius ``r50 = sqrt(2 ln 2) sigma`` of a circular + Gaussian. + + Parameters + ---------- + sigma : float or numpy.ndarray + Gaussian scale + + Returns + ------- + float or numpy.ndarray + half-light radius, in units of ``sigma`` + + """ + return SIGMA_TO_R50 * sigma + + +def r50_to_sigma(r50): + """R50 To Sigma. + + Gaussian scale from the half-light radius. + + Parameters + ---------- + r50 : float or numpy.ndarray + half-light radius + + Returns + ------- + float or numpy.ndarray + Gaussian scale ``sigma``, in units of ``r50`` + + """ + return r50 / SIGMA_TO_R50 + + +def sigma_to_fwhm(sigma): + """Sigma To Fwhm. + + Full width at half maximum ``FWHM = 2 sqrt(2 ln 2) sigma`` of a + Gaussian. + + Parameters + ---------- + sigma : float or numpy.ndarray + Gaussian scale + + Returns + ------- + float or numpy.ndarray + FWHM, in units of ``sigma`` + + """ + return SIGMA_TO_FWHM * sigma + + +def fwhm_to_sigma(fwhm): + """Fwhm To Sigma. + + Gaussian scale from the full width at half maximum. + + Parameters + ---------- + fwhm : float or numpy.ndarray + full width at half maximum + + Returns + ------- + float or numpy.ndarray + Gaussian scale ``sigma``, in units of ``fwhm`` + + """ + return fwhm / SIGMA_TO_FWHM + + +def T_to_r50(T): + """T To R50. + + Half-light radius ``r50 = sqrt(2 ln 2) * sqrt(T / 2) + = sqrt(ln 2 * T)`` from the ngmix area parameter. + + Parameters + ---------- + T : float or numpy.ndarray + ngmix area parameter, ``T = 2 sigma^2`` + + Returns + ------- + float or numpy.ndarray + half-light radius, in units of ``sqrt(T)`` + + """ + return sigma_to_r50(T_to_sigma(T)) + + +def r50_to_T(r50): + """R50 To T. + + ngmix area parameter ``T = 2 (r50 / sqrt(2 ln 2))^2 = r50^2 / ln 2`` + from the half-light radius. + + Parameters + ---------- + r50 : float or numpy.ndarray + half-light radius + + Returns + ------- + float or numpy.ndarray + ngmix area parameter ``T``, in units of ``r50^2`` + + """ + return sigma_to_T(r50_to_sigma(r50)) + + +def T_to_fwhm(T): + """T To Fwhm. + + Full width at half maximum ``FWHM = 2 sqrt(2 ln 2) * sqrt(T / 2)`` + from the ngmix area parameter. + + Note that ``T`` is an *area*: the conversion to the length ``FWHM`` + involves a square root, not a linear factor. + + Parameters + ---------- + T : float or numpy.ndarray + ngmix area parameter, ``T = 2 sigma^2`` + + Returns + ------- + float or numpy.ndarray + FWHM, in units of ``sqrt(T)`` + + """ + return sigma_to_fwhm(T_to_sigma(T)) diff --git a/cs_util/tests/test_size.py b/cs_util/tests/test_size.py new file mode 100644 index 0000000..111f155 --- /dev/null +++ b/cs_util/tests/test_size.py @@ -0,0 +1,79 @@ +"""UNIT TESTS FOR SIZE SUBPACKAGE. + +This module contains unit tests for the size subpackage. + +""" + +import numpy as np +from numpy import testing as npt + +from unittest import TestCase + +from cs_util import size + + +class SizeTestCase(TestCase): + """Test case for the ``size`` module.""" + + def setUp(self): + """Set test parameter values.""" + # Unit-sigma Gaussian: T = 2, r50 = 1.17741, FWHM = 2.35482 + self._sigma = 1.0 + self._T = 2.0 + self._r50 = 1.1774100226 + self._fwhm = 2.3548200450 + + def tearDown(self): + """Unset test parameter values.""" + self._sigma = None + self._T = None + self._r50 = None + self._fwhm = None + + def test_constants(self): + """Test the module constants against their closed forms.""" + npt.assert_almost_equal(size.SIGMA_TO_R50, np.sqrt(2 * np.log(2))) + npt.assert_almost_equal(size.SIGMA_TO_FWHM, 2 * np.sqrt(2 * np.log(2))) + npt.assert_almost_equal(size.SIGMA_TO_R50, 1.17741, decimal=5) + npt.assert_almost_equal(size.SIGMA_TO_FWHM, 2.35482, decimal=5) + + def test_unit_sigma_values(self): + """Test all conversions on the unit-sigma Gaussian.""" + npt.assert_almost_equal(size.T_to_sigma(self._T), self._sigma) + npt.assert_almost_equal(size.sigma_to_T(self._sigma), self._T) + npt.assert_almost_equal(size.sigma_to_r50(self._sigma), self._r50) + npt.assert_almost_equal(size.sigma_to_fwhm(self._sigma), self._fwhm) + npt.assert_almost_equal(size.T_to_r50(self._T), self._r50) + npt.assert_almost_equal(size.T_to_fwhm(self._T), self._fwhm) + + def test_T_to_r50_closed_form(self): + """Test ``T_to_r50(T) == sqrt(ln 2 * T)``.""" + T = np.array([0.05, 0.18, 2.0, 10.0]) + npt.assert_allclose(size.T_to_r50(T), np.sqrt(np.log(2) * T)) + + def test_round_trips(self): + """Test that inverse pairs compose to the identity.""" + values = np.array([0.01, 0.1, 1.0, 7.5]) + npt.assert_allclose(size.r50_to_T(size.T_to_r50(values)), values) + npt.assert_allclose(size.T_to_r50(size.r50_to_T(values)), values) + npt.assert_allclose( + size.sigma_to_T(size.T_to_sigma(values)), values + ) + npt.assert_allclose( + size.r50_to_sigma(size.sigma_to_r50(values)), values + ) + npt.assert_allclose( + size.fwhm_to_sigma(size.sigma_to_fwhm(values)), values + ) + + def test_fwhm_is_twice_r50(self): + """Test ``FWHM = 2 r50`` for a Gaussian, via both paths.""" + T = np.array([0.05, 0.18, 2.0]) + npt.assert_allclose(size.T_to_fwhm(T), 2 * size.T_to_r50(T)) + + def test_array_input(self): + """Test that array input returns arrays of the same shape.""" + T = np.linspace(0.01, 5.0, 11) + r50 = size.T_to_r50(T) + self.assertEqual(r50.shape, T.shape) + self.assertTrue(np.all(np.diff(r50) > 0)) From a8771add81f8b54ccaf172bc09e34a687dac5249 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 11 Jun 2026 01:52:20 +0200 Subject: [PATCH 6/7] test: pin inverse converters and nonpositive-T contract; author email Co-Authored-By: Claude Fable 5 --- cs_util/size.py | 2 +- cs_util/tests/test_size.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cs_util/size.py b/cs_util/size.py index bd41fe9..9c12760 100644 --- a/cs_util/size.py +++ b/cs_util/size.py @@ -17,7 +17,7 @@ producers (ShapePipe) and consumers (sp_validation) should import from here rather than re-deriving the factors locally. -:Author: Cail Daley +:Author: Cail Daley """ diff --git a/cs_util/tests/test_size.py b/cs_util/tests/test_size.py index 111f155..3ab01a6 100644 --- a/cs_util/tests/test_size.py +++ b/cs_util/tests/test_size.py @@ -51,6 +51,20 @@ def test_T_to_r50_closed_form(self): T = np.array([0.05, 0.18, 2.0, 10.0]) npt.assert_allclose(size.T_to_r50(T), np.sqrt(np.log(2) * T)) + def test_inverse_direct_values(self): + """Test each inverse converter directly on the unit-sigma case.""" + npt.assert_almost_equal(size.r50_to_sigma(self._r50), self._sigma) + npt.assert_almost_equal(size.fwhm_to_sigma(self._fwhm), self._sigma) + npt.assert_almost_equal(size.r50_to_T(self._r50), self._T) + + def test_nonpositive_T(self): + """Test that T = 0 maps to size 0 and negative T to NaN.""" + npt.assert_equal(size.T_to_sigma(0.0), 0.0) + npt.assert_equal(size.T_to_r50(0.0), 0.0) + npt.assert_equal(size.T_to_fwhm(0.0), 0.0) + with np.errstate(invalid="ignore"): + self.assertTrue(np.isnan(size.T_to_r50(-1.0))) + def test_round_trips(self): """Test that inverse pairs compose to the identity.""" values = np.array([0.01, 0.1, 1.0, 7.5]) From 28d37bcfced0bf1cea14ffc17f4b782314ee25a2 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 11 Jun 2026 01:56:28 +0200 Subject: [PATCH 7/7] chore: bump version to 0.2.1 (first release with cs_util.size) Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 426fc33..ad014c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cs_util" -version = "0.2" +version = "0.2.1" description = "Utility library for CosmoStat" authors = [ { name = "Martin Kilbinger", email = "martin.kilbinger@cea.fr" },