|
| 1 | +# bspline.py - B-spline basis functions |
| 2 | +# RMM, 2 Aug 2022 |
| 3 | +# |
| 4 | +# This class implements a set of B-spline basis functions that implement a |
| 5 | +# piecewise polynomial at a set of breakpoints t0, ..., tn with given orders |
| 6 | +# and smoothness. |
| 7 | +# |
| 8 | + |
| 9 | +import numpy as np |
| 10 | +from .basis import BasisFamily |
| 11 | +from scipy.interpolate import BSpline, splev |
| 12 | + |
| 13 | +class BSplineFamily(BasisFamily): |
| 14 | + """B-spline basis functions. |
| 15 | +
|
| 16 | + This class represents a B-spline basis for piecewise polynomials defined |
| 17 | + across a set of breakpoints with given degree and smoothness. On each |
| 18 | + interval between two breakpoints, we have a polynomial of a given degree |
| 19 | + and the spline is continuous up to a given smoothness at interior |
| 20 | + breakpoints. |
| 21 | +
|
| 22 | + Parameters |
| 23 | + ---------- |
| 24 | + breakpoints : 1D array or 2D array of float |
| 25 | + The breakpoints for the spline(s). |
| 26 | +
|
| 27 | + degree : int or list of ints |
| 28 | + For each spline variable, the degree of the polynomial between |
| 29 | + breakpoints. If a single number is given and more than one spline |
| 30 | + variable is specified, the same degree is used for each spline |
| 31 | + variable. |
| 32 | +
|
| 33 | + smoothness : int or list of ints |
| 34 | + For each spline variable, the smoothness at breakpoints (number of |
| 35 | + derivatives that should match). |
| 36 | +
|
| 37 | + vars : None or int, optional |
| 38 | + The number of spline variables. If specified as None (default), |
| 39 | + then the spline basis describes a single variable, with no indexing. |
| 40 | + If the number of spine variables is > 0, then the spline basis is |
| 41 | + index using the `var` keyword. |
| 42 | +
|
| 43 | + """ |
| 44 | + def __init__(self, breakpoints, degree, smoothness=None, vars=None): |
| 45 | + """Create a B-spline basis for piecewise smooth polynomials.""" |
| 46 | + # Process the breakpoints for the spline */ |
| 47 | + breakpoints = np.array(breakpoints, dtype=float) |
| 48 | + if breakpoints.ndim == 2: |
| 49 | + raise NotImplementedError( |
| 50 | + "breakpoints for each spline variable not yet supported") |
| 51 | + elif breakpoints.ndim != 1: |
| 52 | + raise ValueError("breakpoints must be convertable to a 1D array") |
| 53 | + elif breakpoints.size < 2: |
| 54 | + raise ValueError("break point vector must have at least 2 values") |
| 55 | + elif np.any(np.diff(breakpoints) <= 0): |
| 56 | + raise ValueError("break points must be strictly increasing values") |
| 57 | + |
| 58 | + # Decide on the number of spline variables |
| 59 | + if vars is None: |
| 60 | + nvars = 1 |
| 61 | + self.nvars = None # track as single variable |
| 62 | + elif not isinstance(vars, int): |
| 63 | + raise TypeError("vars must be an integer") |
| 64 | + else: |
| 65 | + nvars = vars |
| 66 | + self.nvars = nvars |
| 67 | + |
| 68 | + # |
| 69 | + # Process B-spline parameters (degree, smoothness) |
| 70 | + # |
| 71 | + # B-splines are defined on a set of intervals separated by |
| 72 | + # breakpoints. On each interval we have a polynomial of a certain |
| 73 | + # degree and the spline is continuous up to a given smoothness at |
| 74 | + # breakpoints. The code in this section allows some flexibility in |
| 75 | + # the way that all of this information is supplied, including using |
| 76 | + # scalar values for parameters (which are then broadcast to each |
| 77 | + # output) and inferring values and dimensions from other |
| 78 | + # information, when possible. |
| 79 | + # |
| 80 | + |
| 81 | + # Utility function for broadcasting spline params (degree, smoothness) |
| 82 | + def process_spline_parameters( |
| 83 | + values, length, allowed_types, minimum=0, |
| 84 | + default=None, name='unknown'): |
| 85 | + |
| 86 | + # Preprocessing |
| 87 | + if values is None and default is None: |
| 88 | + return None |
| 89 | + elif values is None: |
| 90 | + values = default |
| 91 | + elif isinstance(values, np.ndarray): |
| 92 | + # Convert ndarray to list |
| 93 | + values = values.tolist() |
| 94 | + |
| 95 | + # Figure out what type of object we were passed |
| 96 | + if isinstance(values, allowed_types): |
| 97 | + # Single number of an allowed type => broadcast to list |
| 98 | + values = [values for i in range(length)] |
| 99 | + elif all([isinstance(v, allowed_types) for v in values]): |
| 100 | + # List of values => make sure it is the right size |
| 101 | + if len(values) != length: |
| 102 | + raise ValueError(f"length of '{name}' does not match" |
| 103 | + f" number of variables") |
| 104 | + else: |
| 105 | + raise ValueError(f"could not parse '{name}' keyword") |
| 106 | + |
| 107 | + # Check to make sure the values are OK |
| 108 | + if values is not None and any([val < minimum for val in values]): |
| 109 | + raise ValueError( |
| 110 | + f"invalid value for '{name}'; must be at least {minimum}") |
| 111 | + |
| 112 | + return values |
| 113 | + |
| 114 | + # Degree of polynomial |
| 115 | + degree = process_spline_parameters( |
| 116 | + degree, nvars, (int), name='degree', minimum=1) |
| 117 | + |
| 118 | + # Smoothness at breakpoints; set default to degree - 1 (max possible) |
| 119 | + smoothness = process_spline_parameters( |
| 120 | + smoothness, nvars, (int), name='smoothness', minimum=0, |
| 121 | + default=[d - 1 for d in degree]) |
| 122 | + |
| 123 | + # Make sure degree is sufficent for the level of smoothness |
| 124 | + if any([degree[i] - smoothness[i] < 1 for i in range(nvars)]): |
| 125 | + raise ValueError("degree must be greater than smoothness") |
| 126 | + |
| 127 | + # Store the parameters for the spline (self.nvars already stored) |
| 128 | + self.breakpoints = breakpoints |
| 129 | + self.degree = degree |
| 130 | + self.smoothness = smoothness |
| 131 | + |
| 132 | + # |
| 133 | + # Compute parameters for a SciPy BSpline object |
| 134 | + # |
| 135 | + # To create a B-spline, we need to compute the knotpoints, keeping |
| 136 | + # track of the use of repeated knotpoints at the initial knot and |
| 137 | + # final knot as well as repeated knots at intermediate points |
| 138 | + # depending on the desired smoothness. |
| 139 | + # |
| 140 | + |
| 141 | + # Store the coefficients for each output (useful later) |
| 142 | + self.coef_offset, self.coef_length, offset = [], [], 0 |
| 143 | + for i in range(nvars): |
| 144 | + # Compute number of coefficients for the piecewise polynomial |
| 145 | + ncoefs = (self.degree[i] + 1) * (len(self.breakpoints) - 1) - \ |
| 146 | + (self.smoothness[i] + 1) * (len(self.breakpoints) - 2) |
| 147 | + |
| 148 | + self.coef_offset.append(offset) |
| 149 | + self.coef_length.append(ncoefs) |
| 150 | + offset += ncoefs |
| 151 | + self.N = offset # save the total number of coefficients |
| 152 | + |
| 153 | + # Create knotpoints for each spline variable |
| 154 | + # TODO: extend to multi-dimensional breakpoints |
| 155 | + self.knotpoints = [] |
| 156 | + for i in range(nvars): |
| 157 | + # Allocate space for the knotpoints |
| 158 | + self.knotpoints.append(np.empty( |
| 159 | + (self.degree[i] + 1) + (len(self.breakpoints) - 2) * \ |
| 160 | + (self.degree[i] - self.smoothness[i]) + (self.degree[i] + 1))) |
| 161 | + |
| 162 | + # Initial knotpoints (multiplicity = order) |
| 163 | + self.knotpoints[i][0:self.degree[i] + 1] = self.breakpoints[0] |
| 164 | + offset = self.degree[i] + 1 |
| 165 | + |
| 166 | + # Interior knotpoints (multiplicity = degree - smoothness) |
| 167 | + nknots = self.degree[i] - self.smoothness[i] |
| 168 | + assert nknots > 0 # just in case |
| 169 | + for j in range(1, self.breakpoints.size - 1): |
| 170 | + self.knotpoints[i][offset:offset+nknots] = self.breakpoints[j] |
| 171 | + offset += nknots |
| 172 | + |
| 173 | + # Final knotpoint (multiplicity = order) |
| 174 | + self.knotpoints[i][offset:offset + self.degree[i] + 1] = \ |
| 175 | + self.breakpoints[-1] |
| 176 | + |
| 177 | + def __repr__(self): |
| 178 | + return f'<{self.__class__.__name__}: nvars={self.nvars}, ' + \ |
| 179 | + f'degree={self.degree}, smoothness={self.smoothness}>' |
| 180 | + |
| 181 | + # Compute the kth derivative of the ith basis function at time t |
| 182 | + def eval_deriv(self, i, k, t, var=None): |
| 183 | + """Evaluate the kth derivative of the ith basis function at time t.""" |
| 184 | + if self.nvars is None or (self.nvars == 1 and var is None): |
| 185 | + # Use same variable for all requests |
| 186 | + var = 0 |
| 187 | + elif self.nvars > 1 and var is None: |
| 188 | + raise SystemError( |
| 189 | + "scalar variable call to multi-variable splines") |
| 190 | + |
| 191 | + # Create a coefficient vector for this spline |
| 192 | + coefs = np.zeros(self.coef_length[var]); coefs[i] = 1 |
| 193 | + |
| 194 | + # Evaluate the derivative of the spline at the desired point in time |
| 195 | + return BSpline(self.knotpoints[var], coefs, |
| 196 | + self.degree[var]).derivative(k)(t) |
0 commit comments