Repository: EPFL-Center-for-Imaging/splinebox

Issue:
Difference between `spline.fit(x)` and `spline.knots = x`
If you have the same number of data points as knots, you get different results when
using `spline.fit(x)` and `spline.knots = x`. This is because setting the knots will impose boundary conditions where as fitting chooses random boundary values. Presumably the splines are only different at the ends.

Possible improvements:
- raise a warning when `fit` is used with too few points
- automatically use `spline.knots=x` inside `fit` if the number of points is equal to the number of knots.

Hints from issue comments:

Solution patch:
diff --git a/src/splinebox/spline_curves.py b/src/splinebox/spline_curves.py
index 415e045..225121e 100644
--- a/src/splinebox/spline_curves.py
+++ b/src/splinebox/spline_curves.py
@@ -370,10 +370,11 @@ def fit(self, points, arc_length_parameterization=False):
             between the provided points. This is usefull when the
             points are not equally spaced. Default is `False`.
         """
-        if len(points) < self.M:
+        if len(points) < self.M + 2 * self.pad:
             raise RuntimeError(
-                "You provided fewer data points than you spline has knots. For the fit to have a unique solution you need to provide at least as many data points as your spline has knots. Consider adding more data or reducing the number of knots M."
+                f"You provided too few points. For the fit to have a unique solution you need to provide at least as many points as your spline has control points (including padding). This spline has {self.M}+{2 * self.pad}(padding) control points but you provided {len(points)} points. Consider providing more points or reducing the number of knots M. If the number of points is equal to M, consider using `spline.knots = points` instead of fitting."
             )
+
         if arc_length_parameterization:
             raise NotImplementedError
         else:
@@ -1252,6 +1253,10 @@ def basis_function(self, value):
         self._basis_function = value
 
     def fit(self, points, arc_length_parameterization=False):
+        if len(points) < 2 * (self.M + 2 * self.pad):
+            raise RuntimeError(
+                f"You provided too few points. For a unique solution you need to provide at least 2*({self.M}+{2 * self.pad}) points to match the number of control points and tangents (including padding). You provided {len(points)} points. Consider providing more points or reducing the number of knots M."
+            )
         if len(points) < self.M:
             raise RuntimeError(
                 "You provided fewer data points than you spline has knots. For the fit to have a unique solution you need to provide at least as many data points as your spline has knots. Consider adding more data or reducing the number of knots M."
@@ -1263,8 +1268,8 @@ def fit(self, points, arc_length_parameterization=False):
         tval = self._get_tval(t)
         basis_function_values = self.basis_function.eval(tval, derivative=0)
         basis_function_values = np.concatenate([basis_function_values[0], basis_function_values[1]], axis=1)
-        half = self.M if self.closed else self.M + 2 * self.pad
         solution = np.linalg.lstsq(basis_function_values, points, rcond=None)[0]
+        half = self.M if self.closed else self.M + 2 * self.pad
         self.control_points = solution[:half]
         self.tangents = solution[half:]
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 3682f7a..d699409 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -276,7 +276,7 @@ def arc_length_parametrization(request):
     return request.param
 
 
-@pytest.fixture(params=[2, 20, 50])
+@pytest.fixture(params=[2, 5, 20, 50])
 def n_points(request):
     return request.param
 
diff --git a/tests/test_spline_curves.py b/tests/test_spline_curves.py
index 4c1dcff..599c716 100644
--- a/tests/test_spline_curves.py
+++ b/tests/test_spline_curves.py
@@ -326,7 +326,8 @@ def test_fit(spline_curve, arc_length_parametrization, points, is_hermite_spline
     # Bool indicating whether spline_curve is a hermite spline or not
     hermite = is_hermite_spline(spline_curve)
 
-    if len(points) < spline_curve.M:
+    n_control_points = spline_curve.M + 2 * spline_curve.pad
+    if hermite and len(points) < 2 * n_control_points or len(points) < n_control_points:
         # The problem is underdetermined
         with pytest.raises(RuntimeError):
             spline_curve.fit(points)