From 33cafd5ecf17eac67cceae6addb735d5183d4b65 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Thu, 16 Jul 2026 20:29:38 +0200 Subject: [PATCH 1/4] Explicitly prefix guiding-statistics FITS keys with HIERARCH GUIDING UPTIME/RMS/RMS1/RMS2 exceed the 8-char FITS keyword limit, so astropy auto-promotes them to HIERARCH cards and emits a VerifyWarning every time. Writing the HIERARCH prefix ourselves produces the same card without the warning. --- pyobs/modules/pointing/guidingstatistics/pixeloffset.py | 4 ++-- pyobs/modules/pointing/guidingstatistics/skyoffsets.py | 2 +- pyobs/modules/pointing/guidingstatistics/uptime.py | 2 +- tests/modules/pointing/test_guiding_statistic_uptime.py | 2 +- .../pointing/test_guiding_statistics_pixel_offset.py | 8 ++++---- .../pointing/test_guiding_statistics_sky_offset.py | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pyobs/modules/pointing/guidingstatistics/pixeloffset.py b/pyobs/modules/pointing/guidingstatistics/pixeloffset.py index 4b83c9d9..fa5b5f63 100644 --- a/pyobs/modules/pointing/guidingstatistics/pixeloffset.py +++ b/pyobs/modules/pointing/guidingstatistics/pixeloffset.py @@ -36,8 +36,8 @@ def _build_header(self, data: list[tuple[float, float]]) -> dict[str, FitsHeader rms = self._calc_rms(data) if rms is not None: - header["GUIDING RMS1"] = FitsHeaderEntry(float(rms[0]), "RMS for guiding on axis 1") - header["GUIDING RMS2"] = FitsHeaderEntry(float(rms[1]), "RMS for guiding on axis 2") + header["HIERARCH GUIDING RMS1"] = FitsHeaderEntry(float(rms[0]), "RMS for guiding on axis 1") + header["HIERARCH GUIDING RMS2"] = FitsHeaderEntry(float(rms[1]), "RMS for guiding on axis 2") return header diff --git a/pyobs/modules/pointing/guidingstatistics/skyoffsets.py b/pyobs/modules/pointing/guidingstatistics/skyoffsets.py index ad964a52..6bff003c 100644 --- a/pyobs/modules/pointing/guidingstatistics/skyoffsets.py +++ b/pyobs/modules/pointing/guidingstatistics/skyoffsets.py @@ -37,7 +37,7 @@ def _build_header(self, data: list[float]) -> dict[str, FitsHeaderEntry]: rms = self._calc_rms(data) if rms is not None: - header["GUIDING RMS"] = FitsHeaderEntry(float(rms), "RMS for guiding on sky") + header["HIERARCH GUIDING RMS"] = FitsHeaderEntry(float(rms), "RMS for guiding on sky") return header diff --git a/pyobs/modules/pointing/guidingstatistics/uptime.py b/pyobs/modules/pointing/guidingstatistics/uptime.py index 6019d696..940e4b97 100644 --- a/pyobs/modules/pointing/guidingstatistics/uptime.py +++ b/pyobs/modules/pointing/guidingstatistics/uptime.py @@ -44,7 +44,7 @@ def _build_header(self, data: list[tuple[bool | None, datetime]]) -> dict[str, F data.append((None, now)) uptime_percentage = self._calc_uptime_percentage(data) - return {"GUIDING UPTIME": FitsHeaderEntry(uptime_percentage, "Time the guiding loop was closed [%]")} + return {"HIERARCH GUIDING UPTIME": FitsHeaderEntry(uptime_percentage, "Time the guiding loop was closed [%]")} def _get_session_data(self, input_data: bool) -> tuple[bool | None, datetime] | None: now = datetime.now() diff --git a/tests/modules/pointing/test_guiding_statistic_uptime.py b/tests/modules/pointing/test_guiding_statistic_uptime.py index 57f2037e..1eb0cdd5 100644 --- a/tests/modules/pointing/test_guiding_statistic_uptime.py +++ b/tests/modules/pointing/test_guiding_statistic_uptime.py @@ -13,7 +13,7 @@ def test_end_to_end() -> None: header = statistic.add_to_header(client, {}) - assert header["GUIDING UPTIME"].value == 100.0 + assert header["HIERARCH GUIDING UPTIME"].value == 100.0 def test_calc_uptime_percentage() -> None: diff --git a/tests/modules/pointing/test_guiding_statistics_pixel_offset.py b/tests/modules/pointing/test_guiding_statistics_pixel_offset.py index 4b2ecbe5..425b7b07 100644 --- a/tests/modules/pointing/test_guiding_statistics_pixel_offset.py +++ b/tests/modules/pointing/test_guiding_statistics_pixel_offset.py @@ -24,10 +24,10 @@ def test_end_to_end(mock_meta_image) -> None: header = statistic.add_to_header(client, {}) - assert header["GUIDING RMS1"].value == 1.0 - assert header["GUIDING RMS1"].comment == "RMS for guiding on axis 1" - assert header["GUIDING RMS2"].value == 1.0 - assert header["GUIDING RMS2"].comment == "RMS for guiding on axis 2" + assert header["HIERARCH GUIDING RMS1"].value == 1.0 + assert header["HIERARCH GUIDING RMS1"].comment == "RMS for guiding on axis 1" + assert header["HIERARCH GUIDING RMS2"].value == 1.0 + assert header["HIERARCH GUIDING RMS2"].comment == "RMS for guiding on axis 2" def test_build_header_to_few_values() -> None: diff --git a/tests/modules/pointing/test_guiding_statistics_sky_offset.py b/tests/modules/pointing/test_guiding_statistics_sky_offset.py index 4bb19cee..af6a5488 100644 --- a/tests/modules/pointing/test_guiding_statistics_sky_offset.py +++ b/tests/modules/pointing/test_guiding_statistics_sky_offset.py @@ -30,8 +30,8 @@ def test_end_to_end(mock_meta_image) -> None: header = statistic.add_to_header(client, {}) - np.testing.assert_almost_equal(header["GUIDING RMS"].value, 10.0) - assert header["GUIDING RMS"].comment == "RMS for guiding on sky" + np.testing.assert_almost_equal(header["HIERARCH GUIDING RMS"].value, 10.0) + assert header["HIERARCH GUIDING RMS"].comment == "RMS for guiding on sky" def test_build_header_to_few_values() -> None: From c6fd9a5b3641bf3fcd6f07c13749550e18eba067 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Thu, 16 Jul 2026 20:56:54 +0200 Subject: [PATCH 2/4] Ensure `curve_fit` uses `absolute_sigma` for correct error scaling and update weighted-mean focus calculation to use inverse variances. --- pyobs/utils/curvefit.py | 14 +++++++++++++- pyobs/utils/focusseries/projection.py | 17 +++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/pyobs/utils/curvefit.py b/pyobs/utils/curvefit.py index 4da803be..424e6e5e 100644 --- a/pyobs/utils/curvefit.py +++ b/pyobs/utils/curvefit.py @@ -29,8 +29,20 @@ def fit_hyperbola(x_arr: list[float], y_arr: list[float], y_err: list[float]) -> p0 = [a, b, c] # fit + # + # absolute_sigma=True is required here: without it, curve_fit treats y_err as only + # relative weights and rescales the returned covariance matrix so that the fit's + # reduced chi-square equals 1. That makes the reported variance reflect how well the + # hyperbola shape matches this particular focus run rather than the actual per-point + # measurement uncertainty passed in via y_err, which silently miscalibrates every + # downstream error estimate. coeffs, cov = curve_fit( - lambda xx, aa, bb, cc: bb * np.sqrt((xx - cc) ** 2 / aa**2 + 1.0), x_arr, y_arr, sigma=y_err, p0=p0 + lambda xx, aa, bb, cc: bb * np.sqrt((xx - cc) ** 2 / aa**2 + 1.0), + x_arr, + y_arr, + sigma=y_err, + p0=p0, + absolute_sigma=True, ) # return result diff --git a/pyobs/utils/focusseries/projection.py b/pyobs/utils/focusseries/projection.py index 837e984d..fd7987aa 100644 --- a/pyobs/utils/focusseries/projection.py +++ b/pyobs/utils/focusseries/projection.py @@ -126,14 +126,15 @@ def fit_focus(self) -> tuple[float, float]: # fit focus try: - xfoc, xerr = fit_hyperbola(focus, xfwhm, xsig) - yfoc, yerr = fit_hyperbola(focus, yfwhm, ysig) - - # weighted mean - xerr = np.sqrt(xerr) - yerr = np.sqrt(yerr) - foc = (xfoc / xerr + yfoc / yerr) / (1.0 / xerr + 1.0 / yerr) - err = 2.0 / (1.0 / xerr + 1.0 / yerr) + # fit_hyperbola returns (focus, variance) for each axis + xfoc, xvar = fit_hyperbola(focus, xfwhm, xsig) + yfoc, yvar = fit_hyperbola(focus, yfwhm, ysig) + + # inverse-variance weighted mean of the two independent axis estimates + xweight = 1.0 / xvar + yweight = 1.0 / yvar + foc = (xfoc * xweight + yfoc * yweight) / (xweight + yweight) + err = np.sqrt(1.0 / (xweight + yweight)) except (RuntimeError, RuntimeWarning): raise ValueError("Could not find best focus.") From d38a22a60db0c556d69a119bd605d73d551d684d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Thu, 16 Jul 2026 21:07:56 +0200 Subject: [PATCH 3/4] Fix focus error underreported in PhotometryFocusSeries fit_hyperbola returns (focus, variance), not (focus, error), so the reported focus uncertainty was systematically too small by a factor equal to its own square root. --- pyobs/utils/focusseries/photometry.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyobs/utils/focusseries/photometry.py b/pyobs/utils/focusseries/photometry.py index 2595f22f..65e59a46 100644 --- a/pyobs/utils/focusseries/photometry.py +++ b/pyobs/utils/focusseries/photometry.py @@ -83,7 +83,12 @@ def fit_focus(self) -> tuple[float, float]: # fit focus try: - foc, err = fit_hyperbola(focus, r, rerr) + # fit_hyperbola returns (focus, variance), not (focus, error) -- take the + # square root, otherwise the reported error is actually the variance and is + # systematically too small (e.g. 0.01 mm true error would be reported as + # 0.0001). + foc, var = fit_hyperbola(focus, r, rerr) + err = np.sqrt(var) except (RuntimeError, RuntimeWarning): raise ValueError("Could not find best focus.") From b0dc15a08c4bbf13adf22e7be4906a61bc99fde4 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Thu, 16 Jul 2026 21:08:17 +0200 Subject: [PATCH 4/4] v2.0.0.dev27 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b28aa1fd..1f6fea5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyobs-core" -version = "2.0.0.dev26" +version = "2.0.0.dev27" description = "robotic telescope software" authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }] requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index e863fa85..727293cd 100644 --- a/uv.lock +++ b/uv.lock @@ -3237,7 +3237,7 @@ wheels = [ [[package]] name = "pyobs-core" -version = "2.0.0.dev26" +version = "2.0.0.dev27" source = { editable = "." } dependencies = [ { name = "aiohttp" },