From 034555d4488f82b70498904330bd7532b70eb680 Mon Sep 17 00:00:00 2001 From: Rama Vasudevan Date: Fri, 27 Feb 2026 11:22:08 -0500 Subject: [PATCH 1/3] new tests --- tests/proc/test_fitter_refactor.py | 145 ++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/tests/proc/test_fitter_refactor.py b/tests/proc/test_fitter_refactor.py index b47ff2c7..e91c3c09 100644 --- a/tests/proc/test_fitter_refactor.py +++ b/tests/proc/test_fitter_refactor.py @@ -227,4 +227,147 @@ def test_2d_fit_execution(self): diag = np.diag(cov_matrix) self.assertTrue(np.all(diag >= 0), "Covariance diagonal elements must be non-negative") - \ No newline at end of file +class TestSidpyFitterWithBounds(unittest.TestCase): + + def setUp(self): + """ + Synthetic 3x3 spatial x 50-point spectral 1D Gaussian dataset. + Fast, self-contained, no network required. + """ + self.n_x, self.n_y, self.n_spec = 3, 3, 50 + x_axis = np.linspace(-10, 10, self.n_spec) + self.true_params = np.array([3.0, 0.0, 2.0, 0.5]) # amp, center, sigma, offset + + raw = np.zeros((self.n_x, self.n_y, self.n_spec)) + for i in range(self.n_x): + for j in range(self.n_y): + amp, cen, sig, off = self.true_params + cen_ij = cen + 0.5 * (i - 1) + raw[i, j] = (amp * np.exp(-0.5 * ((x_axis - cen_ij) / sig) ** 2) + + off + + np.random.default_rng(i * 10 + j).normal(0, 0.05, self.n_spec)) + + self.dataset = sid.Dataset.from_array(raw, name='Synthetic_1D_Gauss') + self.dataset.set_dimension(0, sid.Dimension(np.arange(self.n_x), 'x', + dimension_type='spatial')) + self.dataset.set_dimension(1, sid.Dimension(np.arange(self.n_y), 'y', + dimension_type='spatial')) + self.dataset.set_dimension(2, sid.Dimension(x_axis, 'spectrum', + dimension_type='spectral')) + + def _gaussian(x, amp, cen, sig, off): + return amp * np.exp(-0.5 * ((x - cen) / sig) ** 2) + off + + def _gaussian_guess(x, y): + off = np.percentile(y, 10) + amp = float(y.max()) - off + cen = float(x[np.argmax(y)]) + sig = (x[-1] - x[0]) / 6.0 + return [amp, cen, sig, off] + + self.model_func = _gaussian + self.guess_func = _gaussian_guess + + def _make_fitter(self, lower_bounds=None, upper_bounds=None): + """Helper: build and setup a fitter with optional bounds.""" + fitter = SidpyFitterRefactor( + self.dataset, self.model_func, self.guess_func, + lower_bounds=lower_bounds, upper_bounds=upper_bounds, + ) + fitter.setup_calc() + return fitter + + def test_unbounded_unchanged(self): + """Unbounded fit must return a valid sidpy.Dataset with finite params.""" + result, _ = self._make_fitter().do_fit() + self.assertIsInstance(result, sid.Dataset) + self.assertTrue(np.all(np.isfinite(np.array(result)))) + + def test_scalar_lower_bound(self): + """Scalar lower_bounds=0 — all returned params must be >= 0.""" + result, _ = self._make_fitter(lower_bounds=0.0).do_fit() + params = np.array(result) + self.assertTrue(np.all(params >= -1e-6), + msg=f"Some params violated lower_bound=0: min={params.min()}") + + def test_scalar_upper_bound(self): + """Scalar upper_bounds=1e6 — all returned params must be <= 1e6.""" + upper = 1e6 + result, _ = self._make_fitter(upper_bounds=upper).do_fit() + params = np.array(result) + self.assertTrue(np.all(params <= upper + 1e-6), + msg=f"Some params violated upper_bound={upper}: max={params.max()}") + + def test_per_param_bounds_respected(self): + """Per-parameter array bounds — each param stays within its own [lb, ub].""" + n = self._make_fitter().num_params + lb = np.zeros(n) + ub = np.full(n, 1e6) + result, _ = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() + params = np.array(result) + for i in range(n): + p = params[..., i] + self.assertTrue(np.all(p >= lb[i] - 1e-6), + msg=f"Param {i} violated lower bound {lb[i]}: min={p.min()}") + self.assertTrue(np.all(p <= ub[i] + 1e-6), + msg=f"Param {i} violated upper bound {ub[i]}: max={p.max()}") + + def test_guess_outside_bounds_no_crash(self): + """Guess outside bounds must be clipped silently, not crash.""" + n = self._make_fitter().num_params + fitter = self._make_fitter(lower_bounds=np.full(n, -1e-10), + upper_bounds=np.full(n, 1e-10)) + try: + fitter.do_fit() + except Exception as e: + self.fail(f"do_fit raised unexpectedly with out-of-bounds guess: {e}") + + def test_bounds_length_mismatch_raises(self): + """Bound array with wrong length must raise ValueError.""" + n = self._make_fitter().num_params + fitter = self._make_fitter(lower_bounds=np.zeros(n + 3)) + with self.assertRaises(ValueError): + fitter.do_fit() + + def test_lb_greater_than_ub_raises(self): + """lower_bounds > upper_bounds must raise ValueError.""" + n = self._make_fitter().num_params + fitter = self._make_fitter(lower_bounds=np.full(n, 10.0), + upper_bounds=np.full(n, 1.0)) + with self.assertRaises(ValueError): + fitter.do_fit() + + def test_bounds_stored_in_metadata(self): + """Bounds must appear correctly in result metadata.""" + n = self._make_fitter().num_params + lb = list(np.zeros(n)) + ub = list(np.ones(n) * 1e6) + result, _ = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() + meta = result.metadata["fit_parameters"] + self.assertIn("lower_bounds", meta) + self.assertIn("upper_bounds", meta) + self.assertEqual(meta["lower_bounds"], lb) + self.assertEqual(meta["upper_bounds"], ub) + + def test_none_bounds_metadata_is_none(self): + """When no bounds are passed, metadata entries must be None.""" + result, _ = self._make_fitter().do_fit() + meta = result.metadata["fit_parameters"] + self.assertIsNone(meta.get("lower_bounds")) + self.assertIsNone(meta.get("upper_bounds")) + + def test_bounds_with_nonlinear_loss(self): + """Bounds + non-linear loss must not raise (both require method='trf').""" + fitter = self._make_fitter(lower_bounds=0.0) + try: + result, _ = fitter.do_fit(loss='soft_l1') + except Exception as e: + self.fail(f"do_fit raised with bounds + non-linear loss: {e}") + self.assertIsInstance(result, sid.Dataset) + + def test_bounds_with_return_cov(self): + """Bounds must be compatible with return_cov=True; covariance shape check.""" + n = self._make_fitter().num_params + params, cov = self._make_fitter(lower_bounds=0.0).do_fit(return_cov=True) + self.assertEqual(cov.shape[-2:], (n, n), + msg=f"Covariance shape {cov.shape} does not end in ({n},{n})") \ No newline at end of file From b277da932d6ec6bf1515ee1666c4317a804af32b Mon Sep 17 00:00:00 2001 From: Rama Vasudevan Date: Mon, 16 Mar 2026 15:16:23 -0400 Subject: [PATCH 2/3] mcp server for beps --- sidpy/proc/mcp_server_beps.py | 473 ++++++++++++++++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 sidpy/proc/mcp_server_beps.py diff --git a/sidpy/proc/mcp_server_beps.py b/sidpy/proc/mcp_server_beps.py new file mode 100644 index 00000000..6a2d608e --- /dev/null +++ b/sidpy/proc/mcp_server_beps.py @@ -0,0 +1,473 @@ +""" +MCP server utilities for BEPS loop and SHO fitting. + +This module wraps the existing ``SidpyFitterRefactor`` workflows in MCP-friendly +tools so an LLM client can fit nested-array BEPS loop data and SHO response +data without re-implementing the fitting logic. + +The MCP runtime is optional. Importing this module does not require ``mcp``, +but running the server does. +""" + +from __future__ import annotations + +from typing import Any, Dict, Iterable, Optional, Sequence + +import numpy as np +import sidpy as sid +from scipy.spatial import ConvexHull +from scipy.special import erf + +from .fitter_refactor import SidpyFitterRefactor + +try: + from mcp.server.fastmcp import FastMCP +except ImportError: # pragma: no cover - optional runtime dependency + FastMCP = None + + +LOOP_PARAMETER_LABELS = [ + "offset", + "amplitude", + "coercive_left", + "coercive_right", + "slope", + "branch_scale_1", + "branch_scale_2", + "branch_scale_3", + "branch_scale_4", +] + +SHO_PARAMETER_LABELS = ["amplitude", "resonance_frequency", "quality_factor", "phase"] + + +def loop_fit_function(vdc: Sequence[float], *coef_vec: float) -> np.ndarray: + """Nine-parameter loop model used by the BEPS fitter tests.""" + vdc = np.asarray(vdc).squeeze() + a = coef_vec[:5] + b = coef_vec[5:] + d = 1000 + + v1 = np.asarray(vdc[: int(len(vdc) / 2)]) + v2 = np.asarray(vdc[int(len(vdc) / 2) :]) + + g1 = (b[1] - b[0]) / 2 * (erf((v1 - a[2]) * d) + 1) + b[0] + g2 = (b[3] - b[2]) / 2 * (erf((v2 - a[3]) * d) + 1) + b[2] + + y1 = (g1 * erf((v1 - a[2]) / g1) + b[0]) / (b[0] + b[1]) + y2 = (g2 * erf((v2 - a[3]) / g2) + b[2]) / (b[2] + b[3]) + + f1 = a[0] + a[1] * y1 + a[4] * v1 + f2 = a[0] + a[1] * y2 + a[4] * v2 + return np.hstack((f1, f2)).squeeze() + + +def calculate_loop_centroid(vdc: Sequence[float], loop_vals: Sequence[float]) -> tuple[tuple[float, float], float]: + """Calculate the polygon centroid for one unfolded loop.""" + vdc = np.squeeze(np.asarray(vdc)) + loop_vals = np.squeeze(np.asarray(loop_vals)) + num_steps = vdc.size + + x_vals = np.zeros(num_steps - 1) + y_vals = np.zeros(num_steps - 1) + area_vals = np.zeros(num_steps - 1) + + for index in range(num_steps - 1): + x_i = vdc[index] + x_i1 = vdc[index + 1] + y_i = loop_vals[index] + y_i1 = loop_vals[index + 1] + + x_vals[index] = (x_i + x_i1) * (x_i * y_i1 - x_i1 * y_i) + y_vals[index] = (y_i + y_i1) * (x_i * y_i1 - x_i1 * y_i) + area_vals[index] = x_i * y_i1 - x_i1 * y_i + + area = 0.5 * np.sum(area_vals) + cent_x = (1.0 / (6.0 * area)) * np.sum(x_vals) + cent_y = (1.0 / (6.0 * area)) * np.sum(y_vals) + return (cent_x, cent_y), area + + +def generate_guess(vdc: Sequence[float], pr_vec: Sequence[float], show_plots: bool = False) -> np.ndarray: + """ + Generate the initial BEPS loop parameter guess. + + This matches the tested heuristic in ``tests/proc/test_fitter_refactor.py``. + ``show_plots`` is accepted for API compatibility but not used here. + """ + del show_plots + + points = np.transpose(np.array([np.squeeze(vdc), pr_vec])) + geom_centroid, _ = calculate_loop_centroid(points[:, 0], points[:, 1]) + hull = ConvexHull(points) + + def find_intersection(a: Sequence[float], b: Sequence[float], c: Sequence[float], d: Sequence[float]): + def ccw(p_a, p_b, p_c): + return (p_c[1] - p_a[1]) * (p_b[0] - p_a[0]) > (p_b[1] - p_a[1]) * (p_c[0] - p_a[0]) + + def line(p1, p2): + coeff_a = p1[1] - p2[1] + coeff_b = p2[0] - p1[0] + coeff_c = p1[0] * p2[1] - p2[0] * p1[1] + return coeff_a, coeff_b, -coeff_c + + def intersection(line_1, line_2): + det = line_1[0] * line_2[1] - line_1[1] * line_2[0] + det_x = line_1[2] * line_2[1] - line_1[1] * line_2[2] + det_y = line_1[0] * line_2[2] - line_1[2] * line_2[0] + if det == 0: + return None + return det_x / det, det_y / det + + intersects = (ccw(a, c, d) is not ccw(b, c, d)) and (ccw(a, b, c) is not ccw(a, b, d)) + if not intersects: + return None + return intersection(line(a, b), line(c, d)) + + outline_1 = np.zeros((hull.simplices.shape[0], 2), dtype=float) + outline_2 = np.zeros((hull.simplices.shape[0], 2), dtype=float) + for index, pair in enumerate(hull.simplices): + outline_1[index, :] = points[pair[0]] + outline_2[index, :] = points[pair[1]] + + y_intersections = [] + for pair in range(outline_1.shape[0]): + point = find_intersection( + outline_1[pair], + outline_2[pair], + [geom_centroid[0], hull.min_bound[1]], + [geom_centroid[0], hull.max_bound[1]], + ) + if point is not None: + y_intersections.append(point) + + x_intersections = [] + for pair in range(outline_1.shape[0]): + point = find_intersection( + outline_1[pair], + outline_2[pair], + [hull.min_bound[0], geom_centroid[1]], + [hull.max_bound[0], geom_centroid[1]], + ) + if point is not None: + x_intersections.append(point) + + if len(y_intersections) < 2: + min_y_intercept = min(pr_vec) + max_y_intercept = max(pr_vec) + else: + min_y_intercept = min(y_intersections[0][1], y_intersections[1][1]) + max_y_intercept = max(y_intersections[0][1], y_intersections[1][1]) + + if len(x_intersections) < 2: + min_x_intercept = min(vdc) / 2.0 + max_x_intercept = max(vdc) / 2.0 + else: + min_x_intercept = min(x_intersections[0][0], x_intersections[1][0]) + max_x_intercept = max(x_intersections[0][0], x_intersections[1][0]) + + init_guess = np.zeros(shape=9) + init_guess[0] = min_y_intercept + init_guess[1] = max_y_intercept - min_y_intercept + init_guess[2] = min_x_intercept + init_guess[3] = max_x_intercept + init_guess[4] = 0 + init_guess[5:] = 2 + return init_guess + + +def SHO_fit_flattened(wvec: Sequence[float], *params: float) -> np.ndarray: + """Flattened complex SHO response used by the SHO fitter tests.""" + amp, w_0, quality_factor, phase = params[0], params[1], params[2], params[3] + func = amp * np.exp(1j * phase) * w_0**2 / (wvec**2 - 1j * wvec * w_0 / quality_factor - w_0**2) + return np.hstack([np.real(func), np.imag(func)]) + + +def sho_guess_fn(freq_vec: Sequence[float], ydata: Sequence[complex]) -> list[float]: + """Initial guess heuristic for SHO fitting.""" + ydata = np.asarray(ydata) + amp_guess = np.abs(ydata)[np.argmax(np.abs(ydata))] + phase_guess = np.angle(ydata)[np.argmax(np.abs(ydata))] + w_guess = np.asarray(freq_vec)[np.argmax(np.abs(ydata))] + + q_values = [5, 10, 20, 50, 100, 200, 500] + err_vals = [] + for q_val in q_values: + p_test = [amp_guess / q_val, w_guess, q_val, phase_guess] + func_out = SHO_fit_flattened(freq_vec, *p_test) + complex_output = func_out[: len(func_out) // 2] + 1j * func_out[len(func_out) // 2 :] + amp_output = np.abs(complex_output) + err_vals.append(np.mean((amp_output - np.abs(ydata)) ** 2)) + + q_guess = q_values[int(np.argmin(err_vals))] + return [amp_guess / q_guess, w_guess, q_guess, phase_guess] + + +def _as_builtin(value: Any) -> Any: + """Convert numpy-heavy structures to JSON-serializable builtins.""" + if isinstance(value, dict): + return {str(key): _as_builtin(val) for key, val in value.items()} + if isinstance(value, (list, tuple)): + return [_as_builtin(item) for item in value] + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + return value + + +def _build_dataset( + data: Sequence[Any], + spectral_axis: Sequence[float], + spectral_name: str, + dataset_name: str, + *, + spectral_quantity: Optional[str] = None, + spectral_units: Optional[str] = None, +) -> sid.Dataset: + """Create a sidpy.Dataset assuming the last axis is the fitted spectral axis.""" + array = np.asarray(data) + spectral_axis = np.asarray(spectral_axis) + + if array.ndim < 1: + raise ValueError("Input data must have at least one dimension.") + if array.shape[-1] != spectral_axis.size: + raise ValueError( + "The spectral axis length must match the size of the last data axis. " + f"Received axis length {spectral_axis.size} and last axis {array.shape[-1]}." + ) + + dataset = sid.Dataset.from_array(array, name=dataset_name) + for dim in range(array.ndim - 1): + dataset.set_dimension( + dim, + sid.Dimension(np.arange(array.shape[dim]), name=f"dim_{dim}", dimension_type="spatial"), + ) + dataset.set_dimension( + array.ndim - 1, + sid.Dimension( + spectral_axis, + name=spectral_name, + quantity=spectral_quantity or spectral_name, + units=spectral_units or "a.u.", + dimension_type="spectral", + ), + ) + return dataset + + +def _package_result(result: Any) -> Dict[str, Any]: + """Normalize fitter outputs into a JSON-friendly payload.""" + if isinstance(result, tuple): + params_dataset, cov_dataset = result + else: + params_dataset, cov_dataset = result, None + + payload = { + "parameters": np.asarray(params_dataset).tolist(), + "parameter_shape": list(params_dataset.shape), + "parameter_metadata": _as_builtin(params_dataset.metadata), + } + if cov_dataset is not None: + payload["covariance"] = np.asarray(cov_dataset).tolist() + payload["covariance_shape"] = list(cov_dataset.shape) + payload["covariance_metadata"] = _as_builtin(cov_dataset.metadata) + return payload + + +def fit_beps_loops( + data: Sequence[Any], + dc_voltage: Sequence[float], + *, + use_kmeans: bool = False, + n_clusters: int = 6, + return_cov: bool = False, + loss: str = "linear", + f_scale: float = 1.0, + lower_bounds: Optional[Sequence[float]] = None, + upper_bounds: Optional[Sequence[float]] = None, + chunks: Any = "auto", + dataset_name: str = "beps_loop_data", +) -> Dict[str, Any]: + """Fit BEPS loop data where the last axis contains the loop trace.""" + dataset = _build_dataset( + data, + dc_voltage, + "DC Offset", + dataset_name, + spectral_quantity="Voltage", + spectral_units="Volts", + ) + fitter = SidpyFitterRefactor( + dataset, + loop_fit_function, + generate_guess, + ind_dims=(dataset.ndim - 1,), + lower_bounds=lower_bounds, + upper_bounds=upper_bounds, + ) + fitter.setup_calc(chunks=chunks) + result = fitter.do_fit( + use_kmeans=use_kmeans, + n_clusters=n_clusters, + return_cov=return_cov, + loss=loss, + f_scale=f_scale, + fit_parameter_labels=LOOP_PARAMETER_LABELS, + ) + payload = _package_result(result) + payload["parameter_labels"] = LOOP_PARAMETER_LABELS + payload["fit_kind"] = "beps_loop" + return payload + + +def fit_sho_response( + real_data: Sequence[Any], + frequency: Sequence[float], + *, + imag_data: Optional[Sequence[Any]] = None, + use_kmeans: bool = False, + n_clusters: int = 10, + return_cov: bool = False, + loss: str = "linear", + f_scale: float = 1.0, + lower_bounds: Optional[Sequence[float]] = None, + upper_bounds: Optional[Sequence[float]] = None, + chunks: Any = "auto", + dataset_name: str = "sho_response_data", +) -> Dict[str, Any]: + """Fit SHO response data where the last axis contains the frequency sweep.""" + real_array = np.asarray(real_data) + if imag_data is None: + if not np.iscomplexobj(real_array): + raise ValueError("Pass complex-valued data or provide imag_data separately for SHO fitting.") + complex_array = real_array + else: + imag_array = np.asarray(imag_data) + if imag_array.shape != real_array.shape: + raise ValueError("real_data and imag_data must have the same shape.") + complex_array = real_array + 1j * imag_array + + dataset = _build_dataset( + complex_array, + frequency, + "Frequency", + dataset_name, + spectral_quantity="Frequency", + spectral_units="Hz", + ) + fitter = SidpyFitterRefactor( + dataset, + SHO_fit_flattened, + sho_guess_fn, + ind_dims=(dataset.ndim - 1,), + lower_bounds=lower_bounds, + upper_bounds=upper_bounds, + ) + fitter.setup_calc(chunks=chunks) + result = fitter.do_fit( + use_kmeans=use_kmeans, + n_clusters=n_clusters, + return_cov=return_cov, + loss=loss, + f_scale=f_scale, + fit_parameter_labels=SHO_PARAMETER_LABELS, + ) + payload = _package_result(result) + payload["parameter_labels"] = SHO_PARAMETER_LABELS + payload["fit_kind"] = "sho" + return payload + + +def create_mcp_server(server_name: str = "sidpy-beps-fitting"): + """Create an MCP server exposing the BEPS loop and SHO fitting tools.""" + if FastMCP is None: # pragma: no cover - optional runtime dependency + raise ImportError("The 'mcp' package is required to create the BEPS MCP server.") + + server = FastMCP(server_name) + + @server.tool() + def fit_beps_loops_tool( + data: Sequence[Any], + dc_voltage: Sequence[float], + use_kmeans: bool = False, + n_clusters: int = 6, + return_cov: bool = False, + loss: str = "linear", + f_scale: float = 1.0, + lower_bounds: Optional[Sequence[float]] = None, + upper_bounds: Optional[Sequence[float]] = None, + dataset_name: str = "beps_loop_data", + ) -> Dict[str, Any]: + """Fit BEPS loops from nested arrays with the spectral axis in the last dimension.""" + return fit_beps_loops( + data, + dc_voltage, + use_kmeans=use_kmeans, + n_clusters=n_clusters, + return_cov=return_cov, + loss=loss, + f_scale=f_scale, + lower_bounds=lower_bounds, + upper_bounds=upper_bounds, + dataset_name=dataset_name, + ) + + @server.tool() + def fit_sho_response_tool( + real_data: Sequence[Any], + frequency: Sequence[float], + imag_data: Optional[Sequence[Any]] = None, + use_kmeans: bool = False, + n_clusters: int = 10, + return_cov: bool = False, + loss: str = "linear", + f_scale: float = 1.0, + lower_bounds: Optional[Sequence[float]] = None, + upper_bounds: Optional[Sequence[float]] = None, + dataset_name: str = "sho_response_data", + ) -> Dict[str, Any]: + """Fit SHO data from nested real and imaginary arrays or a complex nested array.""" + return fit_sho_response( + real_data, + frequency, + imag_data=imag_data, + use_kmeans=use_kmeans, + n_clusters=n_clusters, + return_cov=return_cov, + loss=loss, + f_scale=f_scale, + lower_bounds=lower_bounds, + upper_bounds=upper_bounds, + dataset_name=dataset_name, + ) + + return server + + +if FastMCP is not None: # pragma: no cover - optional runtime dependency + mcp = create_mcp_server() +else: # pragma: no cover - optional runtime dependency + mcp = None + + +def main(): + """Run the MCP server over the default transport.""" + if mcp is None: # pragma: no cover - optional runtime dependency + raise ImportError("The 'mcp' package is required to run the BEPS MCP server.") + mcp.run() + + +__all__ = [ + "LOOP_PARAMETER_LABELS", + "SHO_PARAMETER_LABELS", + "SHO_fit_flattened", + "calculate_loop_centroid", + "create_mcp_server", + "fit_beps_loops", + "fit_sho_response", + "generate_guess", + "loop_fit_function", + "main", + "sho_guess_fn", +] From 7d4e4dd3fccfc89dc071c3e949ac771e991ac7d6 Mon Sep 17 00:00:00 2001 From: ramav87 Date: Mon, 16 Mar 2026 17:10:04 -0400 Subject: [PATCH 3/3] new tests --- tests/proc/test_fitter_refactor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/proc/test_fitter_refactor.py b/tests/proc/test_fitter_refactor.py index e91c3c09..2e95ad67 100644 --- a/tests/proc/test_fitter_refactor.py +++ b/tests/proc/test_fitter_refactor.py @@ -279,13 +279,13 @@ def _make_fitter(self, lower_bounds=None, upper_bounds=None): def test_unbounded_unchanged(self): """Unbounded fit must return a valid sidpy.Dataset with finite params.""" - result, _ = self._make_fitter().do_fit() + result = self._make_fitter().do_fit() self.assertIsInstance(result, sid.Dataset) self.assertTrue(np.all(np.isfinite(np.array(result)))) def test_scalar_lower_bound(self): """Scalar lower_bounds=0 — all returned params must be >= 0.""" - result, _ = self._make_fitter(lower_bounds=0.0).do_fit() + result = self._make_fitter(lower_bounds=0.0).do_fit() params = np.array(result) self.assertTrue(np.all(params >= -1e-6), msg=f"Some params violated lower_bound=0: min={params.min()}") @@ -293,7 +293,7 @@ def test_scalar_lower_bound(self): def test_scalar_upper_bound(self): """Scalar upper_bounds=1e6 — all returned params must be <= 1e6.""" upper = 1e6 - result, _ = self._make_fitter(upper_bounds=upper).do_fit() + result = self._make_fitter(upper_bounds=upper).do_fit() params = np.array(result) self.assertTrue(np.all(params <= upper + 1e-6), msg=f"Some params violated upper_bound={upper}: max={params.max()}") @@ -303,7 +303,7 @@ def test_per_param_bounds_respected(self): n = self._make_fitter().num_params lb = np.zeros(n) ub = np.full(n, 1e6) - result, _ = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() + result = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() params = np.array(result) for i in range(n): p = params[..., i] @@ -342,7 +342,7 @@ def test_bounds_stored_in_metadata(self): n = self._make_fitter().num_params lb = list(np.zeros(n)) ub = list(np.ones(n) * 1e6) - result, _ = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() + result = self._make_fitter(lower_bounds=lb, upper_bounds=ub).do_fit() meta = result.metadata["fit_parameters"] self.assertIn("lower_bounds", meta) self.assertIn("upper_bounds", meta) @@ -351,7 +351,7 @@ def test_bounds_stored_in_metadata(self): def test_none_bounds_metadata_is_none(self): """When no bounds are passed, metadata entries must be None.""" - result, _ = self._make_fitter().do_fit() + result = self._make_fitter().do_fit() meta = result.metadata["fit_parameters"] self.assertIsNone(meta.get("lower_bounds")) self.assertIsNone(meta.get("upper_bounds")) @@ -360,7 +360,7 @@ def test_bounds_with_nonlinear_loss(self): """Bounds + non-linear loss must not raise (both require method='trf').""" fitter = self._make_fitter(lower_bounds=0.0) try: - result, _ = fitter.do_fit(loss='soft_l1') + result = fitter.do_fit(loss='soft_l1') except Exception as e: self.fail(f"do_fit raised with bounds + non-linear loss: {e}") self.assertIsInstance(result, sid.Dataset) @@ -370,4 +370,4 @@ def test_bounds_with_return_cov(self): n = self._make_fitter().num_params params, cov = self._make_fitter(lower_bounds=0.0).do_fit(return_cov=True) self.assertEqual(cov.shape[-2:], (n, n), - msg=f"Covariance shape {cov.shape} does not end in ({n},{n})") \ No newline at end of file + msg=f"Covariance shape {cov.shape} does not end in ({n},{n})")