Skip to content

Fitting Curves from Partial Data: Historical Morphing & Boundary Smoothing

TL;DR: Estimating full-season trajectory profiles (such as crop growth or energy demand) when only the initial 25% of data is observed causes runaway polynomial extrapolation or discontinuous historical splicing. In this post, we develop a 3-tier mathematical framework: 4-parameter affine profile morphing to adapt historical archetypes, a \(C^1\)-continuous Hermite blending bridge to eliminate boundary jump discontinuities, and a constrained quadratic program (QP) to strictly enforce cumulative macro targets with minimal curvature roughness. Try the interactive visual simulator and collapsed production SciPy code below.

"When predicting the future from the past, the true art lies not in following the historical average blindly, but in stretching, shifting, and smoothly welding the template to match today's unfolding reality."


1. The Core Problem: The 25% Incomplete Curve Dilemma

In agricultural analytics, remote sensing, and industrial forecasting, practitioners frequently encounter the partial curve completion problem:

  • Known Reality (\(0 \le t \le t_{\text{cut}}\)): We possess high-precision measurements for only the first quarter of the season (e.g., Day of Year \(1\) to \(35\) for a \(140\)-day corn growing cycle).
  • Historical Prior (\(0 \le t \le T\)): We have a multi-year historical average growth curve \(f_{\text{hist}}(t)\) describing standard sigmoidal biomass accumulation.
  • External Constraint (Optional): We may have an independent macro-level prediction (e.g., satellite yield forecast or seasonal nitrogen budget) dictating the target total biomass \(Y_{\text{total}} = \int_0^T y(t) \, dt\) or final maturity value \(y(T)\).
flowchart LR
    subgraph Known["Known Region (0 to 25%)"]
        Obs["Noisy Observations y_obs(t)<br/>Real-time In-Season Data"]
    end

    subgraph Interface["The Boundary (t = 25%)"]
        Jump["Discontinuity Hazard!<br/>Step-jump & Derivative Mismatch"]
    end

    subgraph Unknown["Forecast Region (25% to 100%)"]
        Hist["Historical Baseline f_hist(t)<br/>Provides Phenological Shape"]
        Target["Macro Target Constraint Y_total<br/>Enforces Physical Mass Balance"]
    end

    Obs --> Interface
    Hist --> Interface
    Target --> Interface
    Interface --> SmoothCurve["Seamless C^1 Continuous Full-Season Trajectory"]

    style Known fill:#065f46,stroke:#047857,stroke-width:2px,color:#fff
    style Interface fill:#b91c1c,stroke:#ef4444,stroke-width:2px,color:#fff
    style Unknown fill:#1e3a8a,stroke:#1e40af,stroke-width:2px,color:#fff
    style SmoothCurve fill:#0284c7,stroke:#0369a1,stroke-width:2px,color:#fff

Why Standard Methods Fail

  1. Direct Polynomial / Spline Extrapolation: Fitting a high-degree polynomial or unconstrained spline to the first \(25\%\) of points causes explosive Runge oscillations as \(t \to T\).
  2. Raw Historical Splicing: Sticking the unadjusted historical curve onto the last observed point creates an abrupt angle or vertical cliff (discontinuity in \(y(t)\) or \(y'(t)\)).
  3. Pure Proportional Rescaling: Multiplying the entire historical curve by \(\alpha = y_{\text{obs}}(t_{\text{cut}}) / f_{\text{hist}}(t_{\text{cut}})\) ignores timing delays (phenological shifts) and cannot satisfy cumulative target constraints.

2. Approach 1: 4-Parameter Affine Profile Morphing

The simplest, most robust baseline is to assume that current season dynamics represent an affine transformation (translation and linear stretching in time and amplitude) of the historical archetype \(f_{\text{hist}}(t)\)[^1]:

\[y(t; \mathbf{\theta}) = s_y \cdot f_{\text{hist}}\left( s_x \cdot (t - \Delta t) \right) + \Delta y\]

where the parameter vector \(\mathbf{\theta} = [s_x, s_y, \Delta t, \Delta y]^T\) represents: - \(s_x\) (Time Dilation / Phenology Speed): \(s_x > 1\) represents accelerated growing degree days (GDD); \(s_x < 1\) represents delayed crop development. - \(\Delta t\) (Time Shift): Accounts for late or early planting dates. - \(s_y\) (Amplitude Scaling): Reflects higher or lower overall biomass vigor. - \(\Delta y\) (Baseline Offset): Initial background offset (usually bounded near \(0\)).

                         Affine Parameter Morphing
           y ↑                                        Historical f_hist(t)
             │                                   ╭───────────
             │                             ╭─────╯
             │                       ╭─────╯ (Scaled by s_y, Shifted by Δt)
             │                 ╭─────╯   • • • • Fitted Forecast y(t)
             │        ╭────────╯       •
             │     ╭──╯  ▲ Known 25% •
             │  ╭──╯     │ Obs Points
             └──────────────────────────────────────────────────► t

Optimization Formulation

We estimate \(\mathbf{\theta}^*\) by solving a bounded nonlinear least-squares problem over the observed window \(t \in [0, t_{\text{cut}}]\):

\[\mathbf{\theta}^* = \arg\min_{\mathbf{\theta}} \sum_{i=1}^{N_{\text{obs}}} w_i \left( y_{\text{obs}}(t_i) - y(t_i; \mathbf{\theta}) \right)^2 + \lambda_{\text{reg}} \|\mathbf{\theta} - \mathbf{\theta}_{\text{prior}}\|^2\]

where \(w_i = \exp\left( \frac{t_i - t_{\text{cut}}}{\sigma} \right)\) optionally gives greater weight to recent points, and \(\mathbf{\theta}_{\text{prior}} = [1, 1, 0, 0]^T\) prevents unphysical distortions.

View Python Implementation: Affine Profile Morphing
import numpy as np
from scipy.interpolate import CubicSpline
from scipy.optimize import minimize

def fit_affine_curve(t_obs, y_obs, t_hist, y_hist, t_full):
    """
    Fits 4-parameter affine transformation: y(t) = s_y * f_hist(s_x * (t - dt)) + dy
    """
    # Create continuous historical template function
    f_hist = CubicSpline(t_hist, y_hist, extrapolate=True)

    def objective(params):
        sx, sy, dt, dy = params
        # Transformed time coordinates
        t_warped = sx * (t_obs - dt)
        y_pred = sy * f_hist(t_warped) + dy
        # Weighted least squares with prior regularization
        residuals = y_obs - y_pred
        prior_penalty = 10.0 * ((sx - 1.0)**2 + (dt / 10.0)**2 + (dy)**2)
        return np.sum(residuals**2) + prior_penalty

    # Initial guess [sx, sy, dt, dy]
    p0 = [1.0, np.mean(y_obs[-3:]) / (f_hist(t_obs[-1]) + 1e-6), 0.0, 0.0]
    bounds = [(0.7, 1.4), (0.2, 3.0), (-20.0, 20.0), (-5.0, 5.0)]

    res = minimize(objective, p0, bounds=bounds, method='L-BFGS-B')
    sx_opt, sy_opt, dt_opt, dy_opt = res.x

    # Project across full season
    y_forecast = sy_opt * f_hist(sx_opt * (t_full - dt_opt)) + dy_opt
    return y_forecast, res.x

3. Approach 2: Boundary-Continuous Hermite Blending Bridge

Even with an optimal affine fit, the fitted curve \(\hat{y}(t)\) evaluated at \(t_{\text{cut}}\) rarely matches the exact final observed point \(y_{\text{obs}}(t_{\text{cut}})\) and its instantaneous slope \(y'_{\text{obs}}(t_{\text{cut}})\).

To achieve \(C^1\) continuity (smooth value and slope transition with zero jump), we introduce a Hermite Blending Bridge[^2]:

\[y_{\text{final}}(t) = \begin{cases} y_{\text{obs}}(t), & t \le t_{\text{cut}} \\ \hat{y}(t) + \delta(t), & t > t_{\text{cut}} \end{cases}\]

where the residual correction \(\delta(t)\) decays smoothly from the boundary discrepancy \(\delta_0 = y_{\text{obs}}(t_{\text{cut}}) - \hat{y}(t_{\text{cut}})\) to zero over a relaxation timescale \(\tau\):

\[\delta(t) = \left( \delta_0 + \delta'_0 (t - t_{\text{cut}}) \right) \cdot \exp\left( -\frac{(t - t_{\text{cut}})^2}{2\tau^2} \right)\]

where \(\delta'_0 = y'_{\text{obs}}(t_{\text{cut}}) - \hat{y}'(t_{\text{cut}})\) matches the instantaneous first derivative.

       Discontinuity Without Bridge                Smooth Hermite Blending Bridge
    y ↑                                         y ↑
      │       / (Fitted Forecast)                 │        / (Smooth Transition)
      │      /                                    │      ╭╯
      │     /                                     │     ╭╯
      │    ●  ◄── Discontinuous Jump Δy           │    ●  ◄── Zero Jump (C¹ Continuous)
      │   /                                       │   /
      │  / (Known 25%)                            │  / (Known 25%)
      └──┴───────────────────────► t              └──┴───────────────────────► t
        t_cut                                       t_cut
View Python Implementation: Hermite Blending Bridge
def apply_hermite_bridge(t_full, y_forecast, t_cut, y_cut, dy_cut, tau=15.0):
    """
    Applies C1-continuous residual decay to eliminate jump at t_cut.
    """
    idx_cut = np.searchsorted(t_full, t_cut)
    y_model_cut = y_forecast[idx_cut]

    # Numerical derivative of forecast at cut
    dy_model_cut = (y_forecast[idx_cut + 1] - y_forecast[idx_cut - 1]) / (t_full[idx_cut + 1] - t_full[idx_cut - 1])

    delta_0 = y_cut - y_model_cut
    delta_prime_0 = dy_cut - dy_model_cut

    y_smooth = np.copy(y_forecast)
    t_after = t_full[idx_cut:] - t_cut

    # Hermite gaussian-damped bridge kernel
    bridge = (delta_0 + delta_prime_0 * t_after) * np.exp(-(t_after**2) / (2 * tau**2))
    y_smooth[idx_cut:] += bridge
    return y_smooth

4. Approach 3: Constrained Target Updating (Quadratic Programming)

In advanced applications, we are given an external cumulative total target \(Y_{\text{total}}\) (e.g., predicted end-of-season total dry biomass of \(18.5\text{ t ha}^{-1}\) or total cumulative water volume):

\[Y_{\text{total}} = \int_0^T y(t) \, dt = \sum_{t \le t_{\text{cut}}} y_{\text{obs}}(t) \Delta t + \sum_{t > t_{\text{cut}}} y(t) \Delta t\]

The remaining integral to be allocated across the unknown future \(t > t_{\text{cut}}\) is:

\[Y_{\text{remaining}} = Y_{\text{total}} - \sum_{i \in \text{known}} y_{\text{obs}}(t_i) \Delta t\]

The Optimal Distribution Problem

How do we distribute \(Y_{\text{remaining}}\) across \(t \in (t_{\text{cut}}, T]\) such that: 1. The curve mirrors the historical distribution shape \(p(t) \propto f_{\text{hist}}(t)\). 2. The total sum strictly equals \(Y_{\text{remaining}}\). 3. The curve joins seamlessly at \(t_{\text{cut}}\) with minimum curvature energy (\(\int [y''(t)]^2 dt\))[^3].

We formulate this as a Constrained Quadratic Program (QP):

\[\min_{\mathbf{y}_{\text{future}}} \underbrace{\sum_{k} \left( y_{k+1} - 2y_k + y_{k-1} \right)^2}_{\text{Curvature / Roughness Penalty}} + \alpha \underbrace{\sum_{k} \left( y_k - \gamma f_{\text{hist}}(t_k) \right)^2}_{\text{Fidelity to Historical Profile}}\]
\[\text{Subject to:} \quad \sum_{k=1}^{M} y_k \Delta t = Y_{\text{remaining}}, \quad y_1 = y_{\text{obs}}(t_{\text{cut}}), \quad y_2 - y_1 = \Delta t \cdot y'_{\text{obs}}(t_{\text{cut}})\]
flowchart TD
    TotalTarget["External Macro Target: Y_total"] --> RemCalc["Calculate Remaining Mass: Y_rem = Y_total - ∑y_known"]
    HistShape["Historical Template Shape: f_hist(t)"] --> QP["Quadratic Program Formulation"]
    BoundaryC1["Boundary C^1 Conditions: y(t_cut), y'(t_cut)"] --> QP

    QP --> MatrixSolve["Solve KKT Linear System: [H A^T; A 0] [y; λ] = [d; b]"]
    MatrixSolve --> OptimalCurve["Globally Optimal Future Trajectory:<br/>- Perfect Total Target Match<br/>- Zero Boundary Discontinuity<br/>- Maximum Geometric Smoothness"]

    style TotalTarget fill:#f59e0b,stroke:#d97706,stroke-width:2px,color:#fff
    style QP fill:#1e293b,stroke:#3b82f6,stroke-width:2px,color:#fff
    style MatrixSolve fill:#0284c7,stroke:#0369a1,stroke-width:2px,color:#fff
    style OptimalCurve fill:#059669,stroke:#10b981,stroke-width:2px,color:#fff
View Python Implementation: Target-Constrained QP Solver
import scipy.sparse as sp
from scipy.sparse.linalg import spsolve

def fit_target_constrained_curve(t_future, f_hist_future, y_cut, dy_cut, target_remaining, dt, alpha=0.1):
    """
    Solves QP for smooth trajectory matching historical shape and exact target integral.
    """
    M = len(t_future)
    # Second-order difference matrix (curvature)
    D2 = sp.diags([1, -2, 1], [0, 1, 2], shape=(M - 2, M)).tocsc()
    H = 2 * (D2.T @ D2) + 2 * alpha * sp.eye(M).tocsc()

    # Scale historical template to approximate remaining target
    gamma = target_remaining / (np.sum(f_hist_future) * dt)
    d = 2 * alpha * gamma * f_hist_future

    # Equality constraints:
    # 1. Sum constraint: dt * sum(y) = target_remaining
    # 2. Boundary value: y[0] = y_cut
    # 3. Boundary slope: y[1] - y[0] = dy_cut * dt
    A_sum = np.ones((1, M)) * dt
    A_b0 = np.zeros((1, M)); A_b0[0, 0] = 1.0
    A_b1 = np.zeros((1, M)); A_b1[0, 0] = -1.0; A_b1[0, 1] = 1.0

    A = np.vstack([A_sum, A_b0, A_b1])
    b = np.array([target_remaining, y_cut, dy_cut * dt])

    # Build KKT saddle-point system
    KKT_top = sp.hstack([H, A.T])
    KKT_bot = sp.hstack([A, sp.csc_matrix((3, 3))])
    KKT = sp.vstack([KKT_top, KKT_bot]).tocsc()
    rhs = np.concatenate([d, b])

    sol = spsolve(KKT, rhs)
    y_optimal = sol[:M]
    return y_optimal

6. Production-Ready Python Pipeline: PartialCurveFitter

Here is a unified, object-oriented Python implementation using numpy and scipy that encapsulates all three methods into a single class:

View Production Python Module: PartialGrowthCurveFitter
import numpy as np
import scipy.sparse as sp
from scipy.interpolate import CubicSpline
from scipy.optimize import minimize
from scipy.sparse.linalg import spsolve

class PartialGrowthCurveFitter:
    """
    Hierarchical framework for completing partial growth time series.
    Methods:
      1. 'affine': 4-parameter least-squares shape morphing.
      2. 'bridge': Affine morphing + C1 Hermite boundary smoothing.
      3. 'constrained_target': Quadratic program for exact target total allocation.
    """
    def __init__(self, t_hist: np.ndarray, y_hist: np.ndarray):
        self.t_hist = np.asarray(t_hist, dtype=float)
        self.y_hist = np.asarray(y_hist, dtype=float)
        self.spline_hist = CubicSpline(self.t_hist, self.y_hist, extrapolate=True)

    def fit_affine(self, t_obs: np.ndarray, y_obs: np.ndarray, t_full: np.ndarray):
        """Tier 1: Affine Least-Squares Morphing"""
        t_cut = t_obs[-1]

        def loss(params):
            sx, sy, dt, dy = params
            pred = sy * self.spline_hist(sx * (t_obs - dt)) + dy
            # Recent points receive exponential weighting
            weights = np.exp((t_obs - t_cut) / 10.0)
            res = weights * (y_obs - pred)
            reg = 5.0 * ((sx - 1.0)**2 + (dt / 10.0)**2 + dy**2)
            return np.sum(res**2) + reg

        p0 = [1.0, np.mean(y_obs[-3:]) / (self.spline_hist(t_cut) + 1e-5), 0.0, 0.0]
        bounds = [(0.6, 1.6), (0.2, 3.0), (-25.0, 25.0), (-5.0, 5.0)]
        res = minimize(loss, p0, bounds=bounds, method='L-BFGS-B')
        sx, sy, dt, dy = res.x

        y_full = sy * self.spline_hist(sx * (t_full - dt)) + dy
        return y_full, res.x

    def fit_with_bridge(self, t_obs: np.ndarray, y_obs: np.ndarray, t_full: np.ndarray, tau: float = 15.0):
        """Tier 2: Affine Morphing + C1 Hermite Boundary Bridge"""
        y_affine, params = self.fit_affine(t_obs, y_obs, t_full)
        t_cut = t_obs[-1]
        y_cut = y_obs[-1]

        # Estimate observed boundary slope using last 3 points
        dy_obs_cut = np.polyfit(t_obs[-3:], y_obs[-3:], deg=1)[0]

        idx_cut = np.searchsorted(t_full, t_cut)
        y_model_cut = y_affine[idx_cut]
        dy_model_cut = (y_affine[idx_cut + 1] - y_affine[idx_cut - 1]) / (t_full[idx_cut + 1] - t_full[idx_cut - 1])

        delta_0 = y_cut - y_model_cut
        delta_p0 = dy_obs_cut - dy_model_cut

        y_bridged = np.copy(y_affine)
        dt = t_full[idx_cut:] - t_cut
        bridge = (delta_0 + delta_p0 * dt) * np.exp(-(dt**2) / (2.0 * tau**2))
        y_bridged[idx_cut:] += bridge
        return y_bridged, params

    def fit_constrained_target(self, t_obs: np.ndarray, y_obs: np.ndarray, t_full: np.ndarray, 
                               target_total: float, alpha: float = 0.05):
        """Tier 3: Constrained Quadratic Program for Exact Target Total Integral"""
        t_cut = t_obs[-1]
        y_cut = y_obs[-1]
        dy_obs_cut = np.polyfit(t_obs[-3:], y_obs[-3:], deg=1)[0]

        idx_cut = np.searchsorted(t_full, t_cut)
        dt_step = t_full[1] - t_full[0]

        # Known integral up to t_cut
        known_integral = np.trapz(y_obs, t_obs)
        target_remaining = max(1e-3, target_total - known_integral)

        t_future = t_full[idx_cut:]
        M = len(t_future)
        f_future = self.spline_hist(t_future)

        # 1. Curvature penalty matrix D2
        D2 = sp.diags([1.0, -2.0, 1.0], [0, 1, 2], shape=(M - 2, M)).tocsc()
        H = 2.0 * (D2.T @ D2) + 2.0 * alpha * sp.eye(M).tocsc()

        # 2. Linear gradient term
        gamma = target_remaining / (np.sum(f_future) * dt_step)
        d = 2.0 * alpha * gamma * f_future

        # 3. Constraints: [Sum, Value, Slope]
        A_sum = np.ones((1, M)) * dt_step
        A_b0 = np.zeros((1, M)); A_b0[0, 0] = 1.0
        A_b1 = np.zeros((1, M)); A_b1[0, 0] = -1.0; A_b1[0, 1] = 1.0

        A = np.vstack([A_sum, A_b0, A_b1])
        b = np.array([target_remaining, y_cut, dy_obs_cut * dt_step])

        # 4. KKT Linear System Solve
        KKT = sp.vstack([
            sp.hstack([H, A.T]),
            sp.hstack([A, sp.csc_matrix((3, 3))])
        ]).tocsc()
        rhs = np.concatenate([d, b])

        sol = spsolve(KKT, rhs)
        y_future = sol[:M]

        y_full = np.zeros_like(t_full)
        y_full[:idx_cut] = np.interp(t_full[:idx_cut], t_obs, y_obs)
        y_full[idx_cut:] = y_future
        return y_full

7. Interactive Visual Playground

Experiment with all three algorithms below! Drag the controls to adjust in-season crop vigor, simulate late planting shifts, set an end-of-season target total, and observe the live curve fitting algorithms in action:

🌾 Partial Curve Morphing & Target Fitting Sandbox

── Historical Template ● Known 25% Obs ── Fitted Full Forecast Boundary (Day 35) ★ Target Total
Known Obs (Day 35)
3.42 t/ha
Boundary Jump ($\Delta y$)
0.00 t/ha (Smooth)
Predicted Final Yield
18.95 t/ha
Hermite blending bridge guarantees C¹ continuity at Day 35 while preserving the late-season phenological shape.

7. Practical Engineering Guidelines & Best Practices

  1. Avoid Over-Fitting the 25% Window: Do not use high-degree polynomials (e.g. degree \(\ge 3\)) on early points. Crop growth is inherently sigmoidal (logistic / Gompertz / Richards curves); fitting a polynomial to the initial exponential phase will inevitably explode to infinity.
  2. Prior Regularization is Mandatory: Always regularize parameter deviations (\(\|\mathbf{\theta} - \mathbf{\theta}_{\text{prior}}\|^2\)) in nonlinear optimization. In early spring, 5 consecutive warm days can artificially double \(s_y\) if unconstrained.
  3. Decay Window (\(\tau\)) Selection: Set the Hermite bridge decay timescale \(\tau\) proportional to approximately \(10–20\%\) of the remaining season length (\(15–25\text{ days}\)). A timescale that is too short produces an abrupt kink, while a timescale that is too long overrides the historical biological inflection point.

Data and Code Availability

  • Python Reference Implementations: Complete Jupyter Notebooks and reusable SciPy scripts are hosted on GitHub: kamingfung/partial-curve-fitting.
  • Interactive Sandbox: The live browser visualizer above is implemented in vanilla HTML5 Canvas and runs entirely client-side without external dependencies.

References

[^1]: Yin, X., Goudriaan, J., Lantinga, E. A., Vos, J. & Spiertz, H. J. A flexible sigmoid function of determinate growth. Ann. Bot. 91, 361–371 (2003). https://doi.org/10.1093/aob/mcg029 [^2]: Farin, G. Curves and Surfaces for CAGD: A Practical Guide. (Morgan Kaufmann, San Francisco, 2002). https://doi.org/10.1016/B978-1-55860-737-8.X5000-5 [^3]: Boyd, S. & Vandenberghe, L. Convex Optimization. (Cambridge University Press, Cambridge, 2004). https://doi.org/10.1017/CBO9780511804441