Skip to content

Commit 757c6fa

Browse files
committed
Add support for locked parameters in curve fitting: implement locking mechanism in FitParam and FitParamDataSet, update autofit methods, and introduce tests for locked parameter functionality
1 parent 4510f67 commit 757c6fa

File tree

4 files changed

+125
-15
lines changed

4 files changed

+125
-15
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
💥 New features / Enhancements:
66

7+
* Curve fitting: added support for locked parameters:
8+
* New `locked` parameter in `FitParam` class to lock parameter values during automatic optimization
9+
* New `locked` field in `FitParamDataSet` to configure parameter locking via the settings dialog
10+
* When locked, parameters retain their manually-adjusted values during auto-fit
11+
* Visual indicators: locked parameters show a 🔒 emoji and are grayed out with disabled controls
12+
* All optimization algorithms (simplex, Powell, BFGS, L-BFGS-B, conjugate gradient, least squares) fully support locked parameters
13+
* Enables partial optimization workflows: fix well-determined parameters, optimize uncertain ones
14+
* Improves fit convergence by reducing problem dimensionality
715
* Configurable autoscale margin:
816
* Added `autoscale_margin_percent` parameter to `BasePlotOptions` for intuitive percentage-based margin control
917
* Users can now specify autoscale margins as percentages (e.g., `0.2` for 0.2%, `5.0` for 5%)

plotpy/locale/fr/LC_MESSAGES/plotpy.po

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ msgid ""
66
msgstr ""
77
"Project-Id-Version: plotpy 2.7.4\n"
88
"Report-Msgid-Bugs-To: p.raybaut@codra.fr\n"
9-
"POT-Creation-Date: 2025-10-08 11:19+0200\n"
9+
"POT-Creation-Date: 2025-10-18 16:38+0200\n"
1010
"PO-Revision-Date: 2025-06-02 11:14+0200\n"
1111
"Last-Translator: Christophe Debonnel <c.debonnel@codra.fr>\n"
1212
"Language: fr\n"
@@ -1604,6 +1604,9 @@ msgstr "Format"
16041604
msgid "Logarithmic"
16051605
msgstr "Logarithmique"
16061606

1607+
msgid "Fixed value during optimization"
1608+
msgstr "Valeur fixe pendant l'optimisation"
1609+
16071610
msgid "Curve fitting parameter"
16081611
msgstr "Paramètre d'ajustement"
16091612

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
"""Test script for locked fit parameters feature"""
4+
5+
import numpy as np
6+
7+
from plotpy.widgets.fit import FitParam, guifit
8+
9+
10+
def test_locked_fit():
11+
"""Test the curve fitting tool with locked parameters"""
12+
# Generate test data: y = cos(1.5*x) + 0.2
13+
x = np.linspace(-10, 10, 1000)
14+
true_offset = 0.2
15+
true_freq = 1.5
16+
y = np.cos(true_freq * x) + true_offset + np.random.rand(x.shape[0]) * 0.1
17+
18+
def fit(x, params):
19+
"""Fit function: a + cos(b*x)"""
20+
a, b = params
21+
return np.cos(b * x) + a
22+
23+
# Create fit parameters
24+
# Lock the frequency parameter at the true value
25+
a = FitParam("Offset", 0.0, -1.0, 1.0)
26+
b = FitParam("Frequency", true_freq, 0.3, 3.0, logscale=True, locked=True)
27+
28+
params = [a, b]
29+
30+
print("Initial values:")
31+
print(f" Offset (unlocked): {a.value}")
32+
print(f" Frequency (locked): {b.value}")
33+
34+
values = guifit(
35+
x,
36+
y,
37+
fit,
38+
params,
39+
xlabel="Time (s)",
40+
ylabel="Amplitude (a.u.)",
41+
wintitle="Locked Parameter Test",
42+
auto_fit=True,
43+
)
44+
45+
if values:
46+
print("\nFinal values after fit:")
47+
print(f" Offset: {values[0]:.4f} (should be close to {true_offset})")
48+
print(f" Frequency: {values[1]:.4f} (should remain {true_freq})")
49+
print("\nNote: The frequency parameter was locked and should not have changed.")
50+
print("Only the offset parameter should have been optimized.")
51+
52+
53+
if __name__ == "__main__":
54+
test_locked_fit()

plotpy/widgets/fit.py

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class FitParamDataSet(DataSet):
125125
format = StringItem(_("Format"), default="%.3f").set_pos(col=1)
126126
logscale = BoolItem(_("Logarithmic"), _("Scale"))
127127
unit = StringItem(_("Unit"), default="").set_pos(col=1)
128+
locked = BoolItem(_("Fixed value during optimization"), _("Lock"))
128129

129130

130131
class FitParam:
@@ -140,6 +141,8 @@ class FitParam:
140141
format: format of the parameter. Default is "%.3f".
141142
size_offset: size offset of the parameter. Default is 0.
142143
unit: unit of the parameter. Default is "".
144+
locked: if True, the parameter value is locked and will not be modified
145+
by the automatic fit. Default is False.
143146
"""
144147

145148
def __init__(
@@ -153,6 +156,7 @@ def __init__(
153156
format: str = "%.3f",
154157
size_offset: int = 0,
155158
unit: str = "",
159+
locked: bool = False,
156160
):
157161
self.name = name
158162
self.value = value
@@ -162,6 +166,7 @@ def __init__(
162166
self.steps = steps
163167
self.format = format
164168
self.unit = unit
169+
self.locked = locked
165170
self.prefix_label = None
166171
self.lineedit = None
167172
self.unit_label = None
@@ -188,6 +193,7 @@ def copy(self) -> FitParam:
188193
self.format,
189194
self._size_offset,
190195
self.unit,
196+
self.locked,
191197
)
192198

193199
def create_widgets(self, parent: QWidget, refresh_callback: Callable) -> None:
@@ -258,6 +264,8 @@ def set_text(self, fmt: str = None) -> None:
258264
fmt: format (default: None)
259265
"""
260266
style = "<span style='color: #444444'><b>{}</b></span>"
267+
if self.locked:
268+
style = "<span style='color: #888888'><b>{} 🔒</b></span>"
261269
self.prefix_label.setText(style.format(self.name))
262270
if self.value is None:
263271
value_str = ""
@@ -266,7 +274,10 @@ def set_text(self, fmt: str = None) -> None:
266274
fmt = self.format
267275
value_str = fmt % self.value
268276
self.lineedit.setText(value_str)
269-
self.lineedit.setDisabled(bool(self.value == self.min and self.max == self.min))
277+
is_disabled = bool(
278+
self.locked or (self.value == self.min and self.max == self.min)
279+
)
280+
self.lineedit.setDisabled(is_disabled)
270281

271282
def line_editing_finished(self):
272283
"""Line editing finished"""
@@ -303,7 +314,7 @@ def update_slider_value(self):
303314
elif self.value == self.min and self.max == self.min:
304315
self.slider.hide()
305316
else:
306-
self.slider.setEnabled(True)
317+
self.slider.setEnabled(not self.locked)
307318
if self.slider.parentWidget() and self.slider.parentWidget().isVisible():
308319
self.slider.show()
309320
if self.logscale:
@@ -666,40 +677,73 @@ def compute_imin_imax(self) -> None:
666677
self.i_min = self.x.searchsorted(self.autofit_prm.xmin)
667678
self.i_max = self.x.searchsorted(self.autofit_prm.xmax, side="right")
668679

669-
def errorfunc(self, params: list[float]) -> np.ndarray:
680+
def get_full_params(self, free_params: np.ndarray) -> list[float]:
681+
"""Build full parameter list from free parameters
682+
683+
Args:
684+
free_params: values of unlocked parameters only
685+
686+
Returns:
687+
Full parameter list with locked parameters at their fixed values
688+
"""
689+
full_params = []
690+
free_idx = 0
691+
for p in self.fitparams:
692+
if p.locked:
693+
full_params.append(p.value)
694+
else:
695+
full_params.append(free_params[free_idx])
696+
free_idx += 1
697+
return full_params
698+
699+
def errorfunc(self, free_params: np.ndarray) -> np.ndarray:
670700
"""Get error function
671701
672702
Args:
673-
params: fit parameter values
703+
free_params: values of unlocked fit parameters
674704
675705
Returns:
676706
Error function
677707
"""
678708
x = self.x[self.i_min : self.i_max]
679709
y = self.y[self.i_min : self.i_max]
680710
fitargs, fitkwargs = self.get_fitfunc_arguments()
711+
params = self.get_full_params(free_params)
681712
return y - self.fitfunc(x, params, *fitargs, **fitkwargs)
682713

683714
def autofit(self) -> None:
684715
"""Autofit"""
685716
meth = self.autofit_prm.method
686-
x0 = np.array([p.value for p in self.fitparams])
717+
718+
# Extract only unlocked parameters for optimization
719+
free_params = np.array([p.value for p in self.fitparams if not p.locked])
720+
721+
# If all parameters are locked, nothing to optimize
722+
if len(free_params) == 0:
723+
return
724+
687725
if meth == "lq":
688-
x = self.autofit_lq(x0)
726+
x = self.autofit_lq(free_params)
689727
elif meth == "simplex":
690-
x = self.autofit_simplex(x0)
728+
x = self.autofit_simplex(free_params)
691729
elif meth == "powel":
692-
x = self.autofit_powel(x0)
730+
x = self.autofit_powel(free_params)
693731
elif meth == "bfgs":
694-
x = self.autofit_bfgs(x0)
732+
x = self.autofit_bfgs(free_params)
695733
elif meth == "l_bfgs_b":
696-
x = self.autofit_l_bfgs(x0)
734+
x = self.autofit_l_bfgs(free_params)
697735
elif meth == "cg":
698-
x = self.autofit_cg(x0)
736+
x = self.autofit_cg(free_params)
699737
else:
700738
return
701-
for v, p in zip(x, self.fitparams):
702-
p.value = v
739+
740+
# Restore optimized values only to unlocked parameters
741+
free_idx = 0
742+
for p in self.fitparams:
743+
if not p.locked:
744+
p.value = x[free_idx]
745+
free_idx += 1
746+
703747
self.refresh()
704748
for prm in self.fitparams:
705749
prm.update()
@@ -776,7 +820,8 @@ def autofit_l_bfgs(self, x0: np.ndarray) -> np.ndarray:
776820
Fitted values
777821
"""
778822
prm = self.autofit_prm
779-
bounds = [(p.min, p.max) for p in self.fitparams]
823+
# Build bounds only for unlocked parameters
824+
bounds = [(p.min, p.max) for p in self.fitparams if not p.locked]
780825

781826
x, _f, _d = fmin_l_bfgs_b(
782827
self.get_norm_func(), x0, pgtol=prm.gtol, approx_grad=1, bounds=bounds

0 commit comments

Comments
 (0)