From 5b03a5b87a759ea3504e7cf6bd4f4697dfff417b Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 3 Sep 2025 19:47:45 -0400 Subject: [PATCH 01/54] fix relative imports in core.py remove storage of basis and quadrature evaluations fixes for python 3 compatibility --- pspace/core.py | 468 +++++++++++++------------------ pspace/orthogonal_polynomials.py | 28 +- pspace/stochastic_utils.py | 19 +- tests/python_check.py | 7 +- tests/test_sparsity.py | 34 +-- 5 files changed, 248 insertions(+), 308 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 8045464..867c1e7 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -9,11 +9,11 @@ from enum import Enum # Local modules -from stochastic_utils import tensor_indices, nqpts, sparse -from orthogonal_polynomials import unit_hermite as Hhat -from orthogonal_polynomials import unit_legendre as Phat -from orthogonal_polynomials import unit_laguerre as Lhat -from plotter import plot_jacobian, plot_vector +from .stochastic_utils import tensor_indices, nqpts, sparse +from .orthogonal_polynomials import unit_hermite as Hhat +from .orthogonal_polynomials import unit_legendre as Phat +from .orthogonal_polynomials import unit_laguerre as Lhat +from .plotter import plot_jacobian, plot_vector def index(ii): return ii @@ -25,7 +25,7 @@ def index(ii): return 3 if ii == 3: return 2 - + ## TODO # Parameters are Monomials # Deterministic parameters are constant monomials (degree 0) @@ -39,10 +39,10 @@ class ParameterType(Enum): """ DETERMINISTIC = 1 PROBABILISTIC = 2 - + class DistributionType(Enum): """ - Enumeration of probability distribution types. + Enumeration of probability distribution types. """ NONE = 0 NORMAL = 1 @@ -53,11 +53,12 @@ class DistributionType(Enum): class Parameter(object): """ - A hashable parameter object wrapping information about the - parameter used in computations. Hashable implies being able to - serve as keys dictionaries. + A hashable parameter object wrapping information about the parameter + used in computations. Hashable implies being able to serve as keys + dictionaries. Author: Komahan Boopathy + """ def __init__(self, pdata): self.param_id = pdata['param_id'] @@ -65,10 +66,8 @@ def __init__(self, pdata): self.param_type = pdata['param_type'] self.dist_type = pdata['dist_type'] self.monomial_degree = pdata['monomial_degree'] - self.basis_map = {} - self.quadrature_map = {} return - + def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) @@ -83,7 +82,7 @@ def __ne__(self, other): def getParameterValue(self): return self.param_value - + def getParameterType(self): return self.param_type @@ -102,44 +101,30 @@ def getQuadraturePointsWeights(self, npoints): def evalOrthoNormalBasis(self, z, d): pass - + class DeterministicParameter(Parameter): def __init__(self, pdata): super(DeterministicParameter, self).__init__(pdata) self.param_value = pdata['param_value'] return - + def getQuadraturePointsWeights(self, npoints): - raise - try: - return self.quadrature_map[npoints] - except: - # Store in map - cmap = {'yq' : self.param_value, 'zq' : self.param_value, 'wq' : 1.0} - self.quadrature_map[npoints] = cmap - return cmap - + cmap = {'yq' : self.param_value, 'zq' : self.param_value, 'wq' : 1.0} + return cmap + def evalOrthoNormalBasis(self, z, d): """ - Evaluate the orthonormal basis at supplied coordinate. If it - exists in the map already, return the value from map. If not - evaluate the orthonormal polynomial and return. - - Note: For the deterministic case, the value is always one. + Evaluate the orthonormal basis at supplied coordinate. Note: For + the deterministic case, the value is always one. """ - zkey = hash(z) - try: - return self.basis_map[(d,zkey)] - except: - self.basis_map[(d,zkey)] = 1.0 - return val - + return 1.0 + class ProbabilisticParameter(Parameter): def __init__(self, pdata): super(ProbabilisticParameter, self).__init__(pdata) self.dist_params = pdata['dist_params'] return - + def getDistributionParameters(self, key): return self.dist_params[key] @@ -153,49 +138,38 @@ def getQuadraturePointsWeights(self, npoints): """ numpy.polynomial.laguerre.laggauss(deg)[source] """ - try: - return self.quadrature_map[npoints] - except: - # This is based on interval [0, \inf] with the weight - # function f(xi) = \exp(-xi) which is also the standard - # PDF f(z) = \exp(-z) - xi, w = np.polynomial.laguerre.laggauss(npoints) - mu = self.dist_params['mu'] - beta = self.dist_params['beta'] - - # scale weights to unity (Area under exp(-xi) in [0,inf] is 1.0 - w = w/1.0 - - # transformation of variables - y = mu + beta*xi - - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) - - # Return quadrature point in standard space as well - z = xi # (y-mu)/beta - - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - self.quadrature_map[npoints] = cmap - return cmap + # This is based on interval [0, \inf] with the weight + # function f(xi) = \exp(-xi) which is also the standard + # PDF f(z) = \exp(-z) + + xi, w = np.polynomial.laguerre.laggauss(npoints) + mu = self.dist_params['mu'] + beta = self.dist_params['beta'] + + # scale weights to unity (Area under exp(-xi) in [0,inf] is 1.0 + w = w/1.0 + + # transformation of variables + y = mu + beta*xi + + # assert if weights don't add up to unity + eps = np.finfo(np.float64).eps + assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + + # Return quadrature point in standard space as well + z = xi # (y-mu)/beta + + # Store in map + cmap = {'yq' : y, 'zq' : z, 'wq' : w} + return cmap def evalOrthoNormalBasis(self, z, d): """ - Evaluate the orthonormal basis at supplied coordinate. If it - exists in the map already, return the value from map. If not - evaluate the orthonormal polynomial and return. + Evaluate the orthonormal basis at supplied coordinate. """ - zkey = hash((z,d)) - try: - return self.basis_map[zkey] - except: - val = Lhat(z,d) - self.basis_map[zkey] = val - return val - + return Lhat(z,d) + class NormalParameter(Parameter): def __init__(self, pdata): super(NormalParameter, self).__init__(pdata) @@ -203,97 +177,73 @@ def __init__(self, pdata): return def getQuadraturePointsWeights(self, npoints): - try: - return self.quadrature_map[npoints] - except: - # This is based on physicist unnormlized weight exp(-x*x). - x, w = np.polynomial.hermite.hermgauss(npoints) - mu = self.dist_params['mu'] - sigma = self.dist_params['sigma'] - - # scale weights to unity (Area under exp(-x) in [0,inf] is 1.0 - w = w/np.sqrt(np.pi) - - # transformation of variables - y = mu + sigma*np.sqrt(2)*x - - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) - - # Return quadrature point in standard space as well - z = (y-mu)/sigma - - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - self.quadrature_map[npoints] = cmap - return cmap + # This is based on physicist unnormlized weight exp(-x*x). + x, w = np.polynomial.hermite.hermgauss(npoints) + mu = self.dist_params['mu'] + sigma = self.dist_params['sigma'] + + # scale weights to unity (Area under exp(-x*x) in [-inf,inf] is pi + w = w/np.sqrt(np.pi) + + # transformation of variables + y = mu + sigma*np.sqrt(2)*x + + # assert if weights don't add up to unity + eps = np.finfo(np.float64).eps + assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + + # Return quadrature point in standard space as well + z = (y-mu)/sigma + + # Store in map + cmap = {'yq' : y, 'zq' : z, 'wq' : w} + return cmap def evalOrthoNormalBasis(self, z, d): """ - Evaluate the orthonormal basis at supplied coordinate. If it - exists in the map already, return the value from map. If not - evaluate the orthonormal polynomial and return. - """ - zkey = hash((z,d)) - try: - return self.basis_map[zkey] - except: - val = Hhat(z,d) - self.basis_map[zkey] = val - return val - + Evaluate the orthonormal basis at supplied coordinate. + """ + return Hhat(z, d) + class UniformParameter(Parameter): def __init__(self, pdata): super(UniformParameter, self).__init__(pdata) self.dist_params = pdata['dist_params'] return - def getQuadraturePointsWeights(self, npoints): - try: - return self.quadrature_map[npoints] - except: - # This is based on weight 1.0 on interval [-1,1] - x, w = np.polynomial.legendre.leggauss(npoints) - a = self.dist_params['a'] - b = self.dist_params['b'] - - # scale weights to unity - w = w/2.0 - - # transformation of variables - y = (b-a)*x/2 + (b+a)/2 - - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) - - # Return quadrature point in standard space as well - z = (y-a)/(b-a) - - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - self.quadrature_map[npoints] = cmap - return cmap + def getQuadraturePointsWeights(self, npoints): + # This is based on weight 1.0 on interval [-1,1] + x, w = np.polynomial.legendre.leggauss(npoints) + a = self.dist_params['a'] + b = self.dist_params['b'] + + # scale weights to unity + w = w/2.0 + + # transformation of variables + y = (b-a)*x/2 + (b+a)/2 + + # assert if weights don't add up to unity + eps = np.finfo(np.float64).eps + assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + + # Return quadrature point in standard space as well + z = (y-a)/(b-a) + + # Store in map + cmap = {'yq' : y, 'zq' : z, 'wq' : w} + return cmap def evalOrthoNormalBasis(self, z, d): """ - Evaluate the orthonormal basis at supplied coordinate. If it - exists in the map already, return the value from map. If not - evaluate the orthonormal polynomial and return. + Evaluate the orthonormal basis at supplied coordinate. """ - zkey = hash((z,d)) - try: - return self.basis_map[zkey] - except: - val = Phat(z,d) - self.basis_map[zkey] = val - return val + return Phat(z,d) class HashableDict(dict): def __hash__(self): - return hash(tuple(sorted(self.iteritems()))) - + return hash(tuple(sorted(self.items()))) + class ParameterFactory: """ This class takes in primitives and makes data strucuture required @@ -301,12 +251,12 @@ class ParameterFactory: Deterministic parameter is a parameter whose polynomial degree is zero and stochastic parameter is a parameter whose polynomial - degree is non zero. + degree is non zero. """ def __init__(self): self.next_param_id = 0 return - + def getParameterID(self): pid = self.next_param_id self.next_param_id = self.next_param_id + 1 @@ -347,7 +297,7 @@ def createUniformParameter(self, pname, dist_params, monomial_degree): pdata['monomial_degree'] = monomial_degree pdata['param_id'] = self.getParameterID() return UniformParameter(pdata) - + def createExponentialParameter(self, pname, dist_params, monomial_degree): # Prepare map for calling constructor of Uniform # parameter @@ -366,8 +316,9 @@ class ParameterContainer: quadrature and evaluation of basis functions. This object is simply a container for objects of type Parameter. - + Author: Komahan Boopathy + """ def __init__(self): self.num_terms = 1 @@ -380,10 +331,9 @@ def __init__(self): # is the degree according to tensor # product - # Replace with basis class self.psi_map = {} - + return def __str__(self): @@ -398,18 +348,18 @@ def initialize(self): """ # Create degree map sp_hd_map = self.getParameterHighestDegreeMap(exclude=ParameterType.DETERMINISTIC) - self.basistermwise_parameter_degrees = tensor_indices(sp_hd_map) + self.basistermwise_parameter_degrees = tensor_indices(sp_hd_map) return def getNumQuadraturePoints(self): - """ + """ """ param_nqpts_map = Counter() pkeys = self.parameter_map.keys() for pid in pkeys: param_nqpts_map[pid] = self.getParameter(pid).monomial_degree return param_nqpts_map - + def getNumQuadraturePointsFromDegree(self,dmap): """ Supply a map whose keys are parameterids and values are @@ -422,13 +372,13 @@ def getNumQuadraturePointsFromDegree(self,dmap): ## for pid in pids: ## param_nqpts_map[pid] = nqpts(dmap[pid]) ## return param_nqpts_map - + pids = dmap.keys() param_nqpts_map = Counter() for pid in self.parameter_map.keys(): #pids: param_nqpts_map[pid] = self.parameter_map[pid].monomial_degree #nqpts(dmap[pid]) return param_nqpts_map - + def getParameterDegreeForBasisTerm(self, paramid, kthterm): """ What is the polynomial degree of the corresponding k-th or @@ -438,10 +388,10 @@ def getParameterDegreeForBasisTerm(self, paramid, kthterm): multivariate basis set. """ return self.basistermwise_parameter_degrees[kthterm][paramid] - + def getParameters(self): return self.parameter_map - + def getParameter(self, paramid): return self.parameter_map[paramid] @@ -452,7 +402,7 @@ def getParameterHighestDegreeMap(self, exclude=ParameterType.DETERMINISTIC): if exclude != param.getParameterType(): degree_map[paramkey] = param.monomial_degree return degree_map - + def getNumStochasticBasisTerms(self): return self.num_terms @@ -460,24 +410,24 @@ def addParameter(self, new_parameter): # do you want to hold separate maps for deterministic and # stochastic parameters? - + # Add parameter object to the map of parameters self.parameter_map[new_parameter.getParameterID()] = new_parameter # Increase the number of stochastic terms (tensor pdt rn) self.num_terms = self.num_terms*new_parameter.monomial_degree - + return def initializeQuadrature(self, param_nqpts_map): self.quadrature_map = self.getQuadraturePointsWeights(param_nqpts_map) return - + def W(self, q): wmap = self.quadrature_map[q]['W'] return wmap - def psi(self, k, zmap): + def psi(self, k, zmap): paramids = zmap.keys() ans = 1.0 for paramid in paramids: @@ -496,7 +446,7 @@ def Z(self, q, key='pid'): qmap = self.quadrature_map[q]['Z'] nmap = {} for pid in qmap.keys(): - nmap[self.getParameter(pid).param_name] = qmap[pid] + nmap[self.getParameter(pid).param_name] = qmap[pid] return nmap def Y(self, q, key='pid'): @@ -508,71 +458,57 @@ def Y(self, q, key='pid'): qmap = self.quadrature_map[q]['Y'] nmap = {} for pid in qmap.keys(): - nmap[self.getParameter(pid).param_name] = qmap[pid] + nmap[self.getParameter(pid).param_name] = qmap[pid] return nmap - - ## def evalOrthoNormalBasis(self, k, q): - ## print self.Z(q) - ## return self.psi(k, self.Z(q)) - + def evalOrthoNormalBasis(self, k, q): - zq = self.Z(q) - zkey = HashableDict(zq) - try: - return self.psi_map[(k, zkey)] - except: - val = self.psi(k, zq) - self.psi_map[(k,zkey)] = val - return val + return self.psi(k, self.Z(q)) def getQuadraturePointsWeights(self, param_nqpts_map): """ Return a map of k : qmap, where k is the global basis index """ + params = list(param_nqpts_map.keys()) + nqpts = list(param_nqpts_map.values()) - ## TODO generalize and store things if necessary - - params = param_nqpts_map.keys() - nqpts = param_nqpts_map.values() - # exclude deterministic terms? - total_quadrature_points = np.prod(nqpts) + total_quadrature_points = int(np.prod(nqpts)) num_vars = len(params) - + # Initialize map with empty values corresponding to each key qmap = {} for key in range(total_quadrature_points): qmap[key] = [] if num_vars == 1: - + # Get 1d-quadrature maps map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) pid0 = self.getParameter(0).getParameterID() # Tensor product of 1D-quadrature to get N-D quadrature ctr = 0 - + for i0 in range(nqpts[0]): yvec = { pid0 : map0['yq'][i0] } - + zvec = { pid0 : map0['zq'][i0] } - + w = map0['wq'][i0] - + data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - + qmap[ctr] = data - + ctr += 1 - + return qmap - + elif num_vars == 2: - + # Get 1d-quadrature maps map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) @@ -582,10 +518,10 @@ def getQuadraturePointsWeights(self, param_nqpts_map): # Tensor product of 1D-quadrature to get N-D quadrature ctr = 0 - + for i0 in range(nqpts[0]): for i1 in range(nqpts[1]): - + yvec = { pid0 : map0['yq'][i0], pid1 : map1['yq'][i1] } @@ -597,19 +533,19 @@ def getQuadraturePointsWeights(self, param_nqpts_map): ## pid2 : map2['wq'][i2] } w = map0['wq'][i0]*map1['wq'][i1] - + data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - + qmap[ctr] = data - + ctr += 1 # Check if the sum of weights is one - + return qmap elif num_vars == 3: - + # Get 1d-quadrature maps map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) @@ -621,7 +557,7 @@ def getQuadraturePointsWeights(self, param_nqpts_map): # Tensor product of 1D-quadrature to get N-D quadrature ctr = 0 - + for i0 in range(nqpts[0]): for i1 in range(nqpts[1]): for i2 in range(nqpts[2]): @@ -639,19 +575,19 @@ def getQuadraturePointsWeights(self, param_nqpts_map): ## pid2 : map2['wq'][i2] } w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2] - + data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - + qmap[ctr] = data - + ctr += 1 # Check if the sum of weights is one - + return qmap - + elif num_vars == 4: - + # Get 1d-quadrature maps map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) @@ -665,37 +601,37 @@ def getQuadraturePointsWeights(self, param_nqpts_map): # Tensor product of 1D-quadrature to get N-D quadrature ctr = 0 - + for i0 in range(nqpts[0]): for i1 in range(nqpts[1]): for i2 in range(nqpts[2]): for i3 in range(nqpts[3]): - + yvec = { pid0 : map0['yq'][i0], pid1 : map1['yq'][i1], pid2 : map2['yq'][i2], pid3 : map3['yq'][i3]} - + zvec = { pid0 : map0['zq'][i0], pid1 : map1['zq'][i1], pid2 : map2['zq'][i2], - pid3 : map3['zq'][i3]} - + pid3 : map3['zq'][i3]} + w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2]*map3['wq'][i3] data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - + qmap[ctr] = data - + ctr += 1 # Check if the sum of weights is one - + return qmap - + elif num_vars == 5: - + # Get 1d-quadrature maps map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) @@ -708,10 +644,10 @@ def getQuadraturePointsWeights(self, param_nqpts_map): pid2 = self.getParameter(2).getParameterID() pid3 = self.getParameter(3).getParameterID() pid4 = self.getParameter(4).getParameterID() - + # Tensor product of 1D-quadrature to get N-D quadrature ctr = 0 - + for i0 in range(nqpts[0]): for i1 in range(nqpts[1]): for i2 in range(nqpts[2]): @@ -729,14 +665,14 @@ def getQuadraturePointsWeights(self, param_nqpts_map): pid1 : map1['zq'][i1], pid2 : map2['zq'][i2], pid3 : map3['zq'][i3], - pid4 : map4['zq'][i4]} - + pid4 : map4['zq'][i4]} + w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2]*map3['wq'][i3]*map4['wq'][i4] - + data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - + qmap[ctr] = data - + ctr += 1 return qmap @@ -745,7 +681,7 @@ def projectResidual(self, elem, time, res, X, v, dv, ddv): Project the elements deterministic residual onto stochastic basis and place in global stochastic residual array """ - + # size of deterministic element state vector ndisps = elem.numDisplacements() nnodes = elem.numNodes() @@ -753,7 +689,7 @@ def projectResidual(self, elem, time, res, X, v, dv, ddv): nsdof = ndisps*self.getNumStochasticBasisTerms() for i in range(self.getNumStochasticBasisTerms()): - + # Initialize quadrature with number of gauss points # necessary for i-th basis entry self.initializeQuadrature( @@ -764,19 +700,19 @@ def projectResidual(self, elem, time, res, X, v, dv, ddv): # Quadrature Loop rtmp = np.zeros((nddof)) - + for q in self.quadrature_map.keys(): # Set the parameter values into the element elem.setParameters(self.Y(q,'name')) - + # Create space for fetching deterministic residual # vector resq = np.zeros((nddof)) uq = np.zeros((nddof)) udq = np.zeros((nddof)) uddq = np.zeros((nddof)) - + # Obtain states at quadrature nodes for k in range(self.num_terms): psiky = self.evalOrthoNormalBasis(k,q) @@ -786,18 +722,18 @@ def projectResidual(self, elem, time, res, X, v, dv, ddv): # Fetch the deterministic element residual elem.addResidual(time, resq, X, uq, udq, uddq) - + # Project the determinic element residual onto the # stochastic basis and place in global residual array psiq = self.evalOrthoNormalBasis(i,q) - alphaq = self.W(q) + alphaq = self.W(q) rtmp[:] += resq*psiq*alphaq # Distribute the residual for ii in range(nnodes): # Local indices listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps + liend = (index(ii)+1)*ndisps gistart = index(ii)*nsdof + i*ndisps giend = index(ii)*nsdof + (i+1)*ndisps @@ -809,7 +745,7 @@ def projectResidual(self, elem, time, res, X, v, dv, ddv): # plot_vector(res, 'stochatic-element-residual.pdf', normalize=True, precision=1.0e-6) return - + def projectJacobian(self, elem, time, J, alpha, beta, gamma, @@ -823,23 +759,23 @@ def projectJacobian(self, dmapf = Counter() for pid in self.parameter_map.keys(): dmapf[pid] = 1 - + # size of deterministic element state vector ndisps = elem.numDisplacements() nnodes = elem.numNodes() nddof = ndisps*nnodes nsdof = ndisps*self.getNumStochasticBasisTerms() - + for i in range(self.getNumStochasticBasisTerms()): imap = self.basistermwise_parameter_degrees[i] - - for j in range(self.getNumStochasticBasisTerms()): + + for j in range(self.getNumStochasticBasisTerms()): jmap = self.basistermwise_parameter_degrees[j] - + smap = sparse(imap, jmap, dmapf) - + if False not in smap.values(): - + dmap = Counter() dmap.update(imap) dmap.update(jmap) @@ -851,16 +787,16 @@ def projectJacobian(self, self.initializeQuadrature(nqpts_map) jtmp = np.zeros((nddof,nddof)) - + # Quadrature Loop for q in self.quadrature_map.keys(): try: elem.setParameters(self.Y(q,'name')) - except: + except: print('exception') raise - + # Create space for fetching deterministic # jacobian, and state vectors that go as input Aq = np.zeros((nddof,nddof)) @@ -872,31 +808,31 @@ def projectJacobian(self, uq[:] += v[k*nddof:(k+1)*nddof]*psiky udq[:] += dv[k*nddof:(k+1)*nddof]*psiky uddq[:] += ddv[k*nddof:(k+1)*nddof]*psiky - + # Fetch the deterministic element jacobian matrix elem.addJacobian(time, Aq, alpha, beta, gamma, X, uq, udq, uddq) - + # Project the determinic element jacobian onto the # stochastic basis and place in the global matrix psiziw = self.W(q)*self.evalOrthoNormalBasis(i,q) psizjw = self.evalOrthoNormalBasis(j,q) - jtmp[:,:] += Aq*psiziw*psizjw + jtmp[:,:] += Aq*psiziw*psizjw # Distribute blocks (16 times) - for ii in range(0,nnodes): + for ii in range(0,nnodes): for jj in range(0,nnodes): # Local indices listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps + liend = (index(ii)+1)*ndisps ljstart = index(jj)*ndisps ljend = (index(jj)+1)*ndisps - + gistart = index(ii)*nsdof + i*ndisps giend = index(ii)*nsdof + (i+1)*ndisps gjstart = index(jj)*nsdof + j*ndisps gjend = index(jj)*nsdof + (j+1)*ndisps - + if i == j: J[gistart:giend, gjstart:gjend] += jtmp[listart:liend, ljstart:ljend] else: @@ -905,7 +841,7 @@ def projectJacobian(self, #print("J=", J) #plot_jacobian(J, 'stochatic-element-block.pdf', normalize=True, precision=1.0e-6) - + return def projectInitCond(self, elem, v, vd, vdd, xpts): @@ -920,7 +856,7 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): nnodes = elem.numNodes() nddof = ndisps*nnodes nsdof = ndisps*self.getNumStochasticBasisTerms() - + for k in range(self.getNumStochasticBasisTerms()): # Initialize quadrature with number of gauss points @@ -930,7 +866,7 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): self.basistermwise_parameter_degrees[k] ) ) - + # Quadrature Loop utmp = np.zeros((nddof)) udtmp = np.zeros((nddof)) @@ -957,10 +893,10 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): uddtmp += uddq*psizkw # Distribute values - for ii in range(nnodes): + for ii in range(nnodes): # Local indices listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps + liend = (index(ii)+1)*ndisps gistart = index(ii)*nsdof + k*ndisps giend = index(ii)*nsdof + (k+1)*ndisps @@ -968,7 +904,7 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): v[gistart:giend] += utmp[listart:liend] vd[gistart:giend] += udtmp[listart:liend] vdd[gistart:giend] += uddtmp[listart:liend] - + ## # Replace numbers less than machine precision with zero to ## # avoid numerical issues ## if clean is True: @@ -976,5 +912,5 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): ## v[np.abs(v) < eps] = 0 ## vd[np.abs(vd) < eps] = 0 ## vdd[np.abs(vdd) < eps] = 0 - + return diff --git a/pspace/orthogonal_polynomials.py b/pspace/orthogonal_polynomials.py index 62e693d..0331a85 100644 --- a/pspace/orthogonal_polynomials.py +++ b/pspace/orthogonal_polynomials.py @@ -1,6 +1,6 @@ -import numpy as np import math -from scipy import misc as sp +import numpy as np +import scipy.special as sp def tensor_indices(nterms): """ @@ -8,9 +8,9 @@ def tensor_indices(nterms): """ tot_terms = np.prod(nterms) num_vars = len(nterms) - + #print tot_terms, num_vars - + idx = {} for key in range(tot_terms): idx[key] = [] @@ -18,19 +18,19 @@ def tensor_indices(nterms): ## for term in nterms: ## for k in range(term): ## print k - + ctr = 0 for i in range(nterms[0]): for j in range(nterms[1]): for k in range(nterms[2]): ctr += 1 idx[i+j+k].append((i, j, k)) - + ## for key in idx: ## print idx[key], len(idx[key]) - + flat_list = [item for sublist in idx.values() for item in sublist] - + return flat_list def laguerre(z,d): @@ -57,7 +57,7 @@ def hermite(z, d): Hermite polynomials are produced using exp(-z^2)/sqrt(2*pi) as the weight on trivial monomials on interval [-inf,inf]. - + """ if d == 0: return 1.0 - 0*z @@ -71,7 +71,7 @@ def unit_hermite(z,d): Returns units hermite polynomial of degree n evaluated at z """ return hermite(z,d)/np.sqrt(math.factorial(d)) - + ## def rlegendre(z,d): ## y = 2*z-1 #(z+1)/2.0 ## if d == 0: @@ -86,7 +86,7 @@ def legendre(z, d): Use recursion to generate Legendre polynomials Hermite polynomials are produced using rho(z) = 1.0 as the weight - on trivial monomials in interval [0,1]. + on trivial monomials in interval [0,1]. """ p = 0.0 for k in range(d+1): @@ -98,11 +98,11 @@ def unit_legendre(z,d): return legendre(z,d)*np.sqrt(2*d+1) if __name__ == "__main__": - + """ Test hermite polynomials """ - + print (unit_hermite(1.2,0), hermite(1.2,0)/np.sqrt(math.factorial(0))) print (unit_hermite(1.2,1), hermite(1.2,1)/np.sqrt(math.factorial(1))) print (unit_hermite(1.2,2), hermite(1.2,2)/np.sqrt(math.factorial(2))) @@ -112,7 +112,7 @@ def unit_legendre(z,d): """ Test Legendre polynomials """ - + print ("legendre") print (unit_legendre(1.2,0), legendre(1.2,0), rlegendre(1.2,0)) print (unit_legendre(1.2,1), legendre(1.2,1), rlegendre(1.2,1)) diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index f245ffc..8d45165 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -13,13 +13,13 @@ ## return [k,0,0] ## elif nqpt[0] - k: ## return [ - + ## for (pid,nqpt) in zip(pids,nqpts): ## print pid, nqpt -## if k < nqpt:mod(nqpt-k,k) +## if k < nqpt:mod(nqpt-k,k) ## pidx[pid] = mod( - -## return + +## return def sparse(dmapi, dmapj, dmapf): smap = {} @@ -27,7 +27,7 @@ def sparse(dmapi, dmapj, dmapf): if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: smap[key] = True else: - smap[key] = False + smap[key] = False return smap def nqpts(pdeg): @@ -41,11 +41,12 @@ def tensor_indices(phdmap): """ Get basis functions indices based on tensor product """ - pids = phdmap.keys() # parameter IDs - pdegs = phdmap.values() # parameter degrees + + pids = list(phdmap.keys()) # parameter IDs + pdegs = list(phdmap.values()) # parameter degrees # Exclude deterministic terms - total_tensor_basis_terms = np.prod(pdegs) + total_tensor_basis_terms = int(np.prod(pdegs)) num_vars = len(pdegs) # Initialize map with empty values corresponding to each key @@ -85,7 +86,7 @@ def tensor_indices(phdmap): pids[1]:k1, pids[2]:k2, pids[3]:k3})) # add four element tuple to map - ctr += 1 + ctr += 1 elif num_vars == 5: ctr = 0 for k0 in range(pdegs[0]): diff --git a/tests/python_check.py b/tests/python_check.py index 39e23f1..59ba2be 100644 --- a/tests/python_check.py +++ b/tests/python_check.py @@ -1,7 +1,9 @@ import sys from os import path sys.path.append( path.dirname( path.dirname( path.abspath(__file__) ) ) ) + import numpy as np + from pspace.core import ParameterFactory, ParameterContainer # Create "Parameter" using "Parameter Factory" object @@ -11,7 +13,7 @@ m = pfactory.createExponentialParameter('m', dict(mu=6.0, beta=1.0), 5) d = pfactory.createUniformParameter('d', dict(a=-5.0, b=4.0), 5) e = pfactory.createExponentialParameter('e', dict(mu=6.0, beta=1.0), 5) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) @@ -24,7 +26,8 @@ pc.initializeQuadrature({0:5,1:5,2:5,3:5,4:5}) N = pc.getNumStochasticBasisTerms() -print N +print("Number of basis terms: ", N) + for k in range(N): pids = pc.getParameters().keys() for q in pc.quadrature_map.keys(): diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index 4a99d81..5ac8c1f 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -37,14 +37,14 @@ def sparse(dmapi, dmapj, dmapf): if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: smap[key] = True else: - smap[key] = False + smap[key] = False return smap def getSymmetricNonZeroIndices(pc, dmapf): nz = {} N = pc.getNumStochasticBasisTerms() for i in range(N): - dmapi = pc.basistermwise_parameter_degrees[i] + dmapi = pc.basistermwise_parameter_degrees[i] for j in range(i,N): dmapj = pc.basistermwise_parameter_degrees[j] smap = sparse(dmapi, dmapj, dmapf) @@ -74,14 +74,14 @@ def getSparseJacobian(pc, f, dmapf): def getJacobian(f, dmapf): """ - """ + """ # Test getting ND quadrature points N = pc.getNumStochasticBasisTerms() A = np.zeros((N, N)) for i in range(N): dmapi = pc.basistermwise_parameter_degrees[i] - + for j in range(N): dmapj = pc.basistermwise_parameter_degrees[j] @@ -89,7 +89,7 @@ def getJacobian(f, dmapf): dmap.update(dmapi) dmap.update(dmapj) dmap.update(dmapf) - + # add up the degree of both participating functions psizi # and psizj to determine the total degree of integrand nqpts_map = pc.getNumQuadraturePointsFromDegree(dmap) @@ -103,10 +103,10 @@ def getJacobian(f, dmapf): return A if __name__ == '__main__': - """ + """ """ start = timer() - + # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=-4.0, sigma=0.50), 4) @@ -120,11 +120,11 @@ def getJacobian(f, dmapf): pc.addParameter(k) pc.addParameter(m) pc.addParameter(d) - + pc.initialize() suffix = 'eee' - + # rename for readability w = lambda q : pc.W(q) psiz = lambda i, q : pc.evalOrthoNormalBasis(i,q) @@ -136,7 +136,7 @@ def fy(q): for paramid in paramids: ans = ans*ymap[paramid] return ans - + def gy(q,pid): ymap = pc.Y(q) return ymap[pid] @@ -150,7 +150,7 @@ def gy(q,pid): A = getSparseJacobian(pc, func, dmap) #A = getJacobian(func, dmap) plot_jacobian(A, 'sparsity-y3^4-' + suffix + '.pdf') - + # Nonzero constant func = lambda q : 1.0 dmap[0] = 0 ; dmap[1] = 0; dmap[2] = 0 @@ -234,17 +234,17 @@ def gy(q,pid): A = getSparseJacobian(pc, func, dmap) #A = getJacobian(func, dmap) plot_jacobian(A, 'sparsity-y3^4-' + suffix + '.pdf') - + # y_1 * y_2 * y_3*y_4 func = lambda q : gy(q,0) * gy(q,1) * gy(q,2) * gy(q,3) dmap[0] = 1; dmap[1] = 1; dmap[2] = 1; dmap[3] = 1 - print 'first' + print('first') A = getSparseJacobian(pc, func, dmap) - print 'second' + print('second') A = getSparseJacobian(pc, func, dmap) - print 'third' + print('third') A = getSparseJacobian(pc, func, dmap) #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y1y2y3y4-' + suffix + '.pdf') + plot_jacobian(A, 'sparsity-y1y2y3y4-' + suffix + '.pdf') end = timer() - print "elapsed time:", end - start + print("elapsed time:", end - start) From ca468cf3d3613df9c08ab635719717b9f194dba6 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 3 Sep 2025 20:21:00 -0400 Subject: [PATCH 02/54] fix orthogonal polynomials --- pspace/orthogonal_polynomials.py | 36 ++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/pspace/orthogonal_polynomials.py b/pspace/orthogonal_polynomials.py index 0331a85..4676b3b 100644 --- a/pspace/orthogonal_polynomials.py +++ b/pspace/orthogonal_polynomials.py @@ -38,7 +38,7 @@ def laguerre(z,d): Polynomials such that _{exp(-z)}^{0,inf} = 0 """ if d == 0: - return 1.0 - 0*z + return 1.0 elif d == 1: return 1.0 - z else: @@ -60,7 +60,7 @@ def hermite(z, d): """ if d == 0: - return 1.0 - 0*z + return 1.0 elif d == 1: return z else: @@ -72,20 +72,19 @@ def unit_hermite(z,d): """ return hermite(z,d)/np.sqrt(math.factorial(d)) -## def rlegendre(z,d): -## y = 2*z-1 #(z+1)/2.0 -## if d == 0: -## return 1.0 -## elif d == 1: -## return y -## else: -## return ((2*(d-1)+1)*y*rlegendre(y,d-1)-(d-1)*rlegendre(y,d-2))/(1.0*d) +def rlegendre(z, d): + if d == 0: + return 1.0 + if d == 1: + return 2.0*z - 1.0 + return ((2*d - 1) * (2*z - 1) * rlegendre(z, d-1) + - (d - 1) * rlegendre(z, d-2)) / d def legendre(z, d): """ Use recursion to generate Legendre polynomials - Hermite polynomials are produced using rho(z) = 1.0 as the weight + Legendre polynomials are produced using rho(z) = 1.0 as the weight on trivial monomials in interval [0,1]. """ p = 0.0 @@ -102,7 +101,7 @@ def unit_legendre(z,d): """ Test hermite polynomials """ - + print(" Test Hermite polynomials ") print (unit_hermite(1.2,0), hermite(1.2,0)/np.sqrt(math.factorial(0))) print (unit_hermite(1.2,1), hermite(1.2,1)/np.sqrt(math.factorial(1))) print (unit_hermite(1.2,2), hermite(1.2,2)/np.sqrt(math.factorial(2))) @@ -113,9 +112,20 @@ def unit_legendre(z,d): Test Legendre polynomials """ - print ("legendre") + print(" Test Legendre polynomials ") print (unit_legendre(1.2,0), legendre(1.2,0), rlegendre(1.2,0)) print (unit_legendre(1.2,1), legendre(1.2,1), rlegendre(1.2,1)) print (unit_legendre(1.2,2), legendre(1.2,2), rlegendre(1.2,2)) print (unit_legendre(1.2,3), legendre(1.2,3), rlegendre(1.2,3)) print (unit_legendre(1.2,4), legendre(1.2,4), rlegendre(1.2,4)) + + + """ + Test laguerre polynomials + """ + print(" Test Laguerre polynomials ") + print (unit_laguerre(1.2,0), laguerre(1.2,0)) + print (unit_laguerre(1.2,1), laguerre(1.2,1)) + print (unit_laguerre(1.2,2), laguerre(1.2,2)) + print (unit_laguerre(1.2,3), laguerre(1.2,3)) + print (unit_laguerre(1.2,4), laguerre(1.2,4)) From aac32f25f590b7a517d13b1a268e2bdf6aefba25 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 3 Sep 2025 20:23:25 -0400 Subject: [PATCH 03/54] fix orthogonal polynomials --- pspace/core.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 867c1e7..fe91358 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -10,9 +10,9 @@ # Local modules from .stochastic_utils import tensor_indices, nqpts, sparse -from .orthogonal_polynomials import unit_hermite as Hhat -from .orthogonal_polynomials import unit_legendre as Phat -from .orthogonal_polynomials import unit_laguerre as Lhat +from .orthogonal_polynomials import unit_hermite +from .orthogonal_polynomials import unit_legendre +from .orthogonal_polynomials import unit_laguerre from .plotter import plot_jacobian, plot_vector def index(ii): @@ -168,7 +168,7 @@ def evalOrthoNormalBasis(self, z, d): """ Evaluate the orthonormal basis at supplied coordinate. """ - return Lhat(z,d) + return unit_laguerre(z,d) class NormalParameter(Parameter): def __init__(self, pdata): @@ -203,7 +203,7 @@ def evalOrthoNormalBasis(self, z, d): """ Evaluate the orthonormal basis at supplied coordinate. """ - return Hhat(z, d) + return unit_hermite(z, d) class UniformParameter(Parameter): def __init__(self, pdata): @@ -238,7 +238,7 @@ def evalOrthoNormalBasis(self, z, d): """ Evaluate the orthonormal basis at supplied coordinate. """ - return Phat(z,d) + return unit_legendre(z,d) class HashableDict(dict): def __hash__(self): From 5bff86c0ed3f02a878426d76efd015c824fa39ae Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 3 Sep 2025 21:01:37 -0400 Subject: [PATCH 04/54] add consistency checks --- pspace/core.py | 76 ++++++++++++++++++++++++++++++++++ tests/test.py | 108 ++++++++++++++++++++++++++++++++++++------------- 2 files changed, 157 insertions(+), 27 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index fe91358..b60aae7 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -102,6 +102,53 @@ def getQuadraturePointsWeights(self, npoints): def evalOrthoNormalBasis(self, z, d): pass + def checkConsistency(self, max_degree=5, npoints=20, tol=1e-12, verbose=True): + """ + Check orthonormality of unit polynomials under quadrature. + + Parameters + ---------- + max_degree : int + Highest polynomial degree to check. + npoints : int + Number of quadrature points to use. + tol : float + Numerical tolerance for delta_{mn}. + verbose : bool + Print results if True. + + Returns + ------- + ok : bool + True if all checks pass within tolerance. + errors : list + List of (m,n,value) where error > tol. + """ + # 1D quadrature from this parameter + qmap = self.getQuadraturePointsWeights(npoints) + z = qmap['zq'] + w = qmap['wq'] + + errors = [] + ok = True + + # check inner products + for m in range(max_degree+1): + pm = self.evalOrthoNormalBasis(z, m) + for n in range(max_degree+1): + pn = self.evalOrthoNormalBasis(z, n) + ip = np.sum(pm*pn*w) # quadrature inner product + target = 1.0 if m == n else 0.0 + if abs(ip - target) > tol: + ok = False + errors.append((m, n, ip)) + if verbose: + print(f"Fail: = {ip:.6e} (expected {target})") + if verbose and ok: + print(f"[{self.__class__.__name__}] consistency check passed " + f"for degrees ≤ {max_degree} with {npoints} points.") + return ok, errors + class DeterministicParameter(Parameter): def __init__(self, pdata): super(DeterministicParameter, self).__init__(pdata) @@ -464,7 +511,36 @@ def Y(self, q, key='pid'): def evalOrthoNormalBasis(self, k, q): return self.psi(k, self.Z(q)) + from itertools import product + def getQuadraturePointsWeights(self, param_nqpts_map): + """ + Return a map of quadrature point index → quadrature data (Y,Z,W). + Works for arbitrary number of parameters. + """ + pids = list(param_nqpts_map.keys()) + nqpts = list(param_nqpts_map.values()) + + # fetch 1D quadrature maps for each parameter + maps = [self.getParameter(pid).getQuadraturePointsWeights(n) + for pid, n in zip(pids, nqpts)] + + qmap = {} + ctr = 0 + + # Cartesian product of index ranges + for idx_tuple in product(*[range(n) for n in nqpts]): + yvec, zvec, w = {}, {}, 1.0 + for pid, i, m in zip(pids, idx_tuple, maps): + yvec[pid] = m['yq'][i] + zvec[pid] = m['zq'][i] + w *= m['wq'][i] + qmap[ctr] = {'Y': yvec, 'Z': zvec, 'W': w} + ctr += 1 + return qmap + + + def getQuadraturePointsWeightsOld(self, param_nqpts_map): """ Return a map of k : qmap, where k is the global basis index """ diff --git a/tests/test.py b/tests/test.py index 6d55e48..b4d7315 100644 --- a/tests/test.py +++ b/tests/test.py @@ -4,24 +4,24 @@ import numpy as np from pspace.core import ParameterFactory, ParameterContainer -def univariate(dmax): +def univariate(dmax): # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=4.0, sigma=0.50), dmax[0]) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) pc.initialize() test(pc) - -def bivariate(dmax): + +def bivariate(dmax): # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=4.0, sigma=0.50), dmax[0]) k = pfactory.createExponentialParameter('k', dict(mu=4.0, beta=0.50), dmax[1]) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) @@ -29,14 +29,14 @@ def bivariate(dmax): pc.initialize() test(pc) - -def trivariate(dmax): + +def trivariate(dmax): # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=4.0, sigma=0.50), dmax[0]) k = pfactory.createExponentialParameter('k', dict(mu=4.0, beta=0.50), dmax[1]) m = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), dmax[2]) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) @@ -45,15 +45,15 @@ def trivariate(dmax): pc.initialize() test(pc) - -def quadvariate(dmax): + +def quadvariate(dmax): # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=4.0, sigma=0.50), dmax[0]) k = pfactory.createExponentialParameter('k', dict(mu=4.0, beta=0.50), dmax[1]) m = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), dmax[2]) d = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), dmax[3]) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) @@ -65,7 +65,7 @@ def quadvariate(dmax): test(pc) -def pentavariate(dmax): +def pentavariate(dmax): # Create "Parameter" using "Parameter Factory" object pfactory = ParameterFactory() c = pfactory.createNormalParameter('c', dict(mu=4.0, sigma=0.50), dmax[0]) @@ -73,7 +73,7 @@ def pentavariate(dmax): m = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), dmax[2]) d = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), dmax[3]) e = pfactory.createExponentialParameter('k', dict(mu=4.0, beta=0.50), dmax[4]) - + # Add "Parameter" into "ParameterContainer" pc = ParameterContainer() pc.addParameter(c) @@ -89,16 +89,16 @@ def pentavariate(dmax): def test(pc): # Test getting ND quadrature points N = pc.getNumStochasticBasisTerms() - - A = np.zeros((N, N)) + + A = np.zeros((N, N)) for i in range(N): - + dmapi = pc.basistermwise_parameter_degrees[i] param_nqpts_mapi = pc.getNumQuadraturePointsFromDegree(dmapi) - + for j in range(N): - + dmapj = pc.basistermwise_parameter_degrees[j] param_nqpts_mapj = pc.getNumQuadraturePointsFromDegree(dmapj) @@ -127,22 +127,76 @@ def gy(q,pid): pids = pc.getParameters().keys() for q in pc.quadrature_map.keys(): A[i,j] += w(q)*psiz(i,q)*psiz(j,q) - - assert(np.allclose(A, np.eye(A.shape[0])) == True) + + assert(np.allclose(A, np.eye(A.shape[0])) == True) def testall(nmax): for i in range(nmax): print (i, univariate([i+1])) for j in range(nmax): print (i, j, bivariate([i+1,j+1])) - for k in range(nmax): + for k in range(nmax): print (i, j, k, trivariate([i+1,j+1,k+1])) - for l in range(nmax): + for l in range(nmax): print (i, j, k, l, quadvariate([i+1,j+1,k+1, l+1])) - for m in range(nmax): + for m in range(nmax): print (i, j, k, l, m, pentavariate([i+1,j+1,k+1, l+1, m+1])) -if __name__ == '__main__': - - for n in range(5): - testall(n+1) +def test_standard_uniform(): + factory = ParameterFactory() + dist_params = {'a': 0.0, 'b': 1.0} # Uniform(0,1) + param = factory.createUniformParameter("u_std", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Standard Uniform consistency failed: {errors}" + print("Standard UniformParameter consistency check passed.") + +def test_nonstandard_uniform(): + factory = ParameterFactory() + dist_params = {'a': 2.0, 'b': 5.0} # Uniform(2,5) — non-standard + param = factory.createUniformParameter("u_nonstd", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Non-standard Uniform consistency failed: {errors}" + print("Non-standard UniformParameter consistency check passed.") + +def test_standard_normal(): + factory = ParameterFactory() + dist_params = {'mu': 0.0, 'sigma': 1.0} # Standard Normal N(0,1) + param = factory.createNormalParameter("n_std", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Standard Normal consistency failed: {errors}" + print("Standard NormalParameter consistency check passed.") + +def test_nonstandard_normal(): + factory = ParameterFactory() + dist_params = {'mu': 3.0, 'sigma': 2.0} # Non-standard Normal N(3,4) + param = factory.createNormalParameter("n_nonstd", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Non-standard Normal consistency failed: {errors}" + print("Non-standard NormalParameter consistency check passed.") + +def test_standard_exponential(): + factory = ParameterFactory() + dist_params = {'mu': 0.0, 'beta': 1.0} # Standard Exponential Exp(1) + param = factory.createExponentialParameter("e_std", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Standard Exponential consistency failed: {errors}" + print("Standard ExponentialParameter consistency check passed.") + +def test_nonstandard_exponential(): + factory = ParameterFactory() + dist_params = {'mu': 1.0, 'beta': 2.0} # Shifted/Scaled Exponential + param = factory.createExponentialParameter("e_nonstd", dist_params, monomial_degree=5) + ok, errors = param.checkConsistency(max_degree=4, npoints=10) + assert ok, f"Non-standard Exponential consistency failed: {errors}" + print("Non-standard ExponentialParameter consistency check passed.") + +if __name__ == "__main__": + test_standard_uniform() + test_nonstandard_uniform() + test_standard_normal() + test_nonstandard_normal() + test_standard_exponential() + test_nonstandard_exponential() + + #for n in range(5): + # testall(n+1) From df203469b26565ecb4a66f5b6ae0a72c7dfdda5a Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 3 Sep 2025 23:50:22 -0400 Subject: [PATCH 05/54] add consistency checks at container level --- pspace/core.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- tests/test.py | 20 +++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/pspace/core.py b/pspace/core.py index b60aae7..181f973 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -7,6 +7,7 @@ import math from collections import Counter from enum import Enum +from itertools import product # Local modules from .stochastic_utils import tensor_indices, nqpts, sparse @@ -102,6 +103,7 @@ def getQuadraturePointsWeights(self, npoints): def evalOrthoNormalBasis(self, z, d): pass + def checkConsistency(self, max_degree=5, npoints=20, tol=1e-12, verbose=True): """ Check orthonormality of unit polynomials under quadrature. @@ -511,7 +513,6 @@ def Y(self, q, key='pid'): def evalOrthoNormalBasis(self, k, q): return self.psi(k, self.Z(q)) - from itertools import product def getQuadraturePointsWeights(self, param_nqpts_map): """ @@ -990,3 +991,61 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): ## vdd[np.abs(vdd) < eps] = 0 return + + def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): + """ + Check orthonormality of the multivariate basis functions + under the container's quadrature. + + Parameters + ---------- + max_degree : int or None + Maximum polynomial degree to check. If None, uses + all available basis terms. + tol : float + Numerical tolerance for delta_{ij}. + verbose : bool + Print results if True. + + Returns + ------- + ok : bool + True if all checks pass within tolerance. + errors : list + List of (i,j,value) where error > tol. + """ + + # Ensure initialization + if not hasattr(self, "quadrature_map"): + nqpts_map = self.getNumQuadraturePoints() + self.initializeQuadrature(nqpts_map) + + nbasis = self.getNumStochasticBasisTerms() + if max_degree is not None: + nbasis = min(nbasis, max_degree+1) + + errors = [] + ok = True + + # Loop over basis indices + for i in range(nbasis): + for j in range(nbasis): + s = 0.0 + # Quadrature loop + for q in self.quadrature_map.keys(): + psi_i = self.evalOrthoNormalBasis(i,q) + psi_j = self.evalOrthoNormalBasis(j,q) + wq = self.W(q) + s += psi_i * psi_j * wq + target = 1.0 if i == j else 0.0 + if abs(s - target) > tol: + ok = False + errors.append((i, j, s)) + if verbose: + print(f"Fail: = {s:.6e} (expected {target})") + + if verbose and ok: + print(f"[ParameterContainer] consistency check passed " + f"for {nbasis} basis terms.") + + return ok, errors diff --git a/tests/test.py b/tests/test.py index b4d7315..3b8478b 100644 --- a/tests/test.py +++ b/tests/test.py @@ -190,6 +190,24 @@ def test_nonstandard_exponential(): assert ok, f"Non-standard Exponential consistency failed: {errors}" print("Non-standard ExponentialParameter consistency check passed.") +def test_container_consistency(): + factory = ParameterFactory() + pc = ParameterContainer() + + # Standard + non-standard parameters + pc.addParameter(factory.createUniformParameter("u", {"a":-1.0,"b":1.0}, monomial_degree=3)) + pc.addParameter(factory.createNormalParameter("n", {"mu":2.0,"sigma":1.5}, monomial_degree=3)) + pc.addParameter(factory.createExponentialParameter("e", {"mu":3.0,"beta":2.0}, monomial_degree=2)) + + # Initialize container + pc.initialize() + + # Run check + ok, errors = pc.checkConsistency(max_degree=5, tol=1e-10) + + assert ok, f"ParameterContainer consistency failed: {errors}" + print("ParameterContainer consistency check passed.") + if __name__ == "__main__": test_standard_uniform() test_nonstandard_uniform() @@ -198,5 +216,7 @@ def test_nonstandard_exponential(): test_standard_exponential() test_nonstandard_exponential() + test_container_consistency() + #for n in range(5): # testall(n+1) From 4e8961414d617e67a9f0dd0a8ee88aee641cc03e Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Thu, 4 Sep 2025 00:03:36 -0400 Subject: [PATCH 06/54] add consistency checks at container level with matrix print --- pspace/core.py | 42 +++++++++++++++++++++++++----------------- tests/test.py | 15 ++++++++------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 181f973..7ebf9f2 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -995,27 +995,29 @@ def projectInitCond(self, elem, v, vd, vdd, xpts): def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): """ Check orthonormality of the multivariate basis functions - under the container's quadrature. + under the container's quadrature. Also prints a table of + inner products for debugging. Parameters ---------- max_degree : int or None - Maximum polynomial degree to check. If None, uses - all available basis terms. + Maximum number of basis terms to check. If None, uses + all available basis terms. tol : float - Numerical tolerance for delta_{ij}. + Numerical tolerance for delta_{ij}. verbose : bool - Print results if True. + Print results if True. Returns ------- ok : bool - True if all checks pass within tolerance. + True if all checks pass within tolerance. errors : list - List of (i,j,value) where error > tol. + List of (i,j,value) where error > tol. + gram : np.ndarray + Inner product matrix (approximate identity). """ - - # Ensure initialization + # Ensure quadrature is initialized if not hasattr(self, "quadrature_map"): nqpts_map = self.getNumQuadraturePoints() self.initializeQuadrature(nqpts_map) @@ -1024,28 +1026,34 @@ def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): if max_degree is not None: nbasis = min(nbasis, max_degree+1) + gram = np.zeros((nbasis, nbasis)) errors = [] ok = True - # Loop over basis indices + # Build Gram matrix for i in range(nbasis): for j in range(nbasis): s = 0.0 - # Quadrature loop for q in self.quadrature_map.keys(): psi_i = self.evalOrthoNormalBasis(i,q) psi_j = self.evalOrthoNormalBasis(j,q) wq = self.W(q) s += psi_i * psi_j * wq + gram[i,j] = s target = 1.0 if i == j else 0.0 if abs(s - target) > tol: ok = False errors.append((i, j, s)) - if verbose: - print(f"Fail: = {s:.6e} (expected {target})") - if verbose and ok: - print(f"[ParameterContainer] consistency check passed " - f"for {nbasis} basis terms.") + if verbose: + print(f"[ParameterContainer] Gram matrix for {nbasis} basis terms:") + with np.printoptions(precision=3, suppress=True): + print(gram) - return ok, errors + if ok: + print(f"[ParameterContainer] consistency check passed " + f"for {nbasis} basis terms.") + else: + print(f"[ParameterContainer] FAILED: {len(errors)} inconsistencies found.") + + return ok, errors, gram diff --git a/tests/test.py b/tests/test.py index 3b8478b..4bea73e 100644 --- a/tests/test.py +++ b/tests/test.py @@ -194,25 +194,26 @@ def test_container_consistency(): factory = ParameterFactory() pc = ParameterContainer() - # Standard + non-standard parameters - pc.addParameter(factory.createUniformParameter("u", {"a":-1.0,"b":1.0}, monomial_degree=3)) + # Mix of standard and non-standard + pc.addParameter(factory.createUniformParameter("u", {"a":0.0,"b":1.0}, monomial_degree=3)) pc.addParameter(factory.createNormalParameter("n", {"mu":2.0,"sigma":1.5}, monomial_degree=3)) - pc.addParameter(factory.createExponentialParameter("e", {"mu":3.0,"beta":2.0}, monomial_degree=2)) + pc.addParameter(factory.createExponentialParameter("e", {"mu":1.0,"beta":0.5}, monomial_degree=2)) - # Initialize container pc.initialize() - # Run check - ok, errors = pc.checkConsistency(max_degree=5, tol=1e-10) + ok, errors, gram = pc.checkConsistency(max_degree=5, tol=1e-10, verbose=True) + ok, errors, gram = pc.checkConsistency(tol=1e-10, verbose=True) - assert ok, f"ParameterContainer consistency failed: {errors}" + assert ok, f"Container consistency failed with errors: {errors}" print("ParameterContainer consistency check passed.") if __name__ == "__main__": test_standard_uniform() test_nonstandard_uniform() + test_standard_normal() test_nonstandard_normal() + test_standard_exponential() test_nonstandard_exponential() From 3ae8492ccd754788490247e67030f17029416c0c Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 6 Sep 2025 17:07:39 -0400 Subject: [PATCH 07/54] moved jacobian formation to the probabilistic domain; adjusted test sparsity to create baseline --- pspace/core.py | 86 +++++++++++- tests/test_sparsity.py | 288 +++++++++-------------------------------- 2 files changed, 142 insertions(+), 232 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 7ebf9f2..1e379f8 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -2,19 +2,21 @@ from __future__ import print_function # External modules +import math + import numpy as np np.set_printoptions(precision=3,suppress=True) -import math + from collections import Counter -from enum import Enum -from itertools import product +from enum import Enum +from itertools import product # Local modules -from .stochastic_utils import tensor_indices, nqpts, sparse +from .stochastic_utils import tensor_indices, nqpts, sparse from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre from .orthogonal_polynomials import unit_laguerre -from .plotter import plot_jacobian, plot_vector +from .plotter import plot_jacobian, plot_vector def index(ii): return ii @@ -1057,3 +1059,77 @@ def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): print(f"[ParameterContainer] FAILED: {len(errors)} inconsistencies found.") return ok, errors, gram + + + def sparse(self, dmapi, dmapj, dmapf): + smap = {} + for key in dmapi.keys(): + if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: + smap[key] = True + else: + smap[key] = False + return smap + + def getSymmetricNonZeroIndices(self, dmapf): + nz = {} + N = self.getNumStochasticBasisTerms() + for i in range(N): + dmapi = self.basistermwise_parameter_degrees[i] + for j in range(i,N): + dmapj = self.basistermwise_parameter_degrees[j] + smap = self.sparse(dmapi, dmapj, dmapf) + if False not in smap.values(): + dmap = Counter() + dmap.update(dmapi) + dmap.update(dmapj) + dmap.update(dmapf) + nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) + nz[(i,j)] = nqpts_map + return nz + + def getSparseJacobian(self, f, dmapf): + # rename member functions for local readability + w = lambda q : self.W(q) + psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) + + nzs = self.getSymmetricNonZeroIndices(dmapf) + N = self.getNumStochasticBasisTerms() + A = np.zeros((N, N)) + for index, nqpts in nzs.items(): + self.initializeQuadrature(nqpts) + pids = self.getParameters().keys() + i = index[0] + j = index[1] + for q in self.quadrature_map.keys(): + val = w(q)*psiz(i,q)*psiz(j,q)*f(q) + A[i, j] += val + A[j, i] += val + return A + + def getJacobian(self, f, dmapf): + # rename member functions for local readability + w = lambda q : self.W(q) + psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) + + N = self.getNumStochasticBasisTerms() + A = np.zeros((N, N)) + for i in range(N): + dmapi = self.basistermwise_parameter_degrees[i] + for j in range(N): + dmapj = self.basistermwise_parameter_degrees[j] + + dmap = Counter() + dmap.update(dmapi) + dmap.update(dmapj) + dmap.update(dmapf) + + # add up the degree of both participating functions psizi + # and psizj to determine the total degree of integrand + nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) + self.initializeQuadrature(nqpts_map) + + # Loop quadrature points + pids = self.getParameters().keys() + for q in self.quadrature_map.keys(): + A[i,j] += w(q)*psiz(i,q)*psiz(j,q)*f(q) + return A diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index 5ac8c1f..c1c178d 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -1,250 +1,84 @@ +#=====================================================================# +# File to test jacobian matrix sparsity patterns +#=====================================================================# +# Author : Komahan Boopathy (komahan.boopathy@gmail.com) +#=====================================================================# + +# system/os modules import sys + from os import path sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +import os +outdir = "tests/baseline" +os.makedirs(outdir, exist_ok = True) + +# third party modules import numpy as np from collections import Counter -from timeit import default_timer as timer -from pspace.core import ParameterFactory, ParameterContainer +# local modules +from pspace.core import ParameterFactory +from pspace.core import ParameterContainer from pspace.plotter import plot_jacobian -## def sparsity(a, b): -## """ -## Nonzero entries occur when the parameterwise(variablewise) degree -## of function 'b' matches or exceeds the degree of the basis vector -## 'a'. -## """ -## akeys = a.keys() -## smap = {} -## for akey in akeys: -## if b[akey] - a[akey] <= 0: -## smap[akey] = True -## else: -## smap[akey] = False -## return smap - -def sparse(dmapi, dmapj, dmapf): - ## print '' - ## print '' - ## print 'map i ', dmapi - ## print 'map j ', dmapj - ## print 'map f ', dmapf - smap = {} - #print "keys", dmapi.keys() - for key in dmapi.keys(): - #print 'key', key, "diff", abs(dmapi[key] - dmapj[key]), dmapf[key] - if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: - smap[key] = True - else: - smap[key] = False - return smap - -def getSymmetricNonZeroIndices(pc, dmapf): - nz = {} - N = pc.getNumStochasticBasisTerms() - for i in range(N): - dmapi = pc.basistermwise_parameter_degrees[i] - for j in range(i,N): - dmapj = pc.basistermwise_parameter_degrees[j] - smap = sparse(dmapi, dmapj, dmapf) - if False not in smap.values(): - dmap = Counter() - dmap.update(dmapi) - dmap.update(dmapj) - dmap.update(dmapf) - nqpts_map = pc.getNumQuadraturePointsFromDegree(dmap) - nz[(i,j)] = nqpts_map - return nz - -def getSparseJacobian(pc, f, dmapf): - nzs = getSymmetricNonZeroIndices(pc, dmapf) - N = pc.getNumStochasticBasisTerms() - A = np.zeros((N, N)) - for index, nqpts in nzs.items(): - pc.initializeQuadrature(nqpts) - pids = pc.getParameters().keys() - i = index[0] - j = index[1] - for q in pc.quadrature_map.keys(): - val = w(q)*psiz(i,q)*psiz(j,q)*f(q) - A[i, j] += val - A[j, i] += val - return A - -def getJacobian(f, dmapf): - """ - """ - # Test getting ND quadrature points - N = pc.getNumStochasticBasisTerms() - A = np.zeros((N, N)) - - for i in range(N): - dmapi = pc.basistermwise_parameter_degrees[i] - - for j in range(N): - dmapj = pc.basistermwise_parameter_degrees[j] - - dmap = Counter() - dmap.update(dmapi) - dmap.update(dmapj) - dmap.update(dmapf) - - # add up the degree of both participating functions psizi - # and psizj to determine the total degree of integrand - nqpts_map = pc.getNumQuadraturePointsFromDegree(dmap) - pc.initializeQuadrature(nqpts_map) - # print dmap, nqpts_map - - # Loop quadrature points - pids = pc.getParameters().keys() - for q in pc.quadrature_map.keys(): - A[i,j] += w(q)*psiz(i,q)*psiz(j,q)*f(q) - return A - if __name__ == '__main__': - """ - """ - start = timer() - - # Create "Parameter" using "Parameter Factory" object + # Domain Definition(ADAPTIVE, FIXED={TENSOR, COMPLETE}) pfactory = ParameterFactory() - c = pfactory.createNormalParameter('c', dict(mu=-4.0, sigma=0.50), 4) - k = pfactory.createExponentialParameter('k', dict(mu=6.0, beta=1.0), 5) - m = pfactory.createUniformParameter('m', dict(a=-5.0, b=4.0), 6) - d = pfactory.createUniformParameter('d', dict(a=-5.0, b=4.0), 3) - # Add "Parameter" into "ParameterContainer" + # With adaptive enrichment we can keep the complexity (basis set + # size) tied to the intrinsic structure of the function to be + # decomposed in the probabilistic domain, not the worst-case + # degree cutoffs like 4, 5, 6 + + y1 = pfactory.createNormalParameter ('y1', dict(mu = -4.0, sigma = 0.5), 3) + y2 = pfactory.createExponentialParameter('y2', dict(mu = +6.0, beta = 1.0), 3) + y3 = pfactory.createUniformParameter ('y3', dict(a = -5.0, b = 4.0), 3) + + # Add "Parameter" into "ParameterContainer: create Axes in the Domain pc = ParameterContainer() - pc.addParameter(c) - pc.addParameter(k) - pc.addParameter(m) - pc.addParameter(d) - pc.initialize() + pc.addParameter(y1) + pc.addParameter(y2) + pc.addParameter(y3) - suffix = 'eee' + pc.initialize() - # rename for readability - w = lambda q : pc.W(q) - psiz = lambda i, q : pc.evalOrthoNormalBasis(i,q) + class Function: + def __init__(self, func, dmap, name): + self.func = func + self.name = name + self.dmap = dmap + return - def fy(q): + # find a better way to do this + def y(q, degree): ymap = pc.Y(q) - paramids = ymap.keys() - ans = 1.0 - for paramid in paramids: - ans = ans*ymap[paramid] - return ans + return ymap[degree] - def gy(q,pid): - ymap = pc.Y(q) - return ymap[pid] + def generate_baseline(F : Function): + A = pc.getJacobian(F.func, F.dmap) + np.save('tests/baseline/matrix-full-assembly-' + F.name + '-.npy', A) + + A = pc.getSparseJacobian(func, dmap) + np.save('tests/baseline/matrix-sparse-assembly-' + F.name + '-.npy', A) - # Default map for number of quadrature points + # constant: y0^0 * y1^0 * y2^0 + func = lambda q : y(q,0) * y(q,1) * y(q,2) dmap = Counter() + dmap[0] = 0; dmap[1] = 0; dmap[2] = 0; + constant_function = Function(func, dmap, "y1^0y2^0y3^0") - # y_1^4 - func = lambda q : gy(q,1)*gy(q,1)*gy(q,1)*gy(q,1) - dmap[0] = 0; dmap[1] = 4; dmap[2] = 0 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y3^4-' + suffix + '.pdf') - - # Nonzero constant - func = lambda q : 1.0 - dmap[0] = 0 ; dmap[1] = 0; dmap[2] = 0 - #A = getJacobian(func, dmap) - A = getSparseJacobian(pc, func, dmap) - plot_jacobian(A, 'sparsity-identity-' + suffix + '.pdf') - - # Linear in y_1 - func = lambda q : gy(q,0) - dmap[0] = 1; dmap[1] = 0; dmap[2] = 0 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y1-' + suffix + '.pdf') - - # Linear in y_2 - func = lambda q : gy(q,1) - dmap[0] = 0; dmap[1] = 1; dmap[2] = 0 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y2-' + suffix + '.pdf') - - # Linear in y_3 - func = lambda q : gy(q,2) - dmap[0] = 0; dmap[1] = 0; dmap[2] = 1 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y3-' + suffix + '.pdf') - - # y_1 + y_2 + y_3 - func = lambda q : gy(q,0) + gy(q,1) + gy(q,2) + # linear : define: y0^1 + y1^1 + y2^1 + dmap = Counter() dmap[0] = 1; dmap[1] = 1; dmap[2] = 1 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y1+y2+y3-' + suffix + '.pdf') - - # y_1 * y_2 - func = lambda q : gy(q,0) * gy(q,1) - dmap[0] = 1; dmap[1] = 1; dmap[2] = 0 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y1y2-' + suffix + '.pdf') - - # y_2 * y_3 - func = lambda q : gy(q,1) * gy(q,2) - dmap[0] = 0; dmap[1] = 1; dmap[2] = 1 - #A = getJacobian(func, dmap) - A = getSparseJacobian(pc, func, dmap) - plot_jacobian(A, 'sparsity-y2y3-' + suffix + '.pdf') - - # y_1 * y_3 - func = lambda q : gy(q,0) * gy(q,2) - dmap[0] = 1; dmap[1] = 0; dmap[2] = 1 - #A = getJacobian(func, dmap) - A = getSparseJacobian(pc, func, dmap) - plot_jacobian(A, 'sparsity-y1y3-' + suffix + '.pdf') - - # y_1^2 - func = lambda q : gy(q,0)*gy(q,0) - dmap[0] = 2; dmap[1] = 0; dmap[2] = 0 - #A = getJacobian(func, dmap) - A = getSparseJacobian(pc, func, dmap) - plot_jacobian(A, 'sparsity-y1^2-' + suffix + '.pdf') - - # y_2^2 - func = lambda q : gy(q,1)*gy(q,1) - dmap[0] = 0; dmap[1] = 2; dmap[2] = 0 - #A = getJacobian(func, dmap) - A = getSparseJacobian(pc, func, dmap) - plot_jacobian(A, 'sparsity-y2^2-' + suffix + '.pdf') - - # y_3^2 - func = lambda q : gy(q,2)*gy(q,2) - dmap[0] = 0; dmap[1] = 0; dmap[2] = 2 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y3^2-' + suffix + '.pdf') - - # y_3^4 - func = lambda q : gy(q,2)*gy(q,2)*gy(q,2) + gy(q,2)*gy(q,2)*gy(q,2)*gy(q,2) - dmap[0] = 0; dmap[1] = 0; dmap[2] = 4 - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y3^4-' + suffix + '.pdf') - - # y_1 * y_2 * y_3*y_4 - func = lambda q : gy(q,0) * gy(q,1) * gy(q,2) * gy(q,3) - dmap[0] = 1; dmap[1] = 1; dmap[2] = 1; dmap[3] = 1 - print('first') - A = getSparseJacobian(pc, func, dmap) - print('second') - A = getSparseJacobian(pc, func, dmap) - print('third') - A = getSparseJacobian(pc, func, dmap) - #A = getJacobian(func, dmap) - plot_jacobian(A, 'sparsity-y1y2y3y4-' + suffix + '.pdf') - end = timer() - print("elapsed time:", end - start) + func = lambda q : y(q,0) + y(q,1) + y(q,2) + linear_function = Function(func, dmap, "y1^1+y2^1+y3^1") + + # quadratic : define: y0^2 + y1^2 + y2^2 + dmap = Counter() + dmap[0] = 2; dmap[1] = 2; dmap[2] = 2 + func = lambda q : y(q,0) + y(q,1) + y(q,2) + quadratic_function = Function(func, dmap, "y1^2+y2^2+y3^2") + generate_baseline(quadratic_function) From 962991363c1fe677571310ab54f9ff6ca428e32b Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 6 Sep 2025 19:10:46 -0400 Subject: [PATCH 08/54] fix tests, remove old quadrature map construction, --- pspace/core.py | 221 +---------------------------------------- tests/test_sparsity.py | 8 +- 2 files changed, 8 insertions(+), 221 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 1e379f8..66b00a0 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -518,8 +518,8 @@ def evalOrthoNormalBasis(self, k, q): def getQuadraturePointsWeights(self, param_nqpts_map): """ - Return a map of quadrature point index → quadrature data (Y,Z,W). - Works for arbitrary number of parameters. + Return a map of quadrature point index : quadrature data (Y,Z,W). + Works for arbitrary number of random variables. """ pids = list(param_nqpts_map.keys()) nqpts = list(param_nqpts_map.values()) @@ -528,10 +528,9 @@ def getQuadraturePointsWeights(self, param_nqpts_map): maps = [self.getParameter(pid).getQuadraturePointsWeights(n) for pid, n in zip(pids, nqpts)] + # Cartesian product of index ranges qmap = {} ctr = 0 - - # Cartesian product of index ranges for idx_tuple in product(*[range(n) for n in nqpts]): yvec, zvec, w = {}, {}, 1.0 for pid, i, m in zip(pids, idx_tuple, maps): @@ -540,220 +539,8 @@ def getQuadraturePointsWeights(self, param_nqpts_map): w *= m['wq'][i] qmap[ctr] = {'Y': yvec, 'Z': zvec, 'W': w} ctr += 1 - return qmap - - - def getQuadraturePointsWeightsOld(self, param_nqpts_map): - """ - Return a map of k : qmap, where k is the global basis index - """ - params = list(param_nqpts_map.keys()) - nqpts = list(param_nqpts_map.values()) - - # exclude deterministic terms? - total_quadrature_points = int(np.prod(nqpts)) - num_vars = len(params) - - # Initialize map with empty values corresponding to each key - qmap = {} - for key in range(total_quadrature_points): - qmap[key] = [] - - if num_vars == 1: - - # Get 1d-quadrature maps - map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) - pid0 = self.getParameter(0).getParameterID() - - # Tensor product of 1D-quadrature to get N-D quadrature - ctr = 0 - - for i0 in range(nqpts[0]): - - yvec = { pid0 : map0['yq'][i0] } - - zvec = { pid0 : map0['zq'][i0] } - - w = map0['wq'][i0] - - data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - - qmap[ctr] = data - - ctr += 1 - - - return qmap - - - elif num_vars == 2: - - # Get 1d-quadrature maps - map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) - map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) - - pid0 = self.getParameter(0).getParameterID() - pid1 = self.getParameter(1).getParameterID() - - # Tensor product of 1D-quadrature to get N-D quadrature - ctr = 0 - - for i0 in range(nqpts[0]): - for i1 in range(nqpts[1]): - - yvec = { pid0 : map0['yq'][i0], - pid1 : map1['yq'][i1] } - - zvec = { pid0 : map0['zq'][i0], - pid1 : map1['zq'][i1] } - - ## wvec = { pid0 : map0['wq'][i0], - ## pid1 : map1['wq'][i1], - ## pid2 : map2['wq'][i2] } - - w = map0['wq'][i0]*map1['wq'][i1] - - data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - - qmap[ctr] = data - - ctr += 1 - - # Check if the sum of weights is one - - return qmap - - elif num_vars == 3: - # Get 1d-quadrature maps - map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) - map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) - map2 = self.getParameter(2).getQuadraturePointsWeights(nqpts[2]) - - pid0 = self.getParameter(0).getParameterID() - pid1 = self.getParameter(1).getParameterID() - pid2 = self.getParameter(2).getParameterID() - - # Tensor product of 1D-quadrature to get N-D quadrature - ctr = 0 - - for i0 in range(nqpts[0]): - for i1 in range(nqpts[1]): - for i2 in range(nqpts[2]): - - yvec = { pid0 : map0['yq'][i0], - pid1 : map1['yq'][i1], - pid2 : map2['yq'][i2] } - - zvec = { pid0 : map0['zq'][i0], - pid1 : map1['zq'][i1], - pid2 : map2['zq'][i2] } - - ## wvec = { pid0 : map0['wq'][i0], - ## pid1 : map1['wq'][i1], - ## pid2 : map2['wq'][i2] } - - w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2] - - data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - - qmap[ctr] = data - - ctr += 1 - - # Check if the sum of weights is one - - return qmap - - elif num_vars == 4: - - # Get 1d-quadrature maps - map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) - map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) - map2 = self.getParameter(2).getQuadraturePointsWeights(nqpts[2]) - map3 = self.getParameter(3).getQuadraturePointsWeights(nqpts[3]) - - pid0 = self.getParameter(0).getParameterID() - pid1 = self.getParameter(1).getParameterID() - pid2 = self.getParameter(2).getParameterID() - pid3 = self.getParameter(3).getParameterID() - - # Tensor product of 1D-quadrature to get N-D quadrature - ctr = 0 - - for i0 in range(nqpts[0]): - for i1 in range(nqpts[1]): - for i2 in range(nqpts[2]): - for i3 in range(nqpts[3]): - - yvec = { pid0 : map0['yq'][i0], - pid1 : map1['yq'][i1], - pid2 : map2['yq'][i2], - pid3 : map3['yq'][i3]} - - - zvec = { pid0 : map0['zq'][i0], - pid1 : map1['zq'][i1], - pid2 : map2['zq'][i2], - pid3 : map3['zq'][i3]} - - w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2]*map3['wq'][i3] - - data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - - qmap[ctr] = data - - ctr += 1 - - # Check if the sum of weights is one - - return qmap - - elif num_vars == 5: - - # Get 1d-quadrature maps - map0 = self.getParameter(0).getQuadraturePointsWeights(nqpts[0]) - map1 = self.getParameter(1).getQuadraturePointsWeights(nqpts[1]) - map2 = self.getParameter(2).getQuadraturePointsWeights(nqpts[2]) - map3 = self.getParameter(3).getQuadraturePointsWeights(nqpts[3]) - map4 = self.getParameter(4).getQuadraturePointsWeights(nqpts[4]) - - pid0 = self.getParameter(0).getParameterID() - pid1 = self.getParameter(1).getParameterID() - pid2 = self.getParameter(2).getParameterID() - pid3 = self.getParameter(3).getParameterID() - pid4 = self.getParameter(4).getParameterID() - - # Tensor product of 1D-quadrature to get N-D quadrature - ctr = 0 - - for i0 in range(nqpts[0]): - for i1 in range(nqpts[1]): - for i2 in range(nqpts[2]): - for i3 in range(nqpts[3]): - for i4 in range(nqpts[4]): - - yvec = { pid0 : map0['yq'][i0], - pid1 : map1['yq'][i1], - pid2 : map2['yq'][i2], - pid3 : map3['yq'][i3], - pid4 : map4['yq'][i4]} - - - zvec = { pid0 : map0['zq'][i0], - pid1 : map1['zq'][i1], - pid2 : map2['zq'][i2], - pid3 : map3['zq'][i3], - pid4 : map4['zq'][i4]} - - w = map0['wq'][i0]*map1['wq'][i1]*map2['wq'][i2]*map3['wq'][i3]*map4['wq'][i4] - - data = {'Y' : yvec, 'Z' : zvec, 'W' : w} - - qmap[ctr] = data - - ctr += 1 - return qmap + return qmap def projectResidual(self, elem, time, res, X, v, dv, ddv): """ diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index c1c178d..7fbae2c 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -59,10 +59,10 @@ def y(q, degree): def generate_baseline(F : Function): A = pc.getJacobian(F.func, F.dmap) - np.save('tests/baseline/matrix-full-assembly-' + F.name + '-.npy', A) + np.save(os.path.join(outdir, f"matrix-full-assembly-{F.name}.npy"), A) - A = pc.getSparseJacobian(func, dmap) - np.save('tests/baseline/matrix-sparse-assembly-' + F.name + '-.npy', A) + A = pc.getSparseJacobian(F.func, F.dmap) + np.save(os.path.join(outdir, f"matrix-sparse-assembly-{F.name}.npy"), A) # constant: y0^0 * y1^0 * y2^0 func = lambda q : y(q,0) * y(q,1) * y(q,2) @@ -79,6 +79,6 @@ def generate_baseline(F : Function): # quadratic : define: y0^2 + y1^2 + y2^2 dmap = Counter() dmap[0] = 2; dmap[1] = 2; dmap[2] = 2 - func = lambda q : y(q,0) + y(q,1) + y(q,2) + func = lambda q : y(q,0)**2 + y(q,1)**2 + y(q,2)**2 quadratic_function = Function(func, dmap, "y1^2+y2^2+y3^2") generate_baseline(quadratic_function) From 4e3967a118b995683f2d1850434792a49f7588a7 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 6 Sep 2025 19:16:25 -0400 Subject: [PATCH 09/54] move finite element projection to separate module --- pspace/core.py | 239 ------------------------------------------------- 1 file changed, 239 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 66b00a0..22bf9a1 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -542,245 +542,6 @@ def getQuadraturePointsWeights(self, param_nqpts_map): return qmap - def projectResidual(self, elem, time, res, X, v, dv, ddv): - """ - Project the elements deterministic residual onto stochastic - basis and place in global stochastic residual array - """ - - # size of deterministic element state vector - ndisps = elem.numDisplacements() - nnodes = elem.numNodes() - nddof = ndisps*nnodes - nsdof = ndisps*self.getNumStochasticBasisTerms() - - for i in range(self.getNumStochasticBasisTerms()): - - # Initialize quadrature with number of gauss points - # necessary for i-th basis entry - self.initializeQuadrature( - self.getNumQuadraturePointsFromDegree( - self.basistermwise_parameter_degrees[i] - ) - ) - - # Quadrature Loop - rtmp = np.zeros((nddof)) - - for q in self.quadrature_map.keys(): - - # Set the parameter values into the element - elem.setParameters(self.Y(q,'name')) - - # Create space for fetching deterministic residual - # vector - resq = np.zeros((nddof)) - uq = np.zeros((nddof)) - udq = np.zeros((nddof)) - uddq = np.zeros((nddof)) - - # Obtain states at quadrature nodes - for k in range(self.num_terms): - psiky = self.evalOrthoNormalBasis(k,q) - uq[:] += v[k*nddof:(k+1)*nddof]*psiky - udq[:] += dv[k*nddof:(k+1)*nddof]*psiky - uddq[:] += ddv[k*nddof:(k+1)*nddof]*psiky - - # Fetch the deterministic element residual - elem.addResidual(time, resq, X, uq, udq, uddq) - - # Project the determinic element residual onto the - # stochastic basis and place in global residual array - psiq = self.evalOrthoNormalBasis(i,q) - alphaq = self.W(q) - rtmp[:] += resq*psiq*alphaq - - # Distribute the residual - for ii in range(nnodes): - # Local indices - listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps - gistart = index(ii)*nsdof + i*ndisps - giend = index(ii)*nsdof + (i+1)*ndisps - - # Place in global residul array node by node - #print(gistart, giend, listart, liend) - res[gistart:giend] += rtmp[listart:liend] - - # print("res=", res) - # plot_vector(res, 'stochatic-element-residual.pdf', normalize=True, precision=1.0e-6) - - return - - def projectJacobian(self, - elem, - time, J, alpha, beta, gamma, - X, v, dv, ddv): - """ - Project the elements deterministic jacobian matrix onto - stochastic basis and place in global stochastic jacobian matrix - """ - # All stochastic parameters are assumed to be of degree 1 - # (constant terms) - dmapf = Counter() - for pid in self.parameter_map.keys(): - dmapf[pid] = 1 - - # size of deterministic element state vector - ndisps = elem.numDisplacements() - nnodes = elem.numNodes() - nddof = ndisps*nnodes - nsdof = ndisps*self.getNumStochasticBasisTerms() - - for i in range(self.getNumStochasticBasisTerms()): - imap = self.basistermwise_parameter_degrees[i] - - for j in range(self.getNumStochasticBasisTerms()): - jmap = self.basistermwise_parameter_degrees[j] - - smap = sparse(imap, jmap, dmapf) - - if False not in smap.values(): - - dmap = Counter() - dmap.update(imap) - dmap.update(jmap) - dmap.update(dmapf) - nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) - - # Initialize quadrature with number of gauss points - # necessary for i,j-th jacobian entry - self.initializeQuadrature(nqpts_map) - - jtmp = np.zeros((nddof,nddof)) - - # Quadrature Loop - for q in self.quadrature_map.keys(): - - try: - elem.setParameters(self.Y(q,'name')) - except: - print('exception') - raise - - # Create space for fetching deterministic - # jacobian, and state vectors that go as input - Aq = np.zeros((nddof,nddof)) - uq = np.zeros((nddof)) - udq = np.zeros((nddof)) - uddq = np.zeros((nddof)) - for k in range(self.num_terms): - psiky = self.evalOrthoNormalBasis(k,q) - uq[:] += v[k*nddof:(k+1)*nddof]*psiky - udq[:] += dv[k*nddof:(k+1)*nddof]*psiky - uddq[:] += ddv[k*nddof:(k+1)*nddof]*psiky - - # Fetch the deterministic element jacobian matrix - elem.addJacobian(time, Aq, alpha, beta, gamma, X, uq, udq, uddq) - - # Project the determinic element jacobian onto the - # stochastic basis and place in the global matrix - psiziw = self.W(q)*self.evalOrthoNormalBasis(i,q) - psizjw = self.evalOrthoNormalBasis(j,q) - jtmp[:,:] += Aq*psiziw*psizjw - - # Distribute blocks (16 times) - for ii in range(0,nnodes): - for jj in range(0,nnodes): - - # Local indices - listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps - ljstart = index(jj)*ndisps - ljend = (index(jj)+1)*ndisps - - gistart = index(ii)*nsdof + i*ndisps - giend = index(ii)*nsdof + (i+1)*ndisps - gjstart = index(jj)*nsdof + j*ndisps - gjend = index(jj)*nsdof + (j+1)*ndisps - - if i == j: - J[gistart:giend, gjstart:gjend] += jtmp[listart:liend, ljstart:ljend] - else: - J[gistart:giend, gjstart:gjend] += jtmp[listart:liend, ljstart:ljend] - #J[gjstart:gjend, gistart:giend] += jtmp[listart:liend, ljstart:ljend] - - #print("J=", J) - #plot_jacobian(J, 'stochatic-element-block.pdf', normalize=True, precision=1.0e-6) - - return - - def projectInitCond(self, elem, v, vd, vdd, xpts): - """ - Project the elements deterministic initial condition onto - stochastic basis and place in global stochastic init condition - array - """ - - # size of deterministic element state vector - ndisps = elem.numDisplacements() - nnodes = elem.numNodes() - nddof = ndisps*nnodes - nsdof = ndisps*self.getNumStochasticBasisTerms() - - for k in range(self.getNumStochasticBasisTerms()): - - # Initialize quadrature with number of gauss points - # necessary for k-th basis entry - self.initializeQuadrature( - self.getNumQuadraturePointsFromDegree( - self.basistermwise_parameter_degrees[k] - ) - ) - - # Quadrature Loop - utmp = np.zeros((nddof)) - udtmp = np.zeros((nddof)) - uddtmp = np.zeros((nddof)) - for q in self.quadrature_map.keys(): - - # Set the paramter values into the element - elem.setParameters(self.Y(q,'name')) - - # Create space for fetching deterministic initial - # conditions - uq = np.zeros((nddof)) - udq = np.zeros((nddof)) - uddq = np.zeros((nddof)) - - # Fetch the deterministic initial conditions - elem.getInitConditions(uq, udq, uddq, xpts) - - # Project the determinic initial conditions onto the - # stochastic basis - psizkw = self.W(q)*self.evalOrthoNormalBasis(k,q) - utmp += uq*psizkw - udtmp += udq*psizkw - uddtmp += uddq*psizkw - - # Distribute values - for ii in range(nnodes): - # Local indices - listart = index(ii)*ndisps - liend = (index(ii)+1)*ndisps - gistart = index(ii)*nsdof + k*ndisps - giend = index(ii)*nsdof + (k+1)*ndisps - - # Place in initial condition array node after node - v[gistart:giend] += utmp[listart:liend] - vd[gistart:giend] += udtmp[listart:liend] - vdd[gistart:giend] += uddtmp[listart:liend] - - ## # Replace numbers less than machine precision with zero to - ## # avoid numerical issues - ## if clean is True: - ## eps = np.finfo(np.float).eps - ## v[np.abs(v) < eps] = 0 - ## vd[np.abs(vd) < eps] = 0 - ## vdd[np.abs(vdd) < eps] = 0 - - return - def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): """ Check orthonormality of the multivariate basis functions From 53e94e362c42d788f26008cda644eee68c95a183 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 6 Sep 2025 19:16:38 -0400 Subject: [PATCH 10/54] move finite element projection to separate module --- pspace/stochastic_element.py | 238 +++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 pspace/stochastic_element.py diff --git a/pspace/stochastic_element.py b/pspace/stochastic_element.py new file mode 100644 index 0000000..b90299f --- /dev/null +++ b/pspace/stochastic_element.py @@ -0,0 +1,238 @@ +def projectResidual(self, elem, time, res, X, v, dv, ddv): + """ + Project the elements deterministic residual onto stochastic + basis and place in global stochastic residual array + """ + + # size of deterministic element state vector + ndisps = elem.numDisplacements() + nnodes = elem.numNodes() + nddof = ndisps*nnodes + nsdof = ndisps*self.getNumStochasticBasisTerms() + + for i in range(self.getNumStochasticBasisTerms()): + + # Initialize quadrature with number of gauss points + # necessary for i-th basis entry + self.initializeQuadrature( + self.getNumQuadraturePointsFromDegree( + self.basistermwise_parameter_degrees[i] + ) + ) + + # Quadrature Loop + rtmp = np.zeros((nddof)) + + for q in self.quadrature_map.keys(): + + # Set the parameter values into the element + elem.setParameters(self.Y(q,'name')) + + # Create space for fetching deterministic residual + # vector + resq = np.zeros((nddof)) + uq = np.zeros((nddof)) + udq = np.zeros((nddof)) + uddq = np.zeros((nddof)) + + # Obtain states at quadrature nodes + for k in range(self.num_terms): + psiky = self.evalOrthoNormalBasis(k,q) + uq[:] += v[k*nddof:(k+1)*nddof]*psiky + udq[:] += dv[k*nddof:(k+1)*nddof]*psiky + uddq[:] += ddv[k*nddof:(k+1)*nddof]*psiky + + # Fetch the deterministic element residual + elem.addResidual(time, resq, X, uq, udq, uddq) + + # Project the determinic element residual onto the + # stochastic basis and place in global residual array + psiq = self.evalOrthoNormalBasis(i,q) + alphaq = self.W(q) + rtmp[:] += resq*psiq*alphaq + + # Distribute the residual + for ii in range(nnodes): + # Local indices + listart = index(ii)*ndisps + liend = (index(ii)+1)*ndisps + gistart = index(ii)*nsdof + i*ndisps + giend = index(ii)*nsdof + (i+1)*ndisps + + # Place in global residul array node by node + #print(gistart, giend, listart, liend) + res[gistart:giend] += rtmp[listart:liend] + + # print("res=", res) + # plot_vector(res, 'stochatic-element-residual.pdf', normalize=True, precision=1.0e-6) + + return + +def projectJacobian(self, + elem, + time, J, alpha, beta, gamma, + X, v, dv, ddv): + """ + Project the elements deterministic jacobian matrix onto + stochastic basis and place in global stochastic jacobian matrix + """ + # All stochastic parameters are assumed to be of degree 1 + # (constant terms) + dmapf = Counter() + for pid in self.parameter_map.keys(): + dmapf[pid] = 1 + + # size of deterministic element state vector + ndisps = elem.numDisplacements() + nnodes = elem.numNodes() + nddof = ndisps*nnodes + nsdof = ndisps*self.getNumStochasticBasisTerms() + + for i in range(self.getNumStochasticBasisTerms()): + imap = self.basistermwise_parameter_degrees[i] + + for j in range(self.getNumStochasticBasisTerms()): + jmap = self.basistermwise_parameter_degrees[j] + + smap = sparse(imap, jmap, dmapf) + + if False not in smap.values(): + + dmap = Counter() + dmap.update(imap) + dmap.update(jmap) + dmap.update(dmapf) + nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) + + # Initialize quadrature with number of gauss points + # necessary for i,j-th jacobian entry + self.initializeQuadrature(nqpts_map) + + jtmp = np.zeros((nddof,nddof)) + + # Quadrature Loop + for q in self.quadrature_map.keys(): + + try: + elem.setParameters(self.Y(q,'name')) + except: + print('exception') + raise + + # Create space for fetching deterministic + # jacobian, and state vectors that go as input + Aq = np.zeros((nddof,nddof)) + uq = np.zeros((nddof)) + udq = np.zeros((nddof)) + uddq = np.zeros((nddof)) + for k in range(self.num_terms): + psiky = self.evalOrthoNormalBasis(k,q) + uq[:] += v[k*nddof:(k+1)*nddof]*psiky + udq[:] += dv[k*nddof:(k+1)*nddof]*psiky + uddq[:] += ddv[k*nddof:(k+1)*nddof]*psiky + + # Fetch the deterministic element jacobian matrix + elem.addJacobian(time, Aq, alpha, beta, gamma, X, uq, udq, uddq) + + # Project the determinic element jacobian onto the + # stochastic basis and place in the global matrix + psiziw = self.W(q)*self.evalOrthoNormalBasis(i,q) + psizjw = self.evalOrthoNormalBasis(j,q) + jtmp[:,:] += Aq*psiziw*psizjw + + # Distribute blocks (16 times) + for ii in range(0,nnodes): + for jj in range(0,nnodes): + + # Local indices + listart = index(ii)*ndisps + liend = (index(ii)+1)*ndisps + ljstart = index(jj)*ndisps + ljend = (index(jj)+1)*ndisps + + gistart = index(ii)*nsdof + i*ndisps + giend = index(ii)*nsdof + (i+1)*ndisps + gjstart = index(jj)*nsdof + j*ndisps + gjend = index(jj)*nsdof + (j+1)*ndisps + + if i == j: + J[gistart:giend, gjstart:gjend] += jtmp[listart:liend, ljstart:ljend] + else: + J[gistart:giend, gjstart:gjend] += jtmp[listart:liend, ljstart:ljend] + #J[gjstart:gjend, gistart:giend] += jtmp[listart:liend, ljstart:ljend] + + #print("J=", J) + #plot_jacobian(J, 'stochatic-element-block.pdf', normalize=True, precision=1.0e-6) + + return + +def projectInitCond(self, elem, v, vd, vdd, xpts): + """ + Project the elements deterministic initial condition onto + stochastic basis and place in global stochastic init condition + array + """ + + # size of deterministic element state vector + ndisps = elem.numDisplacements() + nnodes = elem.numNodes() + nddof = ndisps*nnodes + nsdof = ndisps*self.getNumStochasticBasisTerms() + + for k in range(self.getNumStochasticBasisTerms()): + + # Initialize quadrature with number of gauss points + # necessary for k-th basis entry + self.initializeQuadrature( + self.getNumQuadraturePointsFromDegree( + self.basistermwise_parameter_degrees[k] + ) + ) + + # Quadrature Loop + utmp = np.zeros((nddof)) + udtmp = np.zeros((nddof)) + uddtmp = np.zeros((nddof)) + for q in self.quadrature_map.keys(): + + # Set the paramter values into the element + elem.setParameters(self.Y(q,'name')) + + # Create space for fetching deterministic initial + # conditions + uq = np.zeros((nddof)) + udq = np.zeros((nddof)) + uddq = np.zeros((nddof)) + + # Fetch the deterministic initial conditions + elem.getInitConditions(uq, udq, uddq, xpts) + + # Project the determinic initial conditions onto the + # stochastic basis + psizkw = self.W(q)*self.evalOrthoNormalBasis(k,q) + utmp += uq*psizkw + udtmp += udq*psizkw + uddtmp += uddq*psizkw + + # Distribute values + for ii in range(nnodes): + # Local indices + listart = index(ii)*ndisps + liend = (index(ii)+1)*ndisps + gistart = index(ii)*nsdof + k*ndisps + giend = index(ii)*nsdof + (k+1)*ndisps + + # Place in initial condition array node after node + v[gistart:giend] += utmp[listart:liend] + vd[gistart:giend] += udtmp[listart:liend] + vdd[gistart:giend] += uddtmp[listart:liend] + + ## # Replace numbers less than machine precision with zero to + ## # avoid numerical issues + ## if clean is True: + ## eps = np.finfo(np.float).eps + ## v[np.abs(v) < eps] = 0 + ## vd[np.abs(vd) < eps] = 0 + ## vdd[np.abs(vdd) < eps] = 0 + + return From cce9be9d6e4e4443b8b8e2c5d5641d31611dbcf2 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Mon, 8 Sep 2025 19:27:05 -0400 Subject: [PATCH 11/54] core draft --- pspace/core.py | 714 ++++++++++--------------------------------------- 1 file changed, 141 insertions(+), 573 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 22bf9a1..58f9da2 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -1,5 +1,15 @@ #!/usr/bin/env python -from __future__ import print_function + +#=====================================================================# +# ABSTRACT MATHEMATICAL ANALYSIS MODULE FOR STOCHASTIC PARTIAL +# DIFFERENTIAL EQUATIONS +#=====================================================================# +# X DOMAINS (PROBABILISTIC, SPATIAL, TEMPORAL) +# XX DIMENSIONS (AXES) with PROBABILITY DISTRIBUTIONS +# XXX MODES (BASIS FUNCTIONS AND QUADRATURES) +#=====================================================================# +# Author : Komahan Boopathy (komahan.boopathy@gmail.com) +#=====================================================================# # External modules import math @@ -12,191 +22,72 @@ from itertools import product # Local modules -from .stochastic_utils import tensor_indices, nqpts, sparse +from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre from .orthogonal_polynomials import unit_laguerre -from .plotter import plot_jacobian, plot_vector - -def index(ii): - return ii - if ii == 0: - return 0 - if ii == 1: - return 1 - if ii == 2: - return 3 - if ii == 3: - return 2 - -## TODO -# Parameters are Monomials -# Deterministic parameters are constant monomials (degree 0) -# Probabilistic parameters are highest degree monomials - -class ParameterType(Enum): + +class CoordinateType(Enum): """ - Enumeration for types of parameters. Assumption is that any - parameter that you create will either be 'deterministic' or - 'probabilistic' in nature. + DOMAIN TYPES """ - DETERMINISTIC = 1 - PROBABILISTIC = 2 + PROBABILISTIC = 1 + SPATIAL = 2 + TEMPORAL = 3 class DistributionType(Enum): """ - Enumeration of probability distribution types. - """ - NONE = 0 - NORMAL = 1 - UNIFORM = 2 - EXPONENTIAL = 3 - POISSON = 4 - BINORMAL = 5 - -class Parameter(object): + GEOMETRY: DENSITY DISTRIBUTION """ - A hashable parameter object wrapping information about the parameter - used in computations. Hashable implies being able to serve as keys - dictionaries. - - Author: Komahan Boopathy + NORMAL = 0 + UNIFORM = 1 + EXPONENTIAL = 2 + POISSON = 3 + BINORMAL = 4 +class BasisFunctionType(Enum): """ - def __init__(self, pdata): - self.param_id = pdata['param_id'] - self.param_name = pdata['param_name'] - self.param_type = pdata['param_type'] - self.dist_type = pdata['dist_type'] - self.monomial_degree = pdata['monomial_degree'] - return + VECTOR-SPACE CONSTRUCTION METHODS + """ + TENSOR_DEGREE = 0 + TOTAL_DEGREE = 1 + ADAPTIVE_DEGREE = 2 + +class Coordinate(object): + def __init__(self, coord_data): + self.id = coord_data['coord_id'] + self.name = coord_data['coord_name'] + self.type = coord_data['coord_type'] + self.distribution = coord_data['dist_type'] + self.degree = coord_data['monomial_degree'] def __str__(self): - return str(self.__class__.__name__) + " " + str(self.__dict__) - - def __hash__(self): - return hash((self.param_id)) - - def __eq__(self, other): - return (self.param_id) == (other.param_id) - - def __ne__(self, other): - return not(self == other) - - def getParameterValue(self): - return self.param_value - - def getParameterType(self): - return self.param_type - - def getDistributionType(self): - return self.dist_type - - def getParameterID(self): - return self.param_id - - def setParameterID(self, pid): - self.param_id = pid - return + return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" def getQuadraturePointsWeights(self, npoints): pass - def evalOrthoNormalBasis(self, z, d): + def evaluateBasisFunction(self, z, d): pass - - def checkConsistency(self, max_degree=5, npoints=20, tol=1e-12, verbose=True): - """ - Check orthonormality of unit polynomials under quadrature. - - Parameters - ---------- - max_degree : int - Highest polynomial degree to check. - npoints : int - Number of quadrature points to use. - tol : float - Numerical tolerance for delta_{mn}. - verbose : bool - Print results if True. - - Returns - ------- - ok : bool - True if all checks pass within tolerance. - errors : list - List of (m,n,value) where error > tol. - """ - # 1D quadrature from this parameter - qmap = self.getQuadraturePointsWeights(npoints) - z = qmap['zq'] - w = qmap['wq'] - - errors = [] - ok = True - - # check inner products - for m in range(max_degree+1): - pm = self.evalOrthoNormalBasis(z, m) - for n in range(max_degree+1): - pn = self.evalOrthoNormalBasis(z, n) - ip = np.sum(pm*pn*w) # quadrature inner product - target = 1.0 if m == n else 0.0 - if abs(ip - target) > tol: - ok = False - errors.append((m, n, ip)) - if verbose: - print(f"Fail: = {ip:.6e} (expected {target})") - if verbose and ok: - print(f"[{self.__class__.__name__}] consistency check passed " - f"for degrees ≤ {max_degree} with {npoints} points.") - return ok, errors - -class DeterministicParameter(Parameter): +class ExponentialCoordinate(Coordinate): def __init__(self, pdata): - super(DeterministicParameter, self).__init__(pdata) - self.param_value = pdata['param_value'] - return - - def getQuadraturePointsWeights(self, npoints): - cmap = {'yq' : self.param_value, 'zq' : self.param_value, 'wq' : 1.0} - return cmap - - def evalOrthoNormalBasis(self, z, d): - """ - Evaluate the orthonormal basis at supplied coordinate. Note: For - the deterministic case, the value is always one. - """ - return 1.0 - -class ProbabilisticParameter(Parameter): - def __init__(self, pdata): - super(ProbabilisticParameter, self).__init__(pdata) - self.dist_params = pdata['dist_params'] - return - - def getDistributionParameters(self, key): - return self.dist_params[key] + super(ExponentialCoordinate, self).__init__(pdata) + self.dist_coords = pdata['dist_coords'] -class ExponentialParameter(Parameter): - def __init__(self, pdata): - super(ExponentialParameter, self).__init__(pdata) - self.dist_params = pdata['dist_params'] - return - - def getQuadraturePointsWeights(self, npoints): - """ - numpy.polynomial.laguerre.laggauss(deg)[source] - """ + def evaluateBasisFunction(self, zscalar, degree): + return unit_laguerre(zscalar, degree) - # This is based on interval [0, \inf] with the weight - # function f(xi) = \exp(-xi) which is also the standard - # PDF f(z) = \exp(-z) + def getQuadraturePointsWeights(self, degree): + # calculate the required number of quadrature points for the + # degree + npoints = minnum_quadrature_points(degree) + #This is based on interval [0, \inf] with the weight function f(xi) + # = \exp(-xi) which is also the standard PDF f(z) = \exp(-z). xi, w = np.polynomial.laguerre.laggauss(npoints) - mu = self.dist_params['mu'] - beta = self.dist_params['beta'] + mu = self.dist_coords['mu'] + beta = self.dist_coords['beta'] # scale weights to unity (Area under exp(-xi) in [0,inf] is 1.0 w = w/1.0 @@ -209,29 +100,27 @@ def getQuadraturePointsWeights(self, npoints): assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) # Return quadrature point in standard space as well - z = xi # (y-mu)/beta + z = xi - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - return cmap + return {'yq' : y, 'zq' : z, 'wq' : w} - def evalOrthoNormalBasis(self, z, d): - """ - Evaluate the orthonormal basis at supplied coordinate. - """ - return unit_laguerre(z,d) - -class NormalParameter(Parameter): +class NormalCoordinate(Coordinate): def __init__(self, pdata): - super(NormalParameter, self).__init__(pdata) - self.dist_params = pdata['dist_params'] - return + super(NormalCoordinate, self).__init__(pdata) + self.dist_coords = pdata['dist_coords'] + + def evaluateBasisFunction(self, zscalar, degree): + return unit_hermite(zscalar, degree) + + def getQuadraturePointsWeights(self, degree): + # calculate the required number of quadrature points for the + # degree + npoints = minnum_quadrature_points(degree) - def getQuadraturePointsWeights(self, npoints): # This is based on physicist unnormlized weight exp(-x*x). x, w = np.polynomial.hermite.hermgauss(npoints) - mu = self.dist_params['mu'] - sigma = self.dist_params['sigma'] + mu = self.dist_coords['mu'] + sigma = self.dist_coords['sigma'] # scale weights to unity (Area under exp(-x*x) in [-inf,inf] is pi w = w/np.sqrt(np.pi) @@ -246,27 +135,25 @@ def getQuadraturePointsWeights(self, npoints): # Return quadrature point in standard space as well z = (y-mu)/sigma - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - return cmap - - def evalOrthoNormalBasis(self, z, d): - """ - Evaluate the orthonormal basis at supplied coordinate. - """ - return unit_hermite(z, d) + return {'yq' : y, 'zq' : z, 'wq' : w} -class UniformParameter(Parameter): +class UniformCoordinate(Coordinate): def __init__(self, pdata): - super(UniformParameter, self).__init__(pdata) - self.dist_params = pdata['dist_params'] - return + super(UniformCoordinate, self).__init__(pdata) + self.dist_coords = pdata['dist_coords'] + + def evaluateBasisFunction(self, zscalar, degree): + return unit_legendre(zscalar, degree) + + def getQuadraturePointsWeights(self, degree): + # calculate the required number of quadrature points for the + # degree + npoints = minnum_quadrature_points(degree) - def getQuadraturePointsWeights(self, npoints): # This is based on weight 1.0 on interval [-1,1] x, w = np.polynomial.legendre.leggauss(npoints) - a = self.dist_params['a'] - b = self.dist_params['b'] + a = self.dist_coords['a'] + b = self.dist_coords['b'] # scale weights to unity w = w/2.0 @@ -281,403 +168,84 @@ def getQuadraturePointsWeights(self, npoints): # Return quadrature point in standard space as well z = (y-a)/(b-a) - # Store in map - cmap = {'yq' : y, 'zq' : z, 'wq' : w} - return cmap - - def evalOrthoNormalBasis(self, z, d): - """ - Evaluate the orthonormal basis at supplied coordinate. - """ - return unit_legendre(z,d) - -class HashableDict(dict): - def __hash__(self): - return hash(tuple(sorted(self.items()))) + return {'yq' : y, 'zq' : z, 'wq' : w} -class ParameterFactory: - """ - This class takes in primitives and makes data strucuture required - for other classes in this module (this is like creating elements). - - Deterministic parameter is a parameter whose polynomial degree is - zero and stochastic parameter is a parameter whose polynomial - degree is non zero. - """ +class CoordinateFactory: def __init__(self): - self.next_param_id = 0 + self.next_coord_id = 0 return - def getParameterID(self): - pid = self.next_param_id - self.next_param_id = self.next_param_id + 1 + def newCoordinateID(self): + pid = self.next_coord_id + self.next_coord_id = self.next_coord_id + 1 return pid - def createDeterministicParameter(self, pname, pvalue): - # Prepare map for calling constructor of deterministic - # parameter - pdata = {} - pdata['param_name'] = pname - pdata['param_type'] = ParameterType.DETERMINISTIC - pdata['dist_type'] = DistributionType.NONE - pdata['param_value'] = pvalue - pdata['monomial_degree'] = 0 - pdata['param_id'] = self.getParameterID() - return DeterministicParameter(pdata) - - def createNormalParameter(self, pname, dist_params, monomial_degree): - # Prepare map for calling constructor of Normal/Gaussian - # parameter + def createNormalCoordinate(self, coord_id, coord_name, dist_coords, max_monomial_dof): pdata = {} - pdata['param_name'] = pname - pdata['param_type'] = ParameterType.PROBABILISTIC + pdata['coord_id'] = coord_id + pdata['coord_name'] = coord_name + pdata['coord_type'] = CoordinateType.PROBABILISTIC pdata['dist_type'] = DistributionType.NORMAL - pdata['dist_params'] = dist_params - pdata['monomial_degree'] = monomial_degree - pdata['param_id'] = self.getParameterID() - return NormalParameter(pdata) - - def createUniformParameter(self, pname, dist_params, monomial_degree): - # Prepare map for calling constructor of Uniform - # parameter + pdata['dist_coords'] = dist_coords + pdata['monomial_degree'] = max_monomial_dof + pdata['coord_id'] = coord_id + return NormalCoordinate(pdata) + + def createUniformCoordinate(self, coord_id, coord_name, dist_coords, max_monomial_dof): pdata = {} - pdata['param_name'] = pname - pdata['param_type'] = ParameterType.PROBABILISTIC + pdata['coord_id'] = coord_id + pdata['coord_name'] = coord_name + pdata['coord_type'] = CoordinateType.PROBABILISTIC pdata['dist_type'] = DistributionType.UNIFORM - pdata['dist_params'] = dist_params - pdata['monomial_degree'] = monomial_degree - pdata['param_id'] = self.getParameterID() - return UniformParameter(pdata) - - def createExponentialParameter(self, pname, dist_params, monomial_degree): - # Prepare map for calling constructor of Uniform - # parameter + pdata['dist_coords'] = dist_coords + pdata['monomial_degree'] = max_monomial_dof + pdata['coord_id'] = coord_id + return UniformCoordinate(pdata) + + def createExponentialCoordinate(self, coord_id, coord_name, dist_coords, max_monomial_dof): pdata = {} - pdata['param_name'] = pname - pdata['param_type'] = ParameterType.PROBABILISTIC + pdata['coord_id'] = coord_id + pdata['coord_name'] = coord_name + pdata['coord_type'] = CoordinateType.PROBABILISTIC pdata['dist_type'] = DistributionType.EXPONENTIAL - pdata['dist_params'] = dist_params - pdata['monomial_degree'] = monomial_degree - pdata['param_id'] = self.getParameterID() - return ExponentialParameter(pdata) + pdata['dist_coords'] = dist_coords + pdata['monomial_degree'] = max_monomial_dof + pdata['coord_id'] = coord_id + return ExponentialCoordinate(pdata) -class ParameterContainer: +class CoordinateSystem: """ - Class that contains all stochastic parameters and handles - quadrature and evaluation of basis functions. - - This object is simply a container for objects of type Parameter. - - Author: Komahan Boopathy - + 1. Stores all coordinates (axes, dimensions) + 2. Manages basis functions + 3. Manages integrations (inner-product) along these dimensions through quadrature """ - def __init__(self): - self.num_terms = 1 - - # container for storing all parameters - self.parameter_map = {} - - # replace with DegreeSet class - self.basistermwise_parameter_degrees = {} # For each parameter and basis entry what - # is the degree according to tensor - # product - - # Replace with basis class - self.psi_map = {} - - return + def __init__(self, basis_type): + self.coordinates = {} # p0, p1, p2, ... + self.basis_construction = basis_type + self.basis = None def __str__(self): - return str(self.__class__.__name__) + " " + str(self.__dict__) - - def getNumParameters(self): - return len(self.parameter_map.keys()) + return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" - def initialize(self): - """ - Initialize the container after all 'paramters' are added. - """ - # Create degree map - sp_hd_map = self.getParameterHighestDegreeMap(exclude=ParameterType.DETERMINISTIC) - self.basistermwise_parameter_degrees = tensor_indices(sp_hd_map) - return + def getNumCoordinateAxes(self): + return len(self.coordinates.keys()) - def getNumQuadraturePoints(self): - """ - """ - param_nqpts_map = Counter() - pkeys = self.parameter_map.keys() - for pid in pkeys: - param_nqpts_map[pid] = self.getParameter(pid).monomial_degree - return param_nqpts_map - - def getNumQuadraturePointsFromDegree(self,dmap): - """ - Supply a map whose keys are parameterids and values are - monomial degrees and this function will return a map with - parameterids as keys and number of corresponding quadrature - points along the monomial dimension. - """ - ## pids = dmap.keys() - ## param_nqpts_map = Counter() - ## for pid in pids: - ## param_nqpts_map[pid] = nqpts(dmap[pid]) - ## return param_nqpts_map - - pids = dmap.keys() - param_nqpts_map = Counter() - for pid in self.parameter_map.keys(): #pids: - param_nqpts_map[pid] = self.parameter_map[pid].monomial_degree #nqpts(dmap[pid]) - return param_nqpts_map - - def getParameterDegreeForBasisTerm(self, paramid, kthterm): - """ - What is the polynomial degree of the corresponding k-th or - Hermite/Legendre basis function? For univariate stochastic - case d == k, but will change for multivariate case based on - tensor product or other rules used to construct the - multivariate basis set. - """ - return self.basistermwise_parameter_degrees[kthterm][paramid] - - def getParameters(self): - return self.parameter_map - - def getParameter(self, paramid): - return self.parameter_map[paramid] - - def getParameterHighestDegreeMap(self, exclude=ParameterType.DETERMINISTIC): - degree_map = {} - for paramkey in self.parameter_map.keys(): - param = self.parameter_map[paramkey] - if exclude != param.getParameterType(): - degree_map[paramkey] = param.monomial_degree - return degree_map - - def getNumStochasticBasisTerms(self): - return self.num_terms - - def addParameter(self, new_parameter): - - # do you want to hold separate maps for deterministic and - # stochastic parameters? - - # Add parameter object to the map of parameters - self.parameter_map[new_parameter.getParameterID()] = new_parameter - - # Increase the number of stochastic terms (tensor pdt rn) - self.num_terms = self.num_terms*new_parameter.monomial_degree - - return + def getMonomialDegreeCoordinates(self): + return {cid: coord.degree for cid, coord in self.coordinates.items()} - def initializeQuadrature(self, param_nqpts_map): - self.quadrature_map = self.getQuadraturePointsWeights(param_nqpts_map) - return + def addCoordinateAxis(self, coordinate): + self.coordinates[coordinate.id] = coordinate - def W(self, q): - wmap = self.quadrature_map[q]['W'] - return wmap - - def psi(self, k, zmap): - paramids = zmap.keys() - ans = 1.0 - for paramid in paramids: - # Deterministic ones return one! maybe we can avoid! - d = self.getParameterDegreeForBasisTerm(paramid, k) - val = self.getParameter(paramid).evalOrthoNormalBasis(zmap[paramid],d) - ans = ans*val - return ans - - def Z(self, q, key='pid'): - if key == 'pid': - # use pid as key - return self.quadrature_map[q]['Z'] - else: - # use name as key - qmap = self.quadrature_map[q]['Z'] - nmap = {} - for pid in qmap.keys(): - nmap[self.getParameter(pid).param_name] = qmap[pid] - return nmap - - def Y(self, q, key='pid'): - if key == 'pid': - # use pid as key - return self.quadrature_map[q]['Y'] + def initialize(self): + if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: + self.basis = generate_basis_tensor_degree(self.getMonomialDegreeCoordinates()) + elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: + self.basis = generate_basis_total_degree(self.getMonomialDegreeCoordinates()) else: - # use name as key - qmap = self.quadrature_map[q]['Y'] - nmap = {} - for pid in qmap.keys(): - nmap[self.getParameter(pid).param_name] = qmap[pid] - return nmap - - def evalOrthoNormalBasis(self, k, q): - return self.psi(k, self.Z(q)) - - - def getQuadraturePointsWeights(self, param_nqpts_map): - """ - Return a map of quadrature point index : quadrature data (Y,Z,W). - Works for arbitrary number of random variables. - """ - pids = list(param_nqpts_map.keys()) - nqpts = list(param_nqpts_map.values()) - - # fetch 1D quadrature maps for each parameter - maps = [self.getParameter(pid).getQuadraturePointsWeights(n) - for pid, n in zip(pids, nqpts)] - - # Cartesian product of index ranges - qmap = {} - ctr = 0 - for idx_tuple in product(*[range(n) for n in nqpts]): - yvec, zvec, w = {}, {}, 1.0 - for pid, i, m in zip(pids, idx_tuple, maps): - yvec[pid] = m['yq'][i] - zvec[pid] = m['zq'][i] - w *= m['wq'][i] - qmap[ctr] = {'Y': yvec, 'Z': zvec, 'W': w} - ctr += 1 - - return qmap - - def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): - """ - Check orthonormality of the multivariate basis functions - under the container's quadrature. Also prints a table of - inner products for debugging. - - Parameters - ---------- - max_degree : int or None - Maximum number of basis terms to check. If None, uses - all available basis terms. - tol : float - Numerical tolerance for delta_{ij}. - verbose : bool - Print results if True. - - Returns - ------- - ok : bool - True if all checks pass within tolerance. - errors : list - List of (i,j,value) where error > tol. - gram : np.ndarray - Inner product matrix (approximate identity). - """ - # Ensure quadrature is initialized - if not hasattr(self, "quadrature_map"): - nqpts_map = self.getNumQuadraturePoints() - self.initializeQuadrature(nqpts_map) - - nbasis = self.getNumStochasticBasisTerms() - if max_degree is not None: - nbasis = min(nbasis, max_degree+1) - - gram = np.zeros((nbasis, nbasis)) - errors = [] - ok = True - - # Build Gram matrix - for i in range(nbasis): - for j in range(nbasis): - s = 0.0 - for q in self.quadrature_map.keys(): - psi_i = self.evalOrthoNormalBasis(i,q) - psi_j = self.evalOrthoNormalBasis(j,q) - wq = self.W(q) - s += psi_i * psi_j * wq - gram[i,j] = s - target = 1.0 if i == j else 0.0 - if abs(s - target) > tol: - ok = False - errors.append((i, j, s)) - - if verbose: - print(f"[ParameterContainer] Gram matrix for {nbasis} basis terms:") - with np.printoptions(precision=3, suppress=True): - print(gram) - - if ok: - print(f"[ParameterContainer] consistency check passed " - f"for {nbasis} basis terms.") - else: - print(f"[ParameterContainer] FAILED: {len(errors)} inconsistencies found.") - - return ok, errors, gram - - - def sparse(self, dmapi, dmapj, dmapf): - smap = {} - for key in dmapi.keys(): - if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: - smap[key] = True - else: - smap[key] = False - return smap - - def getSymmetricNonZeroIndices(self, dmapf): - nz = {} - N = self.getNumStochasticBasisTerms() - for i in range(N): - dmapi = self.basistermwise_parameter_degrees[i] - for j in range(i,N): - dmapj = self.basistermwise_parameter_degrees[j] - smap = self.sparse(dmapi, dmapj, dmapf) - if False not in smap.values(): - dmap = Counter() - dmap.update(dmapi) - dmap.update(dmapj) - dmap.update(dmapf) - nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) - nz[(i,j)] = nqpts_map - return nz - - def getSparseJacobian(self, f, dmapf): - # rename member functions for local readability - w = lambda q : self.W(q) - psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) - - nzs = self.getSymmetricNonZeroIndices(dmapf) - N = self.getNumStochasticBasisTerms() - A = np.zeros((N, N)) - for index, nqpts in nzs.items(): - self.initializeQuadrature(nqpts) - pids = self.getParameters().keys() - i = index[0] - j = index[1] - for q in self.quadrature_map.keys(): - val = w(q)*psiz(i,q)*psiz(j,q)*f(q) - A[i, j] += val - A[j, i] += val - return A - - def getJacobian(self, f, dmapf): - # rename member functions for local readability - w = lambda q : self.W(q) - psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) - - N = self.getNumStochasticBasisTerms() - A = np.zeros((N, N)) - for i in range(N): - dmapi = self.basistermwise_parameter_degrees[i] - for j in range(N): - dmapj = self.basistermwise_parameter_degrees[j] - - dmap = Counter() - dmap.update(dmapi) - dmap.update(dmapj) - dmap.update(dmapf) - - # add up the degree of both participating functions psizi - # and psizj to determine the total degree of integrand - nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) - self.initializeQuadrature(nqpts_map) - - # Loop quadrature points - pids = self.getParameters().keys() - for q in self.quadrature_map.keys(): - A[i,j] += w(q)*psiz(i,q)*psiz(j,q)*f(q) - return A + raise NOT_IMPLEMENTED + + def evaluateBasis(self, z, counter): + val = 1.0 + for cid, cdeg in counter.items(): + val *= self.coordinates[cid].evaluateBasisFunction(z[cid], cdeg) + return val From 6ba34447863bff7f759682dfd57df9bab50c7043 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Mon, 8 Sep 2025 20:47:41 -0400 Subject: [PATCH 12/54] implemented quadrature and decompose routine --- pspace/core.py | 156 +++++++++++++++++++++++++++++++-- pspace/stochastic_utils.py | 174 +++++++++++++++++++------------------ tests/test_sparsity.py | 109 +++++++++++++++++++---- 3 files changed, 329 insertions(+), 110 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 58f9da2..e0f2d9a 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -22,7 +22,7 @@ from itertools import product # Local modules -from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree +from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree, sum_degrees, safe_zero_degrees from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre from .orthogonal_polynomials import unit_laguerre @@ -220,32 +220,174 @@ class CoordinateSystem: 3. Manages integrations (inner-product) along these dimensions through quadrature """ def __init__(self, basis_type): - self.coordinates = {} # p0, p1, p2, ... + self.coordinates = {} # cid -> Coordinate self.basis_construction = basis_type - self.basis = None + self.basis = None # {basis_id: Counter({cid:deg,...})} def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + def getNumBasisFunctions(self): + return len(self.basis) + def getNumCoordinateAxes(self): return len(self.coordinates.keys()) def getMonomialDegreeCoordinates(self): + # map cid -> configured max degree for that coordinate (basis richness) return {cid: coord.degree for cid, coord in self.coordinates.items()} def addCoordinateAxis(self, coordinate): self.coordinates[coordinate.id] = coordinate def initialize(self): + max_deg_map = self.getMonomialDegreeCoordinates() if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: - self.basis = generate_basis_tensor_degree(self.getMonomialDegreeCoordinates()) + self.basis = generate_basis_tensor_degree(max_deg_map) elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: - self.basis = generate_basis_total_degree(self.getMonomialDegreeCoordinates()) + self.basis = generate_basis_total_degree(max_deg_map) else: - raise NOT_IMPLEMENTED + raise NotImplementedError("ADAPTIVE_DEGREE path not implemented") - def evaluateBasis(self, z, counter): + def evaluateBasisDegrees(self, z, counter): val = 1.0 for cid, cdeg in counter.items(): val *= self.coordinates[cid].evaluateBasisFunction(z[cid], cdeg) return val + + def evaluateBasisIndex(self, z, basis_id): + val = 1.0 + for cid, cdeg in counter.items(): + val *= self.coordinates[cid].evaluateBasisFunction(z[cid], self.basis[basis_id]) + return val + + def print_quadrature(self, qmap): + """ + Pretty-print a quadrature map (qmap) in tabular style. + """ + print("Quadrature rule:") + print("-" * 80) + for q, data in qmap.items(): + y_str = ", ".join(f"y{cid} = {val:.4g}" for cid, val in data['Y'].items()) + z_str = ", ".join(f"z{cid} = {val:.4g}" for cid, val in data['Z'].items()) + w_str = f"W = {data['W']:.4g}" + print(f"q ={q:3d} : {y_str:<40} | {z_str:<40} | {w_str}") + print("-" * 80) + + def build_quadrature(self, degrees: Counter): + """ + Build tensor-product quadrature from per-axis polynomial degree needs. + degrees: Counter({cid: p_i}) — degree of the integrand along each axis. + Uses each coordinate's 1D rule with n_i = minnum_quadrature_points(p_i). + + qmap = + { + q_index : + { + 'Y': {cid: physical_value, ...}, + 'Z': {cid: standard_value, ...}, + 'W': weight + }, + ... + } + + """ + cids = list(self.coordinates.keys()) + q1d = {} # cid -> {'yq','zq','wq'} + npts = {} # cid -> Ni + + # obtain 1D rules per axis + for cid in cids: + p_i = int(degrees.get(cid, 0)) + one_d = self.coordinates[cid].getQuadraturePointsWeights(p_i) + q1d[cid] = one_d + npts[cid] = len(one_d['wq']) + + # tensor them + qmap = {} + ctr = 0 + ranges = [range(npts[cid]) for cid in cids] + for idx_tuple in product(*ranges): + y, z, w = {}, {}, 1.0 + for cid, i in zip(cids, idx_tuple): + y[cid] = q1d[cid]['yq'][i] + z[cid] = q1d[cid]['zq'][i] + w *= q1d[cid]['wq'][i] + qmap[ctr] = {'Y': y, 'Z': z, 'W': w} + ctr += 1 + + self.print_quadrature(qmap) + + self.quadrature = qmap + + return qmap + + # --- inner products & decomposition --- + + def inner_product(self, f_eval, g_eval, f_deg: Counter|None=None, g_deg: Counter|None=None): + """ + = ∫ f(z) g(z) ρ(z) dz, evaluated by exact quadrature if f_deg/g_deg supplied. + - f_eval, g_eval: callables taking z_by_cid: dict(cid->z) + - f_deg, g_deg: Counter({cid: degree}) describing polynomial degrees of f,g per axis. + If None, assumed 0 along each axis (safe but possibly under-integrated). + """ + coord_ids = list(self.coordinates.keys()) + f_deg = f_deg or safe_zero_degrees(coord_ids) + g_deg = g_deg or safe_zero_degrees(coord_ids) + + # Required per-axis polynomial degree for the integrand f*g + need = sum_degrees(f_deg, g_deg) + + # Build exact-enough quadrature + qmap = self.build_quadrature(need) + + s = 0.0 + for q in qmap.values(): + z = q['Z'] + s += f_eval(z) * g_eval(z) * q['W'] + return s + + def inner_product_basis(self, i_id: int, j_id: int, f_eval=None, f_deg: Counter|None=None): + """ + <ψ_i, f, ψ_j> with exact quadrature deduced from degrees. + - f_eval: callable(z_by_cid) or None (acts as 1.0) + - f_deg: Counter({cid: degree}) or None (treated as zeros) + """ + psi_i = self.basis[i_id] + psi_j = self.basis[j_id] + + coord_ids = list(self.coordinates.keys()) + f_deg = f_deg or safe_zero_degrees(coord_ids) + + # integrand degree per axis = deg(ψ_i)+deg(ψ_j)+deg(f) + need = sum_degrees(psi_i, psi_j, f_deg) + + # Quadrature sized to integrate exactly + qmap = self.build_quadrature(need) + + s = 0.0 + for q in qmap.values(): + z = q['Z'] + val = self.evaluateBasisDegrees(z, psi_i) * self.evaluateBasisDegrees(z, psi_j) + if f_eval is not None: + val *= f_eval(z) + s += val * q['W'] + return s + + def decompose(self, f_eval, f_deg: Counter): + """ + Coefficients c_k = , with quadrature sized from deg(f) + deg(ψ_k). + Returns dict {basis_id: coefficient}. + """ + coeffs = {} + for k, psi_k in self.basis.items(): + # per-axis degree need = deg(f) + deg(ψ_k) + need = sum_degrees(f_deg, psi_k) + qmap = self.build_quadrature(need) + + s = 0.0 + for q in qmap.values(): + z = q['Z'] + s += f_eval(z) * self.evaluateBasisDegrees(z, psi_k) * q['W'] + coeffs[k] = s + return coeffs diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 8d45165..60aa91e 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -1,25 +1,29 @@ -from __future__ import print_function - import numpy as np -from collections import Counter - -## def get_tensor_quadrature_index(k, param_nqpts_map): -## pidx = {} -## pids = param_nqpts_map.keys() -## nqpts = param_nqpts_map.values() +from collections import Counter +from itertools import product +import math -## if k < nqpt[0]: -## return [k,0,0] -## elif nqpt[0] - k: -## return [ +def sum_degrees(*counters): + """Per-axis sum of Counter degrees.""" + out = Counter() + for c in counters: + if c is None: + continue + out.update(c) # Counter adds per-key + return out -## for (pid,nqpt) in zip(pids,nqpts): -## print pid, nqpt -## if k < nqpt:mod(nqpt-k,k) -## pidx[pid] = mod( +def safe_zero_degrees(coord_ids): + """Counter with zeros for all coord ids.""" + return Counter({cid: 0 for cid in coord_ids}) -## return +def generate_basis_adaptive(f_indices, m): + """Adaptive basis: closure of f's monomials under m-fold products.""" + if m == 0: + return {(0,) * len(f_indices[0])} + combos = product(f_indices, repeat=m) + return {tuple(sum(idx[i] for idx in combo) for i in range(len(combo[0]))) + for combo in combos} def sparse(dmapi, dmapj, dmapf): smap = {} @@ -30,6 +34,9 @@ def sparse(dmapi, dmapj, dmapf): smap[key] = False return smap +def minnum_quadrature_points(degree): + return math.ceil((degree+1)/2) + def nqpts(pdeg): """ Return the number of quadrature points necessary to integrate the @@ -37,78 +44,73 @@ def nqpts(pdeg): """ return max(pdeg/2+1,1) #1 + pdeg/2 #max(deg/2+1,1) -def tensor_indices(phdmap): - """ - Get basis functions indices based on tensor product +def generate_basis_tensor_degree(max_degree_params): """ + Construct a tensor-product index map for basis functions. - pids = list(phdmap.keys()) # parameter IDs - pdegs = list(phdmap.values()) # parameter degrees + Parameters + ---------- + max_degree_params : dict + Map {param_id : max_degree}, where max_degree is the highest + polynomial degree to include for that parameter. + + Returns + ------- + term_polynomial_degree : dict + Map {basis_index : Counter({pid: degree, ...})}. + Each entry gives the parameterwise polynomial degrees + for that basis term. + """ + pids = list(max_degree_params.keys()) # parameter IDs + pdegs = list(max_degree_params.values()) # max degrees (exclusive upper bound) - # Exclude deterministic terms total_tensor_basis_terms = int(np.prod(pdegs)) - num_vars = len(pdegs) - - # Initialize map with empty values corresponding to each key - idx = {} - for key in range(total_tensor_basis_terms): - idx[key] = [] - - # Add actual values into the map - if num_vars == 1: - ctr = 0 - for k0 in range(pdegs[0]): - idx[k0].append(Counter({pids[0]:k0})) # add one element tuple to map - ctr += 1 - elif num_vars == 2: - ctr = 0 - for k0 in range(pdegs[0]): - for k1 in range(pdegs[1]): - idx[k0+k1].append(Counter({pids[0]:k0, - pids[1]:k1})) # add two element tuple to map - ctr += 1 - elif num_vars == 3: - ctr = 0 - for k0 in range(pdegs[0]): - for k1 in range(pdegs[1]): - for k2 in range(pdegs[2]): - idx[k0+k1+k2].append(Counter({pids[0]:k0, - pids[1]:k1, - pids[2]:k2})) # add three element tuple to map - ctr += 1 - elif num_vars == 4: - ctr = 0 - for k0 in range(pdegs[0]): - for k1 in range(pdegs[1]): - for k2 in range(pdegs[2]): - for k3 in range(pdegs[3]): - idx[k0+k1+k2+k3].append(Counter({pids[0]:k0, - pids[1]:k1, - pids[2]:k2, - pids[3]:k3})) # add four element tuple to map - ctr += 1 - elif num_vars == 5: - ctr = 0 - for k0 in range(pdegs[0]): - for k1 in range(pdegs[1]): - for k2 in range(pdegs[2]): - for k3 in range(pdegs[3]): - for k4 in range(pdegs[4]): - idx[k0+k1+k2+k3+k4].append(Counter({pids[0]:k0, - pids[1]:k1, - pids[2]:k2, - pids[3]:k3, - pids[4]:k4})) # add five element tuple to map - ctr += 1 - else: - print('fix implementation for more elements in tuple') - raise - - # Make a flat list - flat_list = [item for sublist in idx.values() for item in sublist] - - # Convert to map + + term_polynomial_degree = {} + k = 0 + for degrees in product(*[range(d+1) for d in pdegs]): + term_polynomial_degree[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) + k += 1 + + return term_polynomial_degree + +def generate_basis_total_degree(max_degree_params): + """ + Construct a total-degree index map for basis functions. + + Parameters + ---------- + max_degree_params : dict + Map {param_id : max_degree}, where max_degree is the highest + polynomial degree allowed *per dimension*. + + Returns + ------- + term_polynomial_degree : dict + Map {basis_index : Counter({pid: degree, ...})}. + Each entry gives the parameterwise polynomial degrees + for that basis term, with sum(degrees) <= max(total_degrees). + """ + pids = list(max_degree_params.keys()) + pdegs = list(max_degree_params.values()) + + max_total_degree = max(pdegs) + term_polynomial_degree = {} - for k in range(total_tensor_basis_terms): - term_polynomial_degree[k] = flat_list[k] + k = 0 + for degrees in product(*[range(d+1) for d in pdegs]): + if sum(degrees) <= max_total_degree: + term_polynomial_degree[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) + k += 1 + return term_polynomial_degree + +if __name__ == '__main__': + + max_degree_params = {0:3, 1:3, 2:3} + + out = generate_basis_tensor_degree(max_degree_params) + print(len(out), out) + + out = generate_basis_total_degree(max_degree_params) + print(len(out), out) diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index 7fbae2c..634594e 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -19,31 +19,83 @@ from collections import Counter # local modules -from pspace.core import ParameterFactory -from pspace.core import ParameterContainer +from pspace.core import CoordinateFactory +from pspace.core import CoordinateSystem, BasisFunctionType + from pspace.plotter import plot_jacobian if __name__ == '__main__': + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) + cf = CoordinateFactory() + + #y0 = cf.createNormalCoordinate(0, 'y0', dict(mu=0.0, sigma=1.0), max_monomial_dof=1) + #y1 = cf.createUniformCoordinate(1, 'y1', dict(a=-1, b=1), max_monomial_dof=1) + + y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 3) + y1 = cf.createExponentialCoordinate(cf.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 3) + y2 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 3) + + cs.addCoordinateAxis(y0) + cs.addCoordinateAxis(y1) + cs.addCoordinateAxis(y2) + + cs.initialize() + + # decompose a vector to obtain coefficients + f_eval = lambda z: 2 + 3*z[0] - z[1] + f_deg = Counter({0:1, 1:1, 2:0}) + coeffs = cs.decompose(f_eval, f_deg) + print(coeffs) + + # inner product of two basis entries with f + #val = cs.inner_product_basis(i_id=1, j_id=0, f_eval=f_eval, f_deg=f_deg) + #print(val) + + + stop + # Domain Definition(ADAPTIVE, FIXED={TENSOR, COMPLETE}) - pfactory = ParameterFactory() + cfactory = CoordinateFactory() # With adaptive enrichment we can keep the complexity (basis set # size) tied to the intrinsic structure of the function to be # decomposed in the probabilistic domain, not the worst-case - # degree cutoffs like 4, 5, 6 + # degree cutoffs like 4, q5, 6 + + # Random coordinates + y0 = cfactory.createNormalCoordinate(cfactory.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 3) + y1 = cfactory.createExponentialCoordinate(cfactory.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 3) + y2 = cfactory.createUniformCoordinate(cfactory.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 3) + + print(y0) + print(y1) + print(y2) + + # Deterministic coordinates (uniform distribution & monomial degree = 0) + # x1 = cfactory.createUniformCoordinate('x1', dict(a=-2.0, b=2.0), 0) # space + # x2 = cfactory.createUniformCoordinate('x2', dict(a=-2.0, b=2.0), 0) + # x3 = cfactory.createUniformCoordinate('x3', dict(a=-2.0, b=2.0), 0) + # t = cfactory.createUniformCoordinate('t', dict(a=0.0, b=1.0), 0) # time - y1 = pfactory.createNormalParameter ('y1', dict(mu = -4.0, sigma = 0.5), 3) - y2 = pfactory.createExponentialParameter('y2', dict(mu = +6.0, beta = 1.0), 3) - y3 = pfactory.createUniformParameter ('y3', dict(a = -5.0, b = 4.0), 3) + # Add "Coordinate" into "CoordinateSystem: create Axes in the Domain + csystem = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) - # Add "Parameter" into "ParameterContainer: create Axes in the Domain - pc = ParameterContainer() + csystem.addCoordinateAxis(y0) + csystem.addCoordinateAxis(y1) + csystem.addCoordinateAxis(y2) - pc.addParameter(y1) - pc.addParameter(y2) - pc.addParameter(y3) + csystem.initialize() - pc.initialize() + print(csystem) + + for i in range(csystem.getNumBasisFunctions()): + print(csystem.evaluateBasis([0.0, 0.0, 0.0], csystem.basis[i])) + + + # check inner_product(psi_i, psi_j) + # check decompose(function) + + stop class Function: def __init__(self, func, dmap, name): @@ -60,25 +112,48 @@ def y(q, degree): def generate_baseline(F : Function): A = pc.getJacobian(F.func, F.dmap) np.save(os.path.join(outdir, f"matrix-full-assembly-{F.name}.npy"), A) + np.savetxt(os.path.join(outdir, f"matrix-full-assembly-{F.name}.dat"), A, fmt='%f', delimiter=' ') A = pc.getSparseJacobian(F.func, F.dmap) np.save(os.path.join(outdir, f"matrix-sparse-assembly-{F.name}.npy"), A) + np.savetxt(os.path.join(outdir, f"matrix-sparse-assembly-{F.name}.dat"), A, fmt='%f', delimiter=' ') + - # constant: y0^0 * y1^0 * y2^0 + # identity: y0^0 * y1^0 * y2^0 func = lambda q : y(q,0) * y(q,1) * y(q,2) dmap = Counter() dmap[0] = 0; dmap[1] = 0; dmap[2] = 0; - constant_function = Function(func, dmap, "y1^0y2^0y3^0") + constant_function = Function(func, dmap, "identity") + generate_baseline(constant_function) + + + # identity: 2.0 * y0^0 * y1^0 * y2^0 + func = lambda q : 2.0 * y(q,0) * y(q,1) * y(q,2) + dmap = Counter() + dmap[0] = 0; dmap[1] = 0; dmap[2] = 0; + constant_function = Function(func, dmap, "constant") + generate_baseline(constant_function) # linear : define: y0^1 + y1^1 + y2^1 dmap = Counter() dmap[0] = 1; dmap[1] = 1; dmap[2] = 1 func = lambda q : y(q,0) + y(q,1) + y(q,2) - linear_function = Function(func, dmap, "y1^1+y2^1+y3^1") + linear_function = Function(func, dmap, "linear") + generate_baseline(linear_function) # quadratic : define: y0^2 + y1^2 + y2^2 dmap = Counter() dmap[0] = 2; dmap[1] = 2; dmap[2] = 2 func = lambda q : y(q,0)**2 + y(q,1)**2 + y(q,2)**2 - quadratic_function = Function(func, dmap, "y1^2+y2^2+y3^2") + quadratic_function = Function(func, dmap, "quadratic") generate_baseline(quadratic_function) + + # polynomial: y0^4 * y1 + y0 * y2 + y2^3 + dmap = Counter() + dmap[0] = 4 # degree in y0 + dmap[1] = 1 # degree in y1 + dmap[2] = 3 # degree in y2 + + func = lambda q : (y(q,0)**4 * y(q,1)) + (y(q,0) * y(q,2)) + (y(q,2)**3) + poly_function = Function(func, dmap, "quintic") + generate_baseline(poly_function) From 03ec92630bd8623b0042b50c7971c28231beae30 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Tue, 9 Sep 2025 20:02:52 -0400 Subject: [PATCH 13/54] add print verbosity --- pspace/core.py | 8 +- pspace/stochastic_element.py | 344 +++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 4 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index e0f2d9a..d639966 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -219,10 +219,11 @@ class CoordinateSystem: 2. Manages basis functions 3. Manages integrations (inner-product) along these dimensions through quadrature """ - def __init__(self, basis_type): + def __init__(self, basis_type, verbose = False): self.coordinates = {} # cid -> Coordinate self.basis_construction = basis_type self.basis = None # {basis_id: Counter({cid:deg,...})} + self.verbose = True def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" @@ -316,9 +317,8 @@ def build_quadrature(self, degrees: Counter): qmap[ctr] = {'Y': y, 'Z': z, 'W': w} ctr += 1 - self.print_quadrature(qmap) - - self.quadrature = qmap + if self.verbose is True: + self.print_quadrature(qmap) return qmap diff --git a/pspace/stochastic_element.py b/pspace/stochastic_element.py index b90299f..8115f4d 100644 --- a/pspace/stochastic_element.py +++ b/pspace/stochastic_element.py @@ -1,3 +1,347 @@ +class BasisFunction(object): + def __init__(self, basis_data): + self.id = basis_data['basis_id'] + self.name = basis_data['basis_name'] + self.degrees = basis_data['monomial_degrees'] # Counter({0: 1, 1: 0}), + + # add sanity check for whether basis vector is unitary + self.num_coords = len(self.degrees) + + def __str__(self): + return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + + def getBasisFunctionID(self): + return self.id + + def getBasisFunctionName(self): + return self.name + +class BasisFunctionFactory: + def __init__(self): + self.next_id = 0 + + def newBasisFunctionID(self): + bid = self.next_id + self.next_id += 1 + return bid + + def __str__(self): + return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + + def createBasisFunction(self, basis_id, basis_name, monomial_degrees): + data = {} + data['basis_id'] = basis_id + data['basis_name'] = basis_name + data['monomial_degrees'] = monomial_degrees + return BasisFunction(data) + + def checkConsistency(self, max_degree=5, npoints=20, tol=1e-12, verbose=True): + """ + Check orthonormality of unit polynomials under quadrature. + + Coordinates + ---------- + max_degree : int + Highest polynomial degree to check. + npoints : int + Number of quadrature points to use. + tol : float + Numerical tolerance for delta_{mn}. + verbose : bool + Print results if True. + + Returns + ------- + ok : bool + True if all checks pass within tolerance. + errors : list + List of (m,n,value) where error > tol. + """ + # 1D quadrature from this coordinate + qmap = self.getQuadraturePointsWeights(npoints) + z = qmap['zq'] + w = qmap['wq'] + + errors = [] + ok = True + + # check inner products + for m in range(max_degree+1): + pm = self.evalOrthoNormalBasis(z, m) + for n in range(max_degree+1): + pn = self.evalOrthoNormalBasis(z, n) + ip = np.sum(pm*pn*w) # quadrature inner product + target = 1.0 if m == n else 0.0 + if abs(ip - target) > tol: + ok = False + errors.append((m, n, ip)) + if verbose: + print(f"Fail: = {ip:.6e} (expected {target})") + if verbose and ok: + print(f"[{self.__class__.__name__}] consistency check passed " + f"for degrees ≤ {max_degree} with {npoints} points.") + return ok, errors + + def getNumQuadraturePointsFromDegree(self, dmap): + """ + Supply a map whose keys are coordinateids and values are + monomial degrees and this function will return a map with + coordinateids as keys and number of corresponding quadrature + points along the monomial dimension. + """ + ## pids = dmap.keys() + ## coord_nqpts_map = Counter() + ## for pid in pids: + ## coord_nqpts_map[pid] = nqpts(dmap[pid]) + ## return coord_nqpts_map + + pids = dmap.keys() + coord_nqpts_map = Counter() + for pid in self.coordinates.keys(): #pids: + coord_nqpts_map[pid] = self.coordinates[pid].monomial_degree #nqpts(dmap[pid]) + return coord_nqpts_map + + def getCoordinateDegreeForBasisTerm(self, coordid, kthterm): + """ + What is the polynomial degree of the corresponding k-th or + Hermite/Legendre basis function? For univariate stochastic + case d == k, but will change for multivariate case based on + tensor product or other rules used to construct the + multivariate basis set. + """ + return self.basistermwise_coordinate_degrees[kthterm][coordid] + + def getMonimialDegreeCoordinates(self): + degree_map = {} + for coordid in self.coordinates.keys(): + degree_map[coordid] = self.coordinates[coordid].monomial_degree + return degree_map # wrap with Counter() + + def getNumBasisElements(self): + return self.basis_factory.count + 1 + + def addCoordinate(self, coordinate): + # Add coordinate object to the map of coordinates + self.coordinates[coordinate.getCoordinateID()] = coordinate + + # Increase the number of stochastic terms (tensor pdt rn) + # self.num_terms = self.num_terms*new_coordinate.monomial_degree + + def initializeQuadrature(self, coord_nqpts_map): + self.quadrature_map = self.getQuadraturePointsWeights(coord_nqpts_map) + return + + def W(self, q): + wmap = self.quadrature_map[q]['W'] + return wmap + + def psi(self, k, zmap): + coordids = zmap.keys() + ans = 1.0 + for coordid in coordids: + # Deterministic ones return one! maybe we can avoid! + d = self.getCoordinateDegreeForBasisTerm(coordid, k) + val = self.getCoordinate(coordid).evalOrthoNormalBasis(zmap[coordid],d) + ans = ans*val + return ans + + def Z(self, q, key='pid'): + if key == 'pid': + # use pid as key + return self.quadrature_map[q]['Z'] + else: + # use name as key + qmap = self.quadrature_map[q]['Z'] + nmap = {} + for pid in qmap.keys(): + nmap[self.getCoordinate(pid).coord_name] = qmap[pid] + return nmap + + def Y(self, q, key='pid'): + if key == 'pid': + # use pid as key + return self.quadrature_map[q]['Y'] + else: + # use name as key + qmap = self.quadrature_map[q]['Y'] + nmap = {} + for pid in qmap.keys(): + nmap[self.getCoordinate(pid).coord_name] = qmap[pid] + return nmap + + def evalOrthoNormalBasis(self, k, q): + return self.psi(k, self.Z(q)) + + def getQuadraturePointsWeights(self, coord_nqpts_map): + """ + Return a map of quadrature point index : quadrature data (Y,Z,W). + Works for arbitrary number of random variables. + """ + pids = list(coord_nqpts_map.keys()) + nqpts = list(coord_nqpts_map.values()) + + # fetch 1D quadrature maps for each coordinate + maps = [self.getCoordinate(pid).getQuadraturePointsWeights(n) + for pid, n in zip(pids, nqpts)] + + # Cartesian product of index ranges + qmap = {} + ctr = 0 + for idx_tuple in product(*[range(n) for n in nqpts]): + yvec, zvec, w = {}, {}, 1.0 + for pid, i, m in zip(pids, idx_tuple, maps): + yvec[pid] = m['yq'][i] + zvec[pid] = m['zq'][i] + w *= m['wq'][i] + qmap[ctr] = {'Y': yvec, 'Z': zvec, 'W': w} + ctr += 1 + + return qmap + + + def checkConsistency(self, max_degree=None, tol=1e-12, verbose=True): + """ + Check orthonormality of the multivariate basis functions + under the container's quadrature. Also prints a table of + inner products for debugging. + + Coordinates + ---------- + max_degree : int or None + Maximum number of basis terms to check. If None, uses + all available basis terms. + tol : float + Numerical tolerance for delta_{ij}. + verbose : bool + Print results if True. + + Returns + ------- + ok : bool + True if all checks pass within tolerance. + errors : list + List of (i,j,value) where error > tol. + gram : np.ndarray + Inner product matrix (approximate identity). + """ + # Ensure quadrature is initialized + if not hasattr(self, "quadrature_map"): + nqpts_map = self.getNumQuadraturePoints() + self.initializeQuadrature(nqpts_map) + + nbasis = self.getNumBasisElements() + if max_degree is not None: + nbasis = min(nbasis, max_degree+1) + + gram = np.zeros((nbasis, nbasis)) + errors = [] + ok = True + + # Build Gram matrix + for i in range(nbasis): + for j in range(nbasis): + s = 0.0 + for q in self.quadrature_map.keys(): + psi_i = self.evalOrthoNormalBasis(i,q) + psi_j = self.evalOrthoNormalBasis(j,q) + wq = self.W(q) + s += psi_i * psi_j * wq + gram[i,j] = s + target = 1.0 if i == j else 0.0 + if abs(s - target) > tol: + ok = False + errors.append((i, j, s)) + + if verbose: + print(f"[CoordinateSystem] Gram matrix for {nbasis} basis terms:") + with np.printoptions(precision=3, suppress=True): + print(gram) + + if ok: + print(f"[CoordinateSystem] consistency check passed " + f"for {nbasis} basis terms.") + else: + print(f"[CoordinateSystem] FAILED: {len(errors)} inconsistencies found.") + + return ok, errors, gram + + def getSymmetricNonZeroIndices(self, dmapf): + nz = {} + N = self.getNumBasisElements() + for i in range(N): + dmapi = self.basistermwise_coordinate_degrees[i] + for j in range(i,N): + dmapj = self.basistermwise_coordinate_degrees[j] + smap = self.sparse(dmapi, dmapj, dmapf) + if False not in smap.values(): + dmap = Counter() + dmap.update(dmapi) + dmap.update(dmapj) + dmap.update(dmapf) + nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) + nz[(i,j)] = nqpts_map + return nz + + def getSparseJacobian(self, f, dmapf): + # rename member functions for local readability + w = lambda q : self.W(q) + psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) + + nzs = self.getSymmetricNonZeroIndices(dmapf) + N = self.getNumBasisElements() + A = np.zeros((N, N)) + for index, nqpts in nzs.items(): + self.initializeQuadrature(nqpts) + pids = self.getCoordinates().keys() + i = index[0] + j = index[1] + for q in self.quadrature_map.keys(): + val = w(q)*psiz(i,q)*psiz(j,q)*f(q) + A[i, j] += val + A[j, i] += val + return A + + def getJacobian(self, f, dmapf): + # rename member functions for local readability + w = lambda q : self.W(q) + psiz = lambda i, q : self.evalOrthoNormalBasis(i,q) + + N = self.getNumBasisElements() + A = np.zeros((N, N)) + for i in range(N): + dmapi = self.basistermwise_coordinate_degrees[i] + for j in range(N): + dmapj = self.basistermwise_coordinate_degrees[j] + + dmap = Counter() + dmap.update(dmapi) + dmap.update(dmapj) + dmap.update(dmapf) + + # add up the degree of both participating functions psizi + # and psizj to determine the total degree of integrand + nqpts_map = self.getNumQuadraturePointsFromDegree(dmap) + self.initializeQuadrature(nqpts_map) + + # Loop quadrature points + pids = self.getCoordinates().keys() + for q in self.quadrature_map.keys(): + A[i,j] += w(q)*psiz(i,q)*psiz(j,q)*f(q) + return A + + + +def index(ii): + return ii + if ii == 0: + return 0 + if ii == 1: + return 1 + if ii == 2: + return 3 + if ii == 3: + return 2 + def projectResidual(self, elem, time, res, X, v, dv, ddv): """ Project the elements deterministic residual onto stochastic From f0144da175e51fec740bcf5652cecd9c9ac20585 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 20:40:24 -0400 Subject: [PATCH 14/54] add randomized pytest for tensor and total polynomial construction --- tests/test_decomposition.py | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/test_decomposition.py diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py new file mode 100644 index 0000000..018f9f2 --- /dev/null +++ b/tests/test_decomposition.py @@ -0,0 +1,143 @@ +#=====================================================================# +# Randomized Decomposition Tests +# +# Author : Komahan Boopathy (komahan@gatech.edu) +#=====================================================================# + +import random +import sympy as sp +import pytest +from collections import Counter + +from pspace.core import ( + CoordinateFactory, + CoordinateSystem, + BasisFunctionType +) + +#=====================================================================# +# Helper : randomized coordinate factory with logging +#=====================================================================# + +def random_coordinate(cf, cid): + coord_type = random.choice(["normal", "uniform", "exponential"]) + name = f"y{cid}" + + if coord_type == "normal": + mu = random.uniform(-2.0, 2.0) + sigma = random.uniform( 0.5, 2.0) + deg = random.randint(1, 4) + coord = cf.createNormalCoordinate(cf.newCoordinateID(), name, + dict(mu=mu, sigma=sigma), deg) + print(f"[Coord {cid}] NORMAL(mu={mu:.3f}, sigma={sigma:.3f}), " + f"deg={deg}") + return coord + + elif coord_type == "uniform": + a = random.uniform(-3.0, 0.0) + b = a + random.uniform(1.0, 5.0) + deg = random.randint(1, 4) + coord = cf.createUniformCoordinate(cf.newCoordinateID(), name, + dict(a=a, b=b), deg) + print(f"[Coord {cid}] UNIFORM(a={a:.3f}, b={b:.3f}), " + f"deg={deg}") + return coord + + elif coord_type == "exponential": + mu = random.uniform(0.0, 2.0) + beta = random.uniform(0.5, 2.0) + deg = random.randint(1, 4) + coord = cf.createExponentialCoordinate(cf.newCoordinateID(), name, + dict(mu=mu, beta=beta), deg) + print(f"[Coord {cid}] EXPONENTIAL(mu={mu:.3f}, beta={beta:.3f}), " + f"deg={deg}") + return coord + +#=====================================================================# +# Helper : build random polynomial (with cross terms) +#=====================================================================# + +def random_polynomial(cs, max_deg=2, max_terms=3): + coords = list(cs.coordinates.keys()) + symbols = {cid : cs.coordinates[cid].symbol for cid in coords} + fdeg = Counter() + terms = [] + + #---------------------------------------------------------------# + # Individual terms + #---------------------------------------------------------------# + for cid in coords: + deg = random.randint(0, max_deg) + coeff = random.randint(1, 3) + terms.append(coeff * symbols[cid]**deg) + fdeg[cid] = max(fdeg.get(cid, 0), deg) + + #---------------------------------------------------------------# + # Cross terms + #---------------------------------------------------------------# + for _ in range(random.randint(0, max_terms)): + cids = random.sample(coords, k=random.randint(2, len(coords))) + coeff = random.randint(1, 3) + term = coeff + for cid in cids: + d = random.randint(1, max_deg) + term *= symbols[cid]**d + fdeg[cid] = max(fdeg.get(cid, 0), d) + terms.append(term) + + fexpr = sum(terms) + + # numeric callable : Y is dict(cid -> float) + fnum = sp.lambdify([list(symbols.values())], fexpr, "numpy") + dfunc = lambda Y: fnum([Y[cid] for cid in coords]) + + print(f"[Polynomial] f(y) = {fexpr}, degrees={dict(fdeg)}") + return fexpr, dfunc, fdeg + +#=====================================================================# +# Tests : Tensor Basis +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_tensor_basis(trial): + print(f"\n=== Trial {trial} : Tensor Basis ===") + cf = CoordinateFactory() + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) + + ncoords = random.randint(1, 3) + print(f"[Setup] Using {ncoords} coordinates") + for cid in range(ncoords): + coord = random_coordinate(cf, cid) + cs.addCoordinateAxis(coord) + + cs.initialize() + fexpr, dfunc, fdeg = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_consistency(dfunc, fdeg, + tol=1e-6, + verbose=True) + assert ok + +#=====================================================================# +# Tests : Total Degree Basis +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_total_basis(trial): + print(f"\n=== Trial {trial} : Total Degree Basis ===") + cf = CoordinateFactory() + cs = CoordinateSystem(BasisFunctionType.TOTAL_DEGREE) + + ncoords = random.randint(1, 3) + print(f"[Setup] Using {ncoords} coordinates") + for cid in range(ncoords): + coord = random_coordinate(cf, cid) + cs.addCoordinateAxis(coord) + + cs.initialize() + fexpr, dfunc, fdeg = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_consistency(dfunc, fdeg, + tol=1e-6, + verbose=True) + assert ok From 84e34156a745756e32eede01330b71e01cd6f8ac Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 21:53:13 -0400 Subject: [PATCH 15/54] fix cross terms logic for one term --- tests/test_decomposition.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index 018f9f2..2fb33c8 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -75,15 +75,16 @@ def random_polynomial(cs, max_deg=2, max_terms=3): #---------------------------------------------------------------# # Cross terms #---------------------------------------------------------------# - for _ in range(random.randint(0, max_terms)): - cids = random.sample(coords, k=random.randint(2, len(coords))) - coeff = random.randint(1, 3) - term = coeff - for cid in cids: - d = random.randint(1, max_deg) - term *= symbols[cid]**d - fdeg[cid] = max(fdeg.get(cid, 0), d) - terms.append(term) + if len(coords) >= 2: + for _ in range(random.randint(0, max_terms)): + cids = random.sample(coords, k=random.randint(2, len(coords))) + coeff = random.randint(1, 3) + term = coeff + for cid in cids: + d = random.randint(1, max_deg) + term *= symbols[cid]**d + fdeg[cid] = max(fdeg.get(cid, 0), d) + terms.append(term) fexpr = sum(terms) From 5bbac4a6631e037d763360a0b23c4209ba3aab1c Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 21:55:51 -0400 Subject: [PATCH 16/54] fix failed sympy evaluation with numeric fallback --- pspace/core.py | 542 +++++++++++++++++++++++++------------ pspace/stochastic_utils.py | 20 +- 2 files changed, 390 insertions(+), 172 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index d639966..5fc15d6 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -14,6 +14,8 @@ # External modules import math +import sympy as sp + import numpy as np np.set_printoptions(precision=3,suppress=True) @@ -22,7 +24,7 @@ from itertools import product # Local modules -from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree, sum_degrees, safe_zero_degrees +from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree, generate_basis_total_degree, sum_degrees, safe_zero_degrees from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre from .orthogonal_polynomials import unit_laguerre @@ -60,115 +62,157 @@ def __init__(self, coord_data): self.type = coord_data['coord_type'] self.distribution = coord_data['dist_type'] self.degree = coord_data['monomial_degree'] + self.symbol = sp.Symbol(self.name) # y + self.rho = None # rho(y) def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" - def getQuadraturePointsWeights(self, npoints): - pass - - def evaluateBasisFunction(self, z, d): - pass - -class ExponentialCoordinate(Coordinate): - def __init__(self, pdata): - super(ExponentialCoordinate, self).__init__(pdata) - self.dist_coords = pdata['dist_coords'] - - def evaluateBasisFunction(self, zscalar, degree): - return unit_laguerre(zscalar, degree) - - def getQuadraturePointsWeights(self, degree): - # calculate the required number of quadrature points for the - # degree - npoints = minnum_quadrature_points(degree) - - #This is based on interval [0, \inf] with the weight function f(xi) - # = \exp(-xi) which is also the standard PDF f(z) = \exp(-z). - xi, w = np.polynomial.laguerre.laggauss(npoints) - mu = self.dist_coords['mu'] - beta = self.dist_coords['beta'] + # ---- canonical mappings (must be implemented by subclasses) ---- + def to_standard(self, yscalar): + """Map physical y -> standard z (basis domain).""" + raise NotImplementedError - # scale weights to unity (Area under exp(-xi) in [0,inf] is 1.0 - w = w/1.0 + def to_physical(self, zscalar): + """Map standard z -> physical y (user domain).""" + raise NotImplementedError - # transformation of variables - y = mu + beta*xi + def weight(self): + """Return symbolic weight function ρ(y) attached to this coordinate.""" + return self.rho - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + # ---- basis eval in y (thin wrapper over z) ---- + def evaluateBasisAtY(self, yscalar, degree): + z = self.to_standard(yscalar) + return self._evaluateBasisAtZ(z, degree) - # Return quadrature point in standard space as well - z = xi + # ---- subclass provides the family polynomial in z ---- + def _evaluateBasisAtZ(self, zscalar, degree): + raise NotImplementedError - return {'yq' : y, 'zq' : z, 'wq' : w} - -class NormalCoordinate(Coordinate): - def __init__(self, pdata): - super(NormalCoordinate, self).__init__(pdata) - self.dist_coords = pdata['dist_coords'] - - def evaluateBasisFunction(self, zscalar, degree): - return unit_hermite(zscalar, degree) + # ---- subclass provides a 1D Gauss rule for the needed degree ---- + def _quad_rule_xw(self, degree): + """ + Return native Gauss rule (x_nodes, w_nodes) sized for `degree`. + Subclass chooses the correct family and normalization. + """ + raise NotImplementedError + # ---- lift native x-rule to (z,y,w) consistently ---- def getQuadraturePointsWeights(self, degree): - # calculate the required number of quadrature points for the - # degree - npoints = minnum_quadrature_points(degree) + """ + Return {'yq','zq','wq'} where: + - zq is the standard variable nodes (basis is orthonormal here) + - yq is the physical nodes (user functions evaluated here) + - wq integrates in z-domain (Jacobian absorbed) + """ + x, w = self._quad_rule_xw(degree) - # This is based on physicist unnormlized weight exp(-x*x). - x, w = np.polynomial.hermite.hermgauss(npoints) - mu = self.dist_coords['mu'] - sigma = self.dist_coords['sigma'] + # Map x -> z (family dependent); many families have z == x + z = self._x_to_z(x) - # scale weights to unity (Area under exp(-x*x) in [-inf,inf] is pi - w = w/np.sqrt(np.pi) + # Map z -> y using distribution parameters + y = np.array([self.to_physical(zz) for zz in z]) - # transformation of variables - y = mu + sigma*np.sqrt(2)*x + # Weights already correct for z (we absorb any fixed scalings in _quad_rule_xw / _x_to_z) + return {'yq': y, 'zq': z, 'wq': w} - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + # Default identity for families where x==z + def _x_to_z(self, x): + return np.asarray(x) - # Return quadrature point in standard space as well - z = (y-mu)/sigma - return {'yq' : y, 'zq' : z, 'wq' : w} +class NormalCoordinate(Coordinate): + def __init__(self, pdata): + super().__init__(pdata) + self.dist_coords = pdata['dist_coords'] # {'mu':..., 'sigma':...} + + # Standard Gaussian weight (probability density) + mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] + self.rho = 1/(sp.sqrt(2*sp.pi)*sigma) * sp.exp(-(self.symbol - mu)**2/(2*sigma**2)) + + # y <-> z + def to_standard(self, y): + mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] + return (y - mu) / s + def to_physical(self, z): + mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] + return mu + s * z + + # basis (probabilists' orthonormal Hermite in z) + def _evaluateBasisAtZ(self, z, degree): + return unit_hermite(z, degree) + + # native x,w and x->z mapping + def _quad_rule_xw(self, degree): + npts = minnum_quadrature_points(degree) + # NumPy hermgauss integrates g(x) with weight e^{-x^2} + x, w = np.polynomial.hermite.hermgauss(npts) + # Rescale weights to integrate with weight φ(z)=exp(-z^2/2)/√(2π) + # z = sqrt(2) * x => dz = sqrt(2) dx; φ(z)dz = (1/√(2π))e^{-z^2/2}dz + # hermgauss gives ∑ w_i g(x_i) ≈ ∫ g(x) e^{-x^2} dx = ∫ g(z/√2) e^{-z^2/2} (dz/√2) + # Choose to store weights for z by: w_z = w / np.sqrt(np.pi) + w = w / np.sqrt(np.pi) + return x, w + + def _x_to_z(self, x): + # For Hermite: z = sqrt(2) * x (probabilists' standardization) + return np.sqrt(2.0) * np.asarray(x) class UniformCoordinate(Coordinate): def __init__(self, pdata): - super(UniformCoordinate, self).__init__(pdata) - self.dist_coords = pdata['dist_coords'] - - def evaluateBasisFunction(self, zscalar, degree): - return unit_legendre(zscalar, degree) + super().__init__(pdata) + self.dist_coords = pdata['dist_coords'] # {'a':..., 'b':...} + + # Uniform PDF + a, b = self.dist_coords['a'], self.dist_coords['b'] + self.rho = sp.Piecewise((1/(b-a), (self.symbol>=a) & (self.symbol<=b)), (0, True)) + + # y <-> z (z in [-1,1]) + def to_standard(self, y): + a, b = self.dist_coords['a'], self.dist_coords['b'] + return (y - a) / (b - a) * 2.0 - 1.0 + def to_physical(self, z): + a, b = self.dist_coords['a'], self.dist_coords['b'] + return (b - a) * (z + 1.0) / 2.0 + a + + def _evaluateBasisAtZ(self, z, degree): + return unit_legendre(z, degree) + + def _quad_rule_xw(self, degree): + npts = minnum_quadrature_points(degree) + x, w = np.polynomial.legendre.leggauss(npts) # integrates on [-1,1] with weight 1 + # Normalize weights for ∫_{-1}^1 (1/2) g(z) dz so that ∑w == 1 + w = w / 2.0 + return x, w - def getQuadraturePointsWeights(self, degree): - # calculate the required number of quadrature points for the - # degree - npoints = minnum_quadrature_points(degree) - - # This is based on weight 1.0 on interval [-1,1] - x, w = np.polynomial.legendre.leggauss(npoints) - a = self.dist_coords['a'] - b = self.dist_coords['b'] +class ExponentialCoordinate(Coordinate): + def __init__(self, pdata): + super().__init__(pdata) + self.dist_coords = pdata['dist_coords'] # {'mu':..., 'beta':...} - # scale weights to unity - w = w/2.0 + # Shifted exponential PDF + mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] + self.rho = (1/beta) * sp.exp(-(self.symbol-mu)/beta) - # transformation of variables - y = (b-a)*x/2 + (b+a)/2 + # y <-> z (z >= 0 with e^{-z}) + def to_standard(self, y): + mu, b = self.dist_coords['mu'], self.dist_coords['beta'] + return (y - mu) / b - # assert if weights don't add up to unity - eps = np.finfo(np.float64).eps - assert((1.0 - 2.0*eps <= np.sum(w) <= 1.0 + 2.0*eps) == True) + def to_physical(self, z): + mu, b = self.dist_coords['mu'], self.dist_coords['beta'] + return mu + b * z - # Return quadrature point in standard space as well - z = (y-a)/(b-a) + def _evaluateBasisAtZ(self, z, degree): + return unit_laguerre(z, degree) - return {'yq' : y, 'zq' : z, 'wq' : w} + def _quad_rule_xw(self, degree): + npts = minnum_quadrature_points(degree) + x, w = np.polynomial.laguerre.laggauss(npts) # integrates ∫_0^∞ g(x) e^{-x} dx + # Normalize so ∑ w == 1 under the exp weight (already true up to FP) + # keep w as-is (optionally enforce ∑w ≈ 1 with a tiny renorm) + return x, w class CoordinateFactory: def __init__(self): @@ -215,27 +259,55 @@ def createExponentialCoordinate(self, coord_id, coord_name, dist_coords, max_mon class CoordinateSystem: """ - 1. Stores all coordinates (axes, dimensions) - 2. Manages basis functions - 3. Manages integrations (inner-product) along these dimensions through quadrature + 1) holds coordinates (axes), + 2) manages basis (multi-indices), + 3) integrates inner products via tensor-product quadrature. """ - def __init__(self, basis_type, verbose = False): + def __init__(self, basis_type, verbose=False): self.coordinates = {} # cid -> Coordinate self.basis_construction = basis_type self.basis = None # {basis_id: Counter({cid:deg,...})} - self.verbose = True + self.verbose = bool(verbose) def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + def basis_at_y(self, yscalar, degree: int): + """ψ(y) = ψ( z(y) ), evaluated symbolically in Y-frame.""" + z = self.to_standard(yscalar) + return self.evaluateBasisFunction(z, degree) + + def integrate(self, expr): + """∫ expr(y) ρ(y) dy over this axis' support (analytic).""" + y = self.symbol + if isinstance(self, UniformCoordinate): + a, b = self.dist_coords['a'], self.dist_coords['b'] + return sp.integrate(expr * self.weight(y), (y, a, b)) + elif isinstance(self, NormalCoordinate): + return sp.integrate(expr * self.weight(y), (y, -sp.oo, sp.oo)) + elif isinstance(coord, ExponentialCoordinate): + #mu, beta = coord.dist_coords['mu'], coord.dist_coords['beta'] + #val = sp.integrate(val, (y, float(mu), sp.oo)) + mu = self.dist_coords['mu'] + return sp.integrate(expr * self.weight(y), (y, float(mu), sp.oo)) + else: + raise NotImplementedError(f"integrate not implemented for {type(self).__name__}") + + def integrate_multivariate(expr, coords): + val = expr + for cid, coord in coords.items(): + y = coord.symbol + val = sp.integrate(val * coord.weight(y), + (y, coord.domain_lower(), coord.domain_upper())) + return val + def getNumBasisFunctions(self): - return len(self.basis) + return len(self.basis) if self.basis is not None else 0 def getNumCoordinateAxes(self): - return len(self.coordinates.keys()) + return len(self.coordinates) def getMonomialDegreeCoordinates(self): - # map cid -> configured max degree for that coordinate (basis richness) return {cid: coord.degree for cid, coord in self.coordinates.items()} def addCoordinateAxis(self, coordinate): @@ -250,144 +322,272 @@ def initialize(self): else: raise NotImplementedError("ADAPTIVE_DEGREE path not implemented") - def evaluateBasisDegrees(self, z, counter): - val = 1.0 - for cid, cdeg in counter.items(): - val *= self.coordinates[cid].evaluateBasisFunction(z[cid], cdeg) - return val + # --- basis evaluation --- - def evaluateBasisIndex(self, z, basis_id): + def evaluateBasisDegreesY(self, y_by_cid, degrees_counter): val = 1.0 - for cid, cdeg in counter.items(): - val *= self.coordinates[cid].evaluateBasisFunction(z[cid], self.basis[basis_id]) + for cid, deg in degrees_counter.items(): + val *= self.coordinates[cid].evaluateBasisAtY(y_by_cid[cid], deg) return val + def evaluateBasisIndexY(self, y_by_cid, basis_id): + degrees = self.basis[basis_id] + return self.evaluateBasisDegreesY(y_by_cid, degrees) + + # --- quadrature building --- + def print_quadrature(self, qmap): - """ - Pretty-print a quadrature map (qmap) in tabular style. - """ + if not self.verbose: + return print("Quadrature rule:") print("-" * 80) for q, data in qmap.items(): - y_str = ", ".join(f"y{cid} = {val:.4g}" for cid, val in data['Y'].items()) - z_str = ", ".join(f"z{cid} = {val:.4g}" for cid, val in data['Z'].items()) - w_str = f"W = {data['W']:.4g}" - print(f"q ={q:3d} : {y_str:<40} | {z_str:<40} | {w_str}") + y_str = ", ".join(f"y{cid}={val:.6g}" for cid, val in data['Y'].items()) + z_str = ", ".join(f"z{cid}={val:.6g}" for cid, val in data['Z'].items()) + print(f"q={q:3d} : {y_str:<36} | {z_str:<36} | W={data['W']:.6g}") print("-" * 80) + print("sum W =", sum(d['W'] for d in qmap.values())) def build_quadrature(self, degrees: Counter): """ - Build tensor-product quadrature from per-axis polynomial degree needs. - degrees: Counter({cid: p_i}) — degree of the integrand along each axis. - Uses each coordinate's 1D rule with n_i = minnum_quadrature_points(p_i). - - qmap = - { - q_index : - { - 'Y': {cid: physical_value, ...}, - 'Z': {cid: standard_value, ...}, - 'W': weight - }, - ... - } - + degrees: Counter({cid: p_i}) — per-axis polynomial degree need for the integrand. + Returns qmap: {q_index: {'Y':{cid: y}, 'Z':{cid: z}, 'W': w}} """ cids = list(self.coordinates.keys()) - q1d = {} # cid -> {'yq','zq','wq'} - npts = {} # cid -> Ni - - # obtain 1D rules per axis - for cid in cids: - p_i = int(degrees.get(cid, 0)) - one_d = self.coordinates[cid].getQuadraturePointsWeights(p_i) - q1d[cid] = one_d - npts[cid] = len(one_d['wq']) - - # tensor them - qmap = {} - ctr = 0 - ranges = [range(npts[cid]) for cid in cids] - for idx_tuple in product(*ranges): + + # 1D rules per axis + one_d = {cid: self.coordinates[cid].getQuadraturePointsWeights(int(degrees.get(cid, 0))) + for cid in cids} + sizes = {cid: len(one_d[cid]['wq']) for cid in cids} + + # Tensor product + qmap, ctr = {}, 0 + for i_tuple in product(*[range(sizes[cid]) for cid in cids]): y, z, w = {}, {}, 1.0 - for cid, i in zip(cids, idx_tuple): - y[cid] = q1d[cid]['yq'][i] - z[cid] = q1d[cid]['zq'][i] - w *= q1d[cid]['wq'][i] + for cid, i in zip(cids, i_tuple): + y[cid] = one_d[cid]['yq'][i] + z[cid] = one_d[cid]['zq'][i] + w *= one_d[cid]['wq'][i] qmap[ctr] = {'Y': y, 'Z': z, 'W': w} ctr += 1 - if self.verbose is True: - self.print_quadrature(qmap) - + self.print_quadrature(qmap) return qmap - # --- inner products & decomposition --- + # --- inner products & decomposition (Y-facing) --- def inner_product(self, f_eval, g_eval, f_deg: Counter|None=None, g_deg: Counter|None=None): """ - = ∫ f(z) g(z) ρ(z) dz, evaluated by exact quadrature if f_deg/g_deg supplied. - - f_eval, g_eval: callables taking z_by_cid: dict(cid->z) - - f_deg, g_deg: Counter({cid: degree}) describing polynomial degrees of f,g per axis. - If None, assumed 0 along each axis (safe but possibly under-integrated). + in Y-frame: + ∫ f(y) g(y) ρ(z(y)) |dz/dy| dy + We evaluate as: + sum_q f(Y_q) g(Y_q) W_q + where W_q is the z-measure weight with Jacobian absorbed. """ coord_ids = list(self.coordinates.keys()) f_deg = f_deg or safe_zero_degrees(coord_ids) g_deg = g_deg or safe_zero_degrees(coord_ids) + need = sum_degrees(f_deg, g_deg) - # Required per-axis polynomial degree for the integrand f*g - need = sum_degrees(f_deg, g_deg) - - # Build exact-enough quadrature qmap = self.build_quadrature(need) - s = 0.0 for q in qmap.values(): - z = q['Z'] - s += f_eval(z) * g_eval(z) * q['W'] + y = q['Y'] + s += f_eval(y) * g_eval(y) * q['W'] return s def inner_product_basis(self, i_id: int, j_id: int, f_eval=None, f_deg: Counter|None=None): """ - <ψ_i, f, ψ_j> with exact quadrature deduced from degrees. - - f_eval: callable(z_by_cid) or None (acts as 1.0) - - f_deg: Counter({cid: degree}) or None (treated as zeros) + <ψ_i, f, ψ_j> in Y-frame. + If f_eval is None, uses f ≡ 1. """ psi_i = self.basis[i_id] psi_j = self.basis[j_id] coord_ids = list(self.coordinates.keys()) f_deg = f_deg or safe_zero_degrees(coord_ids) + need = sum_degrees(psi_i, psi_j, f_deg) - # integrand degree per axis = deg(ψ_i)+deg(ψ_j)+deg(f) - need = sum_degrees(psi_i, psi_j, f_deg) - - # Quadrature sized to integrate exactly qmap = self.build_quadrature(need) - s = 0.0 for q in qmap.values(): - z = q['Z'] - val = self.evaluateBasisDegrees(z, psi_i) * self.evaluateBasisDegrees(z, psi_j) + y = q['Y'] + val = self.evaluateBasisDegreesY(y, psi_i) * self.evaluateBasisDegreesY(y, psi_j) if f_eval is not None: - val *= f_eval(z) + val *= f_eval(y) s += val * q['W'] return s def decompose(self, f_eval, f_deg: Counter): """ - Coefficients c_k = , with quadrature sized from deg(f) + deg(ψ_k). + Coefficients c_k = in Y-frame. + need = deg(f) + deg(ψ_k) per axis. Returns dict {basis_id: coefficient}. """ coeffs = {} for k, psi_k in self.basis.items(): - # per-axis degree need = deg(f) + deg(ψ_k) need = sum_degrees(f_deg, psi_k) qmap = self.build_quadrature(need) s = 0.0 for q in qmap.values(): - z = q['Z'] - s += f_eval(z) * self.evaluateBasisDegrees(z, psi_k) * q['W'] + y = q['Y'] + s += f_eval(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] coeffs[k] = s + return coeffs + + def decompose_analytic(self, f_eval, f_deg: Counter): + """ + Analytic decomposition using SymPy: + c_k = ∫ f(y) ψ_k(y) ρ(y) dy + Arguments: + f_expr : sympy expression in physical variables y + f_deg : Counter({cid: degree}) polynomial degrees of f + Returns: + coeffs : dict {basis_id: sympy expression or numeric constant} + """ + """ + Analytic decomposition using SymPy integration. + f_eval : callable(Y) where Y is dict(cid -> sympy.Symbol) + f_deg : Counter of polynomial degrees for f + """ + # Map from coordinate ids to sympy symbols + symbols = {cid: coord.symbol for cid, coord in self.coordinates.items()} + + # Convert user-supplied callable into a sympy expression + f_expr = f_eval(symbols) + + coeffs = {} + coords = self.coordinates + + # build measure = product of ρ(y) for all coordinates + measure = 1 + symbols = [] + for cid, coord in coords.items(): + measure *= coord.weight() + symbols.append(coord.symbol) + + for k, psi_k in self.basis.items(): + # build ψ_k(y) as symbolic product + psi_expr = 1 + for cid, deg in psi_k.items(): + z = coords[cid].to_standard(coords[cid].symbol) # symbolic z(y) + psi_expr *= coords[cid]._evaluateBasisAtZ(z, deg) + + # integrand in physical space + integrand = f_expr * psi_expr * measure + + # integrate over each coordinate's support + val = integrand + for cid, coord in coords.items(): + y = coord.symbol + if isinstance(coord, UniformCoordinate): + a, b = coord.dist_coords['a'], coord.dist_coords['b'] + val = sp.integrate(val, (y, a, b)) + elif isinstance(coord, NormalCoordinate): + val = sp.integrate(val, (y, -sp.oo, sp.oo)) + elif isinstance(coord, ExponentialCoordinate): + mu, beta = coord.dist_coords['mu'], coord.dist_coords['beta'] + val = sp.integrate(val, (y, float(mu), sp.oo)) + else: + raise NotImplementedError(f"Analytic integration not set up for {coord}") + + coeffs[k] = sp.simplify(val) + + return coeffs + + def decompose_analytic_fix(self, f_eval, f_deg: Counter): + """ + Analytic decomposition using SymPy: + c_k = ∫ f(y) ψ_k(y) ρ(y) dy over the domain. + + Arguments + --------- + f_eval : callable({cid: sympy.Symbol}) -> sympy.Expr + User-supplied function of physical variables y. + f_deg : Counter({cid: degree}) + Polynomial degrees of f, used for quadrature in numeric path + (kept for symmetry, not needed by sympy integration). + + Returns + ------- + coeffs : dict {basis_id: sympy.Expr or constant} + """ + coords = self.coordinates + symbols = {cid: coord.symbol for cid, coord in coords.items()} + + # Convert user function into sympy expression + f_expr = f_eval(symbols) + + coeffs = {} + for k, psi_k in self.basis.items(): + # Build basis polynomial ψ_k(y) = ∏ ψ_{deg}(y_cid) + psi_expr = 1 + for cid, deg in psi_k.items(): + y = coords[cid].symbol + psi_expr *= coords[cid].basis_at_y(y, deg) + + # Integrand in physical space + integrand = f_expr * psi_expr + + # Perform nested univariate integrals, inserting weight per axis + val = integrand + for cid, coord in coords.items(): + y = coord.symbol + w = coord.weight(y) + a, b = coord.domain() + val = sp.integrate(val * w, (y, a, b)) + + coeffs[k] = sp.simplify(val) + + return coeffs + + def check_decomposition_consistency(self, f_eval, f_deg: Counter, tol=1e-10, verbose=True): + """ + Cross-check between numerical and analytic decomposition. + + Parameters + ---------- + f_eval : callable({cid: variable}) -> numeric (for numerical) or sympy.Expr (for analytic) + User-supplied function in physical variables y. + f_deg : Counter({cid: degree}) + Degrees of polynomial terms in f. + tol : float + Numerical tolerance for comparison. + verbose : bool + Print detailed differences if True. + + Returns + ------- + ok : bool + True if all coefficients agree within tolerance. + diffs : dict {basis_id: (numeric_val, analytic_val, error)} + """ + # numeric decomposition + coeffs_num = self.decompose(f_eval, f_deg) + + # analytic decomposition + coeffs_sym = self.decompose_analytic(f_eval, f_deg) + + diffs = {} + ok = True + for k in coeffs_num.keys(): + num_val = float(coeffs_num[k]) + try: + # sympy may return symbolic, so convert to float + ana_val = float(coeffs_sym[k].evalf()) + except TypeError: + print(f"[Warning] Analytic coefficient for basis {k} not evaluable: using numeric evaluation") + ana_val = float(sp.N(coeffs_sym[k], 15)) + err = abs(num_val - ana_val) + if err > tol: + ok = False + diffs[k] = (num_val, ana_val, err) + + if verbose: + print(f"[ConsistencyCheck] {'PASSED' if ok else 'FAILED'} with tol={tol}") + for k, (n, a, e) in diffs.items(): + print(f"Basis {k}: num={n:.6g}, ana={a:.6g}, err={e:.2e}") + + return ok, diffs diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 60aa91e..9b31bb0 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -74,7 +74,25 @@ def generate_basis_tensor_degree(max_degree_params): return term_polynomial_degree -def generate_basis_total_degree(max_degree_params): + +def generate_basis_total_degree(max_degrees): + """ + Build total-degree basis: all multi-indices with sum(deg_i) <= max. + max_degrees : dict {cid: max_degree} + Returns : dict {basis_id: Counter({cid:deg,...})} + """ + cids = list(max_degrees.keys()) + degrees = list(max_degrees.values()) + basis = {} + k = 0 + + for deg_tuple in product(*[range(d+1) for d in degrees]): + if sum(deg_tuple) <= max(degrees): # total degree filter + basis[k] = Counter({cid: d for cid, d in zip(cids, deg_tuple)}) + k += 1 + return basis + +def generate_basis_total_degree_old(max_degree_params): """ Construct a total-degree index map for basis functions. From 3fedc5e14d09187433de5aed24e3c522f3a1d3b9 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 22:21:04 -0400 Subject: [PATCH 17/54] test sparsity in draft mode --- tests/test_sparsity.py | 111 ++++++++++++++++++++++++++++++++++------- 1 file changed, 94 insertions(+), 17 deletions(-) diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index 634594e..e71212e 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -24,34 +24,112 @@ from pspace.plotter import plot_jacobian -if __name__ == '__main__': +def check_hermite_functions(): + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE, False) + cf = CoordinateFactory() + + y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 3) + cs.addCoordinateAxis(y0) + + cs.initialize() + + # decompose a vector to obtain coefficients + func = lambda q : (y(q,0)**4 * y(q,1)) + (y(q,0) * y(q,2)) + (y(q,2)**3) + + dfunc = lambda z: 1.0 + z[0]**2; fdegs = Counter({0:3}) + coeffs = cs.decompose(dfunc, fdegs) + print(coeffs) + +if __name__ == "__main__": + # Build a coordinate system + cf = CoordinateFactory() cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) + + y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu=0.0, sigma=1.0), 3) + y1 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y1', dict(a=-1, b=1), 2) + + cs.addCoordinateAxis(y0) + cs.addCoordinateAxis(y1) + + cs.initialize() + + # Function: f(y) = 1 + y0^2 + y1 + dfunc = lambda Y: 1.0 + Y[0]**2 + Y[1] + fdegs = Counter({0:2, 1:1}) + + ok, diffs = cs.check_decomposition_consistency(dfunc, fdegs, tol=1e-8, verbose=True) + + stop + + + # Build a simple 1D Gaussian coordinate system + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE, verbose=False) + cf = CoordinateFactory() + + #y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu=0.0, sigma=1.0), 4) + #y0 = cf.createExponentialCoordinate(cf.newCoordinateID(), 'y0', dict(mu = +6.0, beta = 1.0), 3) + y0 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y0', dict(a = -5.0, b = 4.0), 3) + + cs.addCoordinateAxis(y0) + cs.initialize() + + # f(y) = 1 + y^2 (degrees: up to 2 on axis 0) + dfunc = lambda Y: 1.0 - Y[0]**0 + fdegs = Counter({0:0}) + coeffs = cs.decompose(dfunc, fdegs) + print(coeffs) # coefficients in the Y-frame basis + + import sympy as sp + coeffs = cs.decompose_analytic(dfunc, fdegs) + #print(coeffs) # coefficients in the Y-frame basis + #print(coeffs) + print({k: sp.simplify(v) for k, v in coeffs.items()}) + + nbasis = cs.getNumBasisFunctions() + A = np.zeros((nbasis, nbasis)) + for ii in range(nbasis): + for jj in range(nbasis): + A[ii,jj] = cs.inner_product_basis(ii, jj) #, f_eval, f_deg) + print(A) + + stop + + check_hermite_functions() + stop + + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE, False) cf = CoordinateFactory() #y0 = cf.createNormalCoordinate(0, 'y0', dict(mu=0.0, sigma=1.0), max_monomial_dof=1) #y1 = cf.createUniformCoordinate(1, 'y1', dict(a=-1, b=1), max_monomial_dof=1) - y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 3) - y1 = cf.createExponentialCoordinate(cf.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 3) - y2 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 3) + y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 0) + #y1 = cf.createExponentialCoordinate(cf.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 1) + #y2 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 1) cs.addCoordinateAxis(y0) - cs.addCoordinateAxis(y1) - cs.addCoordinateAxis(y2) + #cs.addCoordinateAxis(y1) + #cs.addCoordinateAxis(y2) cs.initialize() # decompose a vector to obtain coefficients - f_eval = lambda z: 2 + 3*z[0] - z[1] - f_deg = Counter({0:1, 1:1, 2:0}) - coeffs = cs.decompose(f_eval, f_deg) + f = lambda z: z[0] + fdeg = Counter({0:0}) + coeffs = cs.decompose(f, f) print(coeffs) + stop + + # form the mass matrix + + + print(A, np.linalg.eigvals(A)) + # inner product of two basis entries with f #val = cs.inner_product_basis(i_id=1, j_id=0, f_eval=f_eval, f_deg=f_deg) #print(val) - stop # Domain Definition(ADAPTIVE, FIXED={TENSOR, COMPLETE}) @@ -64,12 +142,12 @@ # Random coordinates y0 = cfactory.createNormalCoordinate(cfactory.newCoordinateID(), 'y0', dict(mu = -4.0, sigma = 0.5), 3) - y1 = cfactory.createExponentialCoordinate(cfactory.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 3) - y2 = cfactory.createUniformCoordinate(cfactory.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 3) + #y1 = cfactory.createExponentialCoordinate(cfactory.newCoordinateID(), 'y1', dict(mu = +6.0, beta = 1.0), 3) + #y2 = cfactory.createUniformCoordinate(cfactory.newCoordinateID(), 'y2', dict(a = -5.0, b = 4.0), 3) print(y0) - print(y1) - print(y2) + #print(y1) + #print(y2) # Deterministic coordinates (uniform distribution & monomial degree = 0) # x1 = cfactory.createUniformCoordinate('x1', dict(a=-2.0, b=2.0), 0) # space @@ -81,8 +159,8 @@ csystem = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) csystem.addCoordinateAxis(y0) - csystem.addCoordinateAxis(y1) - csystem.addCoordinateAxis(y2) + #csystem.addCoordinateAxis(y1) + #csystem.addCoordinateAxis(y2) csystem.initialize() @@ -91,7 +169,6 @@ for i in range(csystem.getNumBasisFunctions()): print(csystem.evaluateBasis([0.0, 0.0, 0.0], csystem.basis[i])) - # check inner_product(psi_i, psi_j) # check decompose(function) From f1e4d569f9ceb616ba610a99b709f02a3cc06563 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 22:49:53 -0400 Subject: [PATCH 18/54] format core.py --- pspace/core.py | 340 +++++++++++++++++++++++-------------------------- 1 file changed, 156 insertions(+), 184 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 5fc15d6..7c40dc6 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -11,24 +11,39 @@ # Author : Komahan Boopathy (komahan.boopathy@gmail.com) #=====================================================================# +#=====================================================================# # External modules -import math +#=====================================================================# +import math import sympy as sp - import numpy as np -np.set_printoptions(precision=3,suppress=True) +np.set_printoptions(precision=3, suppress=True) from collections import Counter from enum import Enum from itertools import product +#=====================================================================# # Local modules -from .stochastic_utils import minnum_quadrature_points, generate_basis_tensor_degree, generate_basis_total_degree, sum_degrees, safe_zero_degrees +#=====================================================================# + +from .stochastic_utils import ( + minnum_quadrature_points, + generate_basis_tensor_degree, + generate_basis_total_degree, + sum_degrees, + safe_zero_degrees +) + from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre from .orthogonal_polynomials import unit_laguerre +#=====================================================================# +# Enums +#=====================================================================# + class CoordinateType(Enum): """ DOMAIN TYPES @@ -37,6 +52,7 @@ class CoordinateType(Enum): SPATIAL = 2 TEMPORAL = 3 + class DistributionType(Enum): """ GEOMETRY: DENSITY DISTRIBUTION @@ -47,6 +63,7 @@ class DistributionType(Enum): POISSON = 3 BINORMAL = 4 + class BasisFunctionType(Enum): """ VECTOR-SPACE CONSTRUCTION METHODS @@ -55,6 +72,10 @@ class BasisFunctionType(Enum): TOTAL_DEGREE = 1 ADAPTIVE_DEGREE = 2 +#=====================================================================# +# Coordinate Base Class +#=====================================================================# + class Coordinate(object): def __init__(self, coord_data): self.id = coord_data['coord_id'] @@ -62,13 +83,15 @@ def __init__(self, coord_data): self.type = coord_data['coord_type'] self.distribution = coord_data['dist_type'] self.degree = coord_data['monomial_degree'] - self.symbol = sp.Symbol(self.name) # y - self.rho = None # rho(y) + self.symbol = sp.Symbol(self.name) # y + self.rho = None # rho(y) def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" - # ---- canonical mappings (must be implemented by subclasses) ---- + #-----------------------------------------------------------------# + # canonical mappings (must be implemented by subclasses) + #-----------------------------------------------------------------# def to_standard(self, yscalar): """Map physical y -> standard z (basis domain).""" raise NotImplementedError @@ -81,16 +104,22 @@ def weight(self): """Return symbolic weight function ρ(y) attached to this coordinate.""" return self.rho - # ---- basis eval in y (thin wrapper over z) ---- + #-----------------------------------------------------------------# + # basis eval in y (thin wrapper over z) + #-----------------------------------------------------------------# def evaluateBasisAtY(self, yscalar, degree): z = self.to_standard(yscalar) return self._evaluateBasisAtZ(z, degree) - # ---- subclass provides the family polynomial in z ---- + #-----------------------------------------------------------------# + # subclass provides the family polynomial in z + #-----------------------------------------------------------------# def _evaluateBasisAtZ(self, zscalar, degree): raise NotImplementedError - # ---- subclass provides a 1D Gauss rule for the needed degree ---- + #-----------------------------------------------------------------# + # subclass provides a 1D Gauss rule for the needed degree + #-----------------------------------------------------------------# def _quad_rule_xw(self, degree): """ Return native Gauss rule (x_nodes, w_nodes) sized for `degree`. @@ -98,7 +127,9 @@ def _quad_rule_xw(self, degree): """ raise NotImplementedError - # ---- lift native x-rule to (z,y,w) consistently ---- + #-----------------------------------------------------------------# + # lift native x-rule to (z,y,w) consistently + #-----------------------------------------------------------------# def getQuadraturePointsWeights(self, degree): """ Return {'yq','zq','wq'} where: @@ -114,64 +145,58 @@ def getQuadraturePointsWeights(self, degree): # Map z -> y using distribution parameters y = np.array([self.to_physical(zz) for zz in z]) - # Weights already correct for z (we absorb any fixed scalings in _quad_rule_xw / _x_to_z) return {'yq': y, 'zq': z, 'wq': w} + #-----------------------------------------------------------------# # Default identity for families where x==z + #-----------------------------------------------------------------# def _x_to_z(self, x): return np.asarray(x) +#=====================================================================# +# Coordinate Implementations +#=====================================================================# class NormalCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] # {'mu':..., 'sigma':...} + self.dist_coords = pdata['dist_coords'] - # Standard Gaussian weight (probability density) mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] - self.rho = 1/(sp.sqrt(2*sp.pi)*sigma) * sp.exp(-(self.symbol - mu)**2/(2*sigma**2)) + self.rho = 1/(sp.sqrt(2*sp.pi)*sigma) * sp.exp(-(self.symbol - mu)**2/(2*sigma**2)) - # y <-> z def to_standard(self, y): mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] return (y - mu) / s + def to_physical(self, z): mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] return mu + s * z - # basis (probabilists' orthonormal Hermite in z) def _evaluateBasisAtZ(self, z, degree): return unit_hermite(z, degree) - # native x,w and x->z mapping def _quad_rule_xw(self, degree): - npts = minnum_quadrature_points(degree) - # NumPy hermgauss integrates g(x) with weight e^{-x^2} - x, w = np.polynomial.hermite.hermgauss(npts) - # Rescale weights to integrate with weight φ(z)=exp(-z^2/2)/√(2π) - # z = sqrt(2) * x => dz = sqrt(2) dx; φ(z)dz = (1/√(2π))e^{-z^2/2}dz - # hermgauss gives ∑ w_i g(x_i) ≈ ∫ g(x) e^{-x^2} dx = ∫ g(z/√2) e^{-z^2/2} (dz/√2) - # Choose to store weights for z by: w_z = w / np.sqrt(np.pi) - w = w / np.sqrt(np.pi) + npts = minnum_quadrature_points(degree) + x, w = np.polynomial.hermite.hermgauss(npts) + w = w / np.sqrt(np.pi) return x, w def _x_to_z(self, x): - # For Hermite: z = sqrt(2) * x (probabilists' standardization) return np.sqrt(2.0) * np.asarray(x) class UniformCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] # {'a':..., 'b':...} + self.dist_coords = pdata['dist_coords'] - # Uniform PDF - a, b = self.dist_coords['a'], self.dist_coords['b'] + a, b = self.dist_coords['a'], self.dist_coords['b'] self.rho = sp.Piecewise((1/(b-a), (self.symbol>=a) & (self.symbol<=b)), (0, True)) - # y <-> z (z in [-1,1]) def to_standard(self, y): a, b = self.dist_coords['a'], self.dist_coords['b'] return (y - a) / (b - a) * 2.0 - 1.0 + def to_physical(self, z): a, b = self.dist_coords['a'], self.dist_coords['b'] return (b - a) * (z + 1.0) / 2.0 + a @@ -181,21 +206,18 @@ def _evaluateBasisAtZ(self, z, degree): def _quad_rule_xw(self, degree): npts = minnum_quadrature_points(degree) - x, w = np.polynomial.legendre.leggauss(npts) # integrates on [-1,1] with weight 1 - # Normalize weights for ∫_{-1}^1 (1/2) g(z) dz so that ∑w == 1 - w = w / 2.0 + x, w = np.polynomial.legendre.leggauss(npts) + w = w / 2.0 return x, w class ExponentialCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] # {'mu':..., 'beta':...} + self.dist_coords = pdata['dist_coords'] - # Shifted exponential PDF mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] - self.rho = (1/beta) * sp.exp(-(self.symbol-mu)/beta) + self.rho = (1/beta) * sp.exp(-(self.symbol - mu)/beta) - # y <-> z (z >= 0 with e^{-z}) def to_standard(self, y): mu, b = self.dist_coords['mu'], self.dist_coords['beta'] return (y - mu) / b @@ -209,18 +231,20 @@ def _evaluateBasisAtZ(self, z, degree): def _quad_rule_xw(self, degree): npts = minnum_quadrature_points(degree) - x, w = np.polynomial.laguerre.laggauss(npts) # integrates ∫_0^∞ g(x) e^{-x} dx - # Normalize so ∑ w == 1 under the exp weight (already true up to FP) - # keep w as-is (optionally enforce ∑w ≈ 1 with a tiny renorm) + x, w = np.polynomial.laguerre.laggauss(npts) return x, w +#=====================================================================# +# Coordinate Factory +#=====================================================================# + class CoordinateFactory: def __init__(self): self.next_coord_id = 0 return def newCoordinateID(self): - pid = self.next_coord_id + pid = self.next_coord_id self.next_coord_id = self.next_coord_id + 1 return pid @@ -232,7 +256,6 @@ def createNormalCoordinate(self, coord_id, coord_name, dist_coords, max_monomial pdata['dist_type'] = DistributionType.NORMAL pdata['dist_coords'] = dist_coords pdata['monomial_degree'] = max_monomial_dof - pdata['coord_id'] = coord_id return NormalCoordinate(pdata) def createUniformCoordinate(self, coord_id, coord_name, dist_coords, max_monomial_dof): @@ -243,7 +266,6 @@ def createUniformCoordinate(self, coord_id, coord_name, dist_coords, max_monomia pdata['dist_type'] = DistributionType.UNIFORM pdata['dist_coords'] = dist_coords pdata['monomial_degree'] = max_monomial_dof - pdata['coord_id'] = coord_id return UniformCoordinate(pdata) def createExponentialCoordinate(self, coord_id, coord_name, dist_coords, max_monomial_dof): @@ -254,9 +276,12 @@ def createExponentialCoordinate(self, coord_id, coord_name, dist_coords, max_mon pdata['dist_type'] = DistributionType.EXPONENTIAL pdata['dist_coords'] = dist_coords pdata['monomial_degree'] = max_monomial_dof - pdata['coord_id'] = coord_id return ExponentialCoordinate(pdata) +#=====================================================================# +# Coordinate System +#=====================================================================# + class CoordinateSystem: """ 1) holds coordinates (axes), @@ -272,35 +297,15 @@ def __init__(self, basis_type, verbose=False): def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + #-----------------------------------------------------------------# + # Basis and initialization + #-----------------------------------------------------------------# + def basis_at_y(self, yscalar, degree: int): - """ψ(y) = ψ( z(y) ), evaluated symbolically in Y-frame.""" + """ψ(y) = ψ(z(y)), evaluated symbolically in Y-frame.""" z = self.to_standard(yscalar) return self.evaluateBasisFunction(z, degree) - def integrate(self, expr): - """∫ expr(y) ρ(y) dy over this axis' support (analytic).""" - y = self.symbol - if isinstance(self, UniformCoordinate): - a, b = self.dist_coords['a'], self.dist_coords['b'] - return sp.integrate(expr * self.weight(y), (y, a, b)) - elif isinstance(self, NormalCoordinate): - return sp.integrate(expr * self.weight(y), (y, -sp.oo, sp.oo)) - elif isinstance(coord, ExponentialCoordinate): - #mu, beta = coord.dist_coords['mu'], coord.dist_coords['beta'] - #val = sp.integrate(val, (y, float(mu), sp.oo)) - mu = self.dist_coords['mu'] - return sp.integrate(expr * self.weight(y), (y, float(mu), sp.oo)) - else: - raise NotImplementedError(f"integrate not implemented for {type(self).__name__}") - - def integrate_multivariate(expr, coords): - val = expr - for cid, coord in coords.items(): - y = coord.symbol - val = sp.integrate(val * coord.weight(y), - (y, coord.domain_lower(), coord.domain_upper())) - return val - def getNumBasisFunctions(self): return len(self.basis) if self.basis is not None else 0 @@ -322,19 +327,9 @@ def initialize(self): else: raise NotImplementedError("ADAPTIVE_DEGREE path not implemented") - # --- basis evaluation --- - - def evaluateBasisDegreesY(self, y_by_cid, degrees_counter): - val = 1.0 - for cid, deg in degrees_counter.items(): - val *= self.coordinates[cid].evaluateBasisAtY(y_by_cid[cid], deg) - return val - - def evaluateBasisIndexY(self, y_by_cid, basis_id): - degrees = self.basis[basis_id] - return self.evaluateBasisDegreesY(y_by_cid, degrees) - - # --- quadrature building --- + #-----------------------------------------------------------------# + # Quadrature + #-----------------------------------------------------------------# def print_quadrature(self, qmap): if not self.verbose: @@ -350,17 +345,14 @@ def print_quadrature(self, qmap): def build_quadrature(self, degrees: Counter): """ - degrees: Counter({cid: p_i}) — per-axis polynomial degree need for the integrand. - Returns qmap: {q_index: {'Y':{cid: y}, 'Z':{cid: z}, 'W': w}} + degrees : Counter({cid: p_i}) — per-axis polynomial degree + Returns : {q_index: {'Y':{cid:y}, 'Z':{cid:z}, 'W':w}} """ - cids = list(self.coordinates.keys()) - - # 1D rules per axis - one_d = {cid: self.coordinates[cid].getQuadraturePointsWeights(int(degrees.get(cid, 0))) - for cid in cids} + cids = list(self.coordinates.keys()) + one_d = {cid: self.coordinates[cid].getQuadraturePointsWeights( + int(degrees.get(cid, 0))) for cid in cids} sizes = {cid: len(one_d[cid]['wq']) for cid in cids} - # Tensor product qmap, ctr = {}, 0 for i_tuple in product(*[range(sizes[cid]) for cid in cids]): y, z, w = {}, {}, 1.0 @@ -369,60 +361,77 @@ def build_quadrature(self, degrees: Counter): z[cid] = one_d[cid]['zq'][i] w *= one_d[cid]['wq'][i] qmap[ctr] = {'Y': y, 'Z': z, 'W': w} - ctr += 1 + ctr += 1 self.print_quadrature(qmap) return qmap - # --- inner products & decomposition (Y-facing) --- + #-----------------------------------------------------------------# + # Basis evaluation + #-----------------------------------------------------------------# - def inner_product(self, f_eval, g_eval, f_deg: Counter|None=None, g_deg: Counter|None=None): + def evaluateBasisDegreesY(self, y_by_cid, degrees_counter): + val = 1.0 + for cid, deg in degrees_counter.items(): + val *= self.coordinates[cid].evaluateBasisAtY(y_by_cid[cid], deg) + return val + + def evaluateBasisIndexY(self, y_by_cid, basis_id): + degrees = self.basis[basis_id] + return self.evaluateBasisDegreesY(y_by_cid, degrees) + + #-----------------------------------------------------------------# + # Inner products + #-----------------------------------------------------------------# + + def inner_product(self, f_eval, g_eval, + f_deg: Counter|None=None, + g_deg: Counter|None=None): """ - in Y-frame: - ∫ f(y) g(y) ρ(z(y)) |dz/dy| dy - We evaluate as: - sum_q f(Y_q) g(Y_q) W_q - where W_q is the z-measure weight with Jacobian absorbed. + in Y-frame = ∑ f(Y_q) g(Y_q) W_q """ coord_ids = list(self.coordinates.keys()) - f_deg = f_deg or safe_zero_degrees(coord_ids) - g_deg = g_deg or safe_zero_degrees(coord_ids) - need = sum_degrees(f_deg, g_deg) + f_deg = f_deg or safe_zero_degrees(coord_ids) + g_deg = g_deg or safe_zero_degrees(coord_ids) + need = sum_degrees(f_deg, g_deg) qmap = self.build_quadrature(need) - s = 0.0 + s = 0.0 for q in qmap.values(): - y = q['Y'] + y = q['Y'] s += f_eval(y) * g_eval(y) * q['W'] return s - def inner_product_basis(self, i_id: int, j_id: int, f_eval=None, f_deg: Counter|None=None): + def inner_product_basis(self, i_id: int, j_id: int, + f_eval=None, f_deg: Counter|None=None): """ <ψ_i, f, ψ_j> in Y-frame. - If f_eval is None, uses f ≡ 1. """ psi_i = self.basis[i_id] psi_j = self.basis[j_id] coord_ids = list(self.coordinates.keys()) - f_deg = f_deg or safe_zero_degrees(coord_ids) - need = sum_degrees(psi_i, psi_j, f_deg) + f_deg = f_deg or safe_zero_degrees(coord_ids) + need = sum_degrees(psi_i, psi_j, f_deg) qmap = self.build_quadrature(need) - s = 0.0 + s = 0.0 for q in qmap.values(): - y = q['Y'] - val = self.evaluateBasisDegreesY(y, psi_i) * self.evaluateBasisDegreesY(y, psi_j) + y = q['Y'] + val = (self.evaluateBasisDegreesY(y, psi_i) * + self.evaluateBasisDegreesY(y, psi_j)) if f_eval is not None: val *= f_eval(y) - s += val * q['W'] + s += val * q['W'] return s + #-----------------------------------------------------------------# + # Decomposition + #-----------------------------------------------------------------# + def decompose(self, f_eval, f_deg: Counter): """ Coefficients c_k = in Y-frame. - need = deg(f) + deg(ψ_k) per axis. - Returns dict {basis_id: coefficient}. """ coeffs = {} for k, psi_k in self.basis.items(): @@ -441,63 +450,46 @@ def decompose_analytic(self, f_eval, f_deg: Counter): """ Analytic decomposition using SymPy: c_k = ∫ f(y) ψ_k(y) ρ(y) dy - Arguments: - f_expr : sympy expression in physical variables y - f_deg : Counter({cid: degree}) polynomial degrees of f - Returns: - coeffs : dict {basis_id: sympy expression or numeric constant} - """ - """ - Analytic decomposition using SymPy integration. - f_eval : callable(Y) where Y is dict(cid -> sympy.Symbol) - f_deg : Counter of polynomial degrees for f """ - # Map from coordinate ids to sympy symbols - symbols = {cid: coord.symbol for cid, coord in self.coordinates.items()} - - # Convert user-supplied callable into a sympy expression - f_expr = f_eval(symbols) + coords = self.coordinates + symbols = {cid: coord.symbol for cid, coord in coords.items()} + f_expr = f_eval(symbols) coeffs = {} - coords = self.coordinates - - # build measure = product of ρ(y) for all coordinates - measure = 1 - symbols = [] - for cid, coord in coords.items(): - measure *= coord.weight() - symbols.append(coord.symbol) - for k, psi_k in self.basis.items(): - # build ψ_k(y) as symbolic product psi_expr = 1 for cid, deg in psi_k.items(): - z = coords[cid].to_standard(coords[cid].symbol) # symbolic z(y) + z = coords[cid].to_standard(coords[cid].symbol) psi_expr *= coords[cid]._evaluateBasisAtZ(z, deg) - # integrand in physical space - integrand = f_expr * psi_expr * measure + integrand = f_expr * psi_expr * sp.Mul(*[c.weight() + for c in coords.values()]) - # integrate over each coordinate's support val = integrand for cid, coord in coords.items(): y = coord.symbol if isinstance(coord, UniformCoordinate): a, b = coord.dist_coords['a'], coord.dist_coords['b'] - val = sp.integrate(val, (y, a, b)) + val = sp.integrate(val, (y, a, b)) elif isinstance(coord, NormalCoordinate): - val = sp.integrate(val, (y, -sp.oo, sp.oo)) + val = sp.integrate(val, (y, -sp.oo, sp.oo)) elif isinstance(coord, ExponentialCoordinate): - mu, beta = coord.dist_coords['mu'], coord.dist_coords['beta'] + mu = coord.dist_coords['mu'] val = sp.integrate(val, (y, float(mu), sp.oo)) else: - raise NotImplementedError(f"Analytic integration not set up for {coord}") + raise NotImplementedError( + f"Analytic integration not set up for {coord}" + ) coeffs[k] = sp.simplify(val) return coeffs - def decompose_analytic_fix(self, f_eval, f_deg: Counter): + #-----------------------------------------------------------------# + # Analytic decomposition (to fix) + #-----------------------------------------------------------------# + + def decompose_analytic_to_fix(self, f_eval, f_deg: Counter): """ Analytic decomposition using SymPy: c_k = ∫ f(y) ψ_k(y) ρ(y) dy over the domain. @@ -507,78 +499,57 @@ def decompose_analytic_fix(self, f_eval, f_deg: Counter): f_eval : callable({cid: sympy.Symbol}) -> sympy.Expr User-supplied function of physical variables y. f_deg : Counter({cid: degree}) - Polynomial degrees of f, used for quadrature in numeric path - (kept for symmetry, not needed by sympy integration). + Polynomial degrees of f (used in numeric path). Returns ------- coeffs : dict {basis_id: sympy.Expr or constant} """ - coords = self.coordinates + coords = self.coordinates symbols = {cid: coord.symbol for cid, coord in coords.items()} - - # Convert user function into sympy expression - f_expr = f_eval(symbols) + f_expr = f_eval(symbols) coeffs = {} for k, psi_k in self.basis.items(): # Build basis polynomial ψ_k(y) = ∏ ψ_{deg}(y_cid) psi_expr = 1 for cid, deg in psi_k.items(): - y = coords[cid].symbol + y = coords[cid].symbol psi_expr *= coords[cid].basis_at_y(y, deg) # Integrand in physical space integrand = f_expr * psi_expr - # Perform nested univariate integrals, inserting weight per axis + # Nested univariate integrals with weight per axis val = integrand for cid, coord in coords.items(): y = coord.symbol w = coord.weight(y) a, b = coord.domain() - val = sp.integrate(val * w, (y, a, b)) + val = sp.integrate(val * w, (y, a, b)) coeffs[k] = sp.simplify(val) return coeffs - def check_decomposition_consistency(self, f_eval, f_deg: Counter, tol=1e-10, verbose=True): - """ - Cross-check between numerical and analytic decomposition. - - Parameters - ---------- - f_eval : callable({cid: variable}) -> numeric (for numerical) or sympy.Expr (for analytic) - User-supplied function in physical variables y. - f_deg : Counter({cid: degree}) - Degrees of polynomial terms in f. - tol : float - Numerical tolerance for comparison. - verbose : bool - Print detailed differences if True. + #-----------------------------------------------------------------# + # Consistency check + #-----------------------------------------------------------------# - Returns - ------- - ok : bool - True if all coefficients agree within tolerance. - diffs : dict {basis_id: (numeric_val, analytic_val, error)} + def check_decomposition_consistency(self, f_eval, f_deg: Counter, + tol=1e-10, verbose=True): + """ + Cross-check numerical vs analytic decomposition. """ - # numeric decomposition coeffs_num = self.decompose(f_eval, f_deg) - - # analytic decomposition coeffs_sym = self.decompose_analytic(f_eval, f_deg) - diffs = {} - ok = True + diffs, ok = {}, True for k in coeffs_num.keys(): num_val = float(coeffs_num[k]) try: - # sympy may return symbolic, so convert to float ana_val = float(coeffs_sym[k].evalf()) except TypeError: - print(f"[Warning] Analytic coefficient for basis {k} not evaluable: using numeric evaluation") ana_val = float(sp.N(coeffs_sym[k], 15)) err = abs(num_val - ana_val) if err > tol: @@ -586,7 +557,8 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, tol=1e-10, ver diffs[k] = (num_val, ana_val, err) if verbose: - print(f"[ConsistencyCheck] {'PASSED' if ok else 'FAILED'} with tol={tol}") + print(f"[ConsistencyCheck] {'PASSED' if ok else 'FAILED'} " + f"with tol={tol}") for k, (n, a, e) in diffs.items(): print(f"Basis {k}: num={n:.6g}, ana={a:.6g}, err={e:.2e}") From 17a4e2d2ccfc47582677488cab2d2baecaead2c5 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 23:16:31 -0400 Subject: [PATCH 19/54] tamizh banner --- pspace/core.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pspace/core.py b/pspace/core.py index 7c40dc6..4e8d9d3 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -1,14 +1,23 @@ #!/usr/bin/env python #=====================================================================# +# சீரற்ற பகுதி வேறுபாட்டுச் சமன்பாடுகளுக்கான +# சுருக்கமான கணிதப் பகுப்பாய்வு தொகுதி +#————————————————————————————————————————————————————————————————————-# # ABSTRACT MATHEMATICAL ANALYSIS MODULE FOR STOCHASTIC PARTIAL # DIFFERENTIAL EQUATIONS #=====================================================================# +# X பரப்புகள் (சாத்தியவியல், இடவியல், காலவியல்) +# XX பரிமாணங்கள் (அச்சுகள்) சாத்தியவியல் பரவல்களுடன் +# XXX நிலைகள் (அடித்தளச் செயல்பாடுகள் மற்றும் முழுமையாக்கம்) +#————————————————————————————————————————————————————————————————————-# # X DOMAINS (PROBABILISTIC, SPATIAL, TEMPORAL) # XX DIMENSIONS (AXES) with PROBABILITY DISTRIBUTIONS # XXX MODES (BASIS FUNCTIONS AND QUADRATURES) #=====================================================================# -# Author : Komahan Boopathy (komahan.boopathy@gmail.com) +# ஆசிரியர் : கோமகன் பூபதி (komahan.boopathy@gmail.com) +#————————————————————————————————————————————————————————————————————-# +# Author : Komahan Boopathy (komahan.boopathy@gmail.com) #=====================================================================# #=====================================================================# From 4ed641857c9e6de028d4a414c6afe867066a03a2 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Wed, 10 Sep 2025 23:22:17 -0400 Subject: [PATCH 20/54] tamizh banner --- pspace/core.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 4e8d9d3..119b546 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -1,8 +1,7 @@ #!/usr/bin/env python #=====================================================================# -# சீரற்ற பகுதி வேறுபாட்டுச் சமன்பாடுகளுக்கான -# சுருக்கமான கணிதப் பகுப்பாய்வு தொகுதி +# சீரற்றப் பகுதி வேறுபாட்டுச் சமன்பாடுகளுக்கான கணிதப் பகுப்பாய்வுத் தொகுதி #————————————————————————————————————————————————————————————————————-# # ABSTRACT MATHEMATICAL ANALYSIS MODULE FOR STOCHASTIC PARTIAL # DIFFERENTIAL EQUATIONS @@ -20,10 +19,7 @@ # Author : Komahan Boopathy (komahan.boopathy@gmail.com) #=====================================================================# -#=====================================================================# # External modules -#=====================================================================# - import math import sympy as sp import numpy as np @@ -33,10 +29,7 @@ from enum import Enum from itertools import product -#=====================================================================# # Local modules -#=====================================================================# - from .stochastic_utils import ( minnum_quadrature_points, generate_basis_tensor_degree, From 3a38981ed0c64843ae96d8d65148a65010912f12 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 13 Sep 2025 20:52:36 -0400 Subject: [PATCH 21/54] fix test failures, timed analytic and numerical decompose --- pspace/core.py | 58 +++++++++++++++++++++++++----------------- tests/test_sparsity.py | 8 +++--- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 119b546..6f99618 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -162,10 +162,13 @@ def _x_to_z(self, x): class NormalCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] + mu = sp.sympify(pdata['dist_coords']['mu']) + sigma = sp.sympify(pdata['dist_coords']['sigma']) + self.dist_coords = {'mu': mu, 'sigma': sigma} + self.rho = sp.exp(-(self.symbol - mu)**2 / (2*sigma**2)) / (sp.sqrt(2*sp.pi) * sigma) - mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] - self.rho = 1/(sp.sqrt(2*sp.pi)*sigma) * sp.exp(-(self.symbol - mu)**2/(2*sigma**2)) + def domain(self): + return -sp.oo, sp.oo def to_standard(self, y): mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] @@ -190,10 +193,13 @@ def _x_to_z(self, x): class UniformCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] + a = sp.sympify(pdata['dist_coords']['a']) + b = sp.sympify(pdata['dist_coords']['b']) + self.dist_coords = {'a': a, 'b': b} + self.rho = sp.Rational(1, b - a) - a, b = self.dist_coords['a'], self.dist_coords['b'] - self.rho = sp.Piecewise((1/(b-a), (self.symbol>=a) & (self.symbol<=b)), (0, True)) + def domain(self): + return self.dist_coords['a'], self.dist_coords['b'] def to_standard(self, y): a, b = self.dist_coords['a'], self.dist_coords['b'] @@ -215,10 +221,13 @@ def _quad_rule_xw(self, degree): class ExponentialCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - self.dist_coords = pdata['dist_coords'] + mu = sp.sympify(pdata['dist_coords']['mu']) + beta = sp.sympify(pdata['dist_coords']['beta']) + self.dist_coords = {'mu': mu, 'beta': beta} + self.rho = sp.exp(-(self.symbol - mu)/beta) / beta - mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] - self.rho = (1/beta) * sp.exp(-(self.symbol - mu)/beta) + def domain(self): + return self.dist_coords['mu'], sp.oo def to_standard(self, y): mu, b = self.dist_coords['mu'], self.dist_coords['beta'] @@ -470,18 +479,8 @@ def decompose_analytic(self, f_eval, f_deg: Counter): val = integrand for cid, coord in coords.items(): y = coord.symbol - if isinstance(coord, UniformCoordinate): - a, b = coord.dist_coords['a'], coord.dist_coords['b'] - val = sp.integrate(val, (y, a, b)) - elif isinstance(coord, NormalCoordinate): - val = sp.integrate(val, (y, -sp.oo, sp.oo)) - elif isinstance(coord, ExponentialCoordinate): - mu = coord.dist_coords['mu'] - val = sp.integrate(val, (y, float(mu), sp.oo)) - else: - raise NotImplementedError( - f"Analytic integration not set up for {coord}" - ) + a, b = coord.domain() + val = sp.integrate(val, (y, a, b)) coeffs[k] = sp.simplify(val) @@ -543,8 +542,15 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, """ Cross-check numerical vs analytic decomposition. """ + from timeit import default_timer as timer + + start_num = timer() coeffs_num = self.decompose(f_eval, f_deg) + elapsed_num = timer() - start_num + + start_sym = timer() coeffs_sym = self.decompose_analytic(f_eval, f_deg) + elapsed_sym = timer() - start_sym diffs, ok = {}, True for k in coeffs_num.keys(): @@ -559,9 +565,13 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, diffs[k] = (num_val, ana_val, err) if verbose: - print(f"[ConsistencyCheck] {'PASSED' if ok else 'FAILED'} " - f"with tol={tol}") + print(f"[Consistency Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}") + print(f"[Elapsed Time] numerical {elapsed_num} analytic = {elapsed_sym}") + header = f"{'Basis':<7} {'numerical':>12} {'analytic':>12} {'error':>12}" + print(header) + print("-" * len(header)) for k, (n, a, e) in diffs.items(): - print(f"Basis {k}: num={n:.6g}, ana={a:.6g}, err={e:.2e}") + print(f"{k:<7d} {n:12.6f} {a:12.6f} {e:12.2e}") + print("-" * len(header)) return ok, diffs diff --git a/tests/test_sparsity.py b/tests/test_sparsity.py index e71212e..085a32f 100644 --- a/tests/test_sparsity.py +++ b/tests/test_sparsity.py @@ -45,8 +45,8 @@ def check_hermite_functions(): cf = CoordinateFactory() cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) - y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu=0.0, sigma=1.0), 3) - y1 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y1', dict(a=-1, b=1), 2) + y0 = cf.createNormalCoordinate(cf.newCoordinateID(), 'y0', dict(mu=0.83, sigma=1.513), 4) + y1 = cf.createUniformCoordinate(cf.newCoordinateID(), 'y1', dict(a=-2.197, b=0.538), 1) cs.addCoordinateAxis(y0) cs.addCoordinateAxis(y1) @@ -54,8 +54,8 @@ def check_hermite_functions(): cs.initialize() # Function: f(y) = 1 + y0^2 + y1 - dfunc = lambda Y: 1.0 + Y[0]**2 + Y[1] - fdegs = Counter({0:2, 1:1}) + dfunc = lambda y: y[0]**2*y[1]**2 + y[0]*y[1]**2 + 2*y[1] + 1.0 + fdegs = Counter({0:2, 1:2}) ok, diffs = cs.check_decomposition_consistency(dfunc, fdegs, tol=1e-8, verbose=True) From 902eea7294e5348070a42e82bb40c030bfeb8ded Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 09:26:46 -0400 Subject: [PATCH 22/54] fix names --- pspace/core.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 6f99618..06d6d30 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -111,12 +111,12 @@ def weight(self): #-----------------------------------------------------------------# def evaluateBasisAtY(self, yscalar, degree): z = self.to_standard(yscalar) - return self._evaluateBasisAtZ(z, degree) + return self.evaluateBasisAtZ(z, degree) #-----------------------------------------------------------------# # subclass provides the family polynomial in z #-----------------------------------------------------------------# - def _evaluateBasisAtZ(self, zscalar, degree): + def evaluateBasisAtZ(self, zscalar, degree): raise NotImplementedError #-----------------------------------------------------------------# @@ -178,7 +178,7 @@ def to_physical(self, z): mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] return mu + s * z - def _evaluateBasisAtZ(self, z, degree): + def evaluateBasisAtZ(self, z, degree): return unit_hermite(z, degree) def _quad_rule_xw(self, degree): @@ -209,7 +209,7 @@ def to_physical(self, z): a, b = self.dist_coords['a'], self.dist_coords['b'] return (b - a) * (z + 1.0) / 2.0 + a - def _evaluateBasisAtZ(self, z, degree): + def evaluateBasisAtZ(self, z, degree): return unit_legendre(z, degree) def _quad_rule_xw(self, degree): @@ -237,7 +237,7 @@ def to_physical(self, z): mu, b = self.dist_coords['mu'], self.dist_coords['beta'] return mu + b * z - def _evaluateBasisAtZ(self, z, degree): + def evaluateBasisAtZ(self, z, degree): return unit_laguerre(z, degree) def _quad_rule_xw(self, degree): @@ -471,7 +471,7 @@ def decompose_analytic(self, f_eval, f_deg: Counter): psi_expr = 1 for cid, deg in psi_k.items(): z = coords[cid].to_standard(coords[cid].symbol) - psi_expr *= coords[cid]._evaluateBasisAtZ(z, deg) + psi_expr *= coords[cid].evaluateBasisAtZ(z, deg) integrand = f_expr * psi_expr * sp.Mul(*[c.weight() for c in coords.values()]) From 7c7067e19a0833420291f8de57a69ed4ff384eb0 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 12:06:49 -0400 Subject: [PATCH 23/54] fix standardization --- pspace/core.py | 109 +++++++++++++++++++++++++++++++------------------ 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 06d6d30..6ba77a9 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -92,36 +92,44 @@ def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" #-----------------------------------------------------------------# - # canonical mappings (must be implemented by subclasses) + # VARIABLE TRANSFORMATIONS #-----------------------------------------------------------------# - def to_standard(self, yscalar): - """Map physical y -> standard z (basis domain).""" + + def physical_to_standard(self, yscalar): + """Map physical y -> standard z""" raise NotImplementedError - def to_physical(self, zscalar): - """Map standard z -> physical y (user domain).""" + def quadrature_to_physical(self, xscalar): + """Map quadrature x -> physical y""" raise NotImplementedError - def weight(self): - """Return symbolic weight function ρ(y) attached to this coordinate.""" - return self.rho + def standard_to_physical(self, zscalar): + """Map standard z -> physical y""" + raise NotImplementedError #-----------------------------------------------------------------# - # basis eval in y (thin wrapper over z) + # BASIS EVALUATIONS #-----------------------------------------------------------------# - def evaluateBasisAtY(self, yscalar, degree): - z = self.to_standard(yscalar) - return self.evaluateBasisAtZ(z, degree) - #-----------------------------------------------------------------# - # subclass provides the family polynomial in z - #-----------------------------------------------------------------# def evaluateBasisAtZ(self, zscalar, degree): raise NotImplementedError + def evaluateBasisAtY(self, yscalar, degree): + z = self.physical_to_standard(yscalar) + return self.evaluateBasisAtZ(z, degree) + + def evaluateBasisAtX(self, xscalar, degree): + z = self.quadrature_to_standard(xscalar) + return self.evaluateBasisAtZ(z, degree) + #-----------------------------------------------------------------# # subclass provides a 1D Gauss rule for the needed degree #-----------------------------------------------------------------# + + def weight(self): + """Return symbolic weight function ρ(y) attached to this coordinate.""" + return self.rho + def _quad_rule_xw(self, degree): """ Return native Gauss rule (x_nodes, w_nodes) sized for `degree`. @@ -132,9 +140,10 @@ def _quad_rule_xw(self, degree): #-----------------------------------------------------------------# # lift native x-rule to (z,y,w) consistently #-----------------------------------------------------------------# + def getQuadraturePointsWeights(self, degree): """ - Return {'yq','zq','wq'} where: + Return {'yq','zq','wq'} where : - zq is the standard variable nodes (basis is orthonormal here) - yq is the physical nodes (user functions evaluated here) - wq integrates in z-domain (Jacobian absorbed) @@ -145,13 +154,14 @@ def getQuadraturePointsWeights(self, degree): z = self._x_to_z(x) # Map z -> y using distribution parameters - y = np.array([self.to_physical(zz) for zz in z]) + y = np.array([self.standard_to_physical(zz) for zz in z]) return {'yq': y, 'zq': z, 'wq': w} #-----------------------------------------------------------------# # Default identity for families where x==z #-----------------------------------------------------------------# + def _x_to_z(self, x): return np.asarray(x) @@ -170,21 +180,28 @@ def __init__(self, pdata): def domain(self): return -sp.oo, sp.oo - def to_standard(self, y): - mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] - return (y - mu) / s + def physical_to_standard(self, yscalar): + """Map physical y -> standard z""" + mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] + return (yscalar - mu) / sigma + + def quadrature_to_physical(self, xscalar): + """Map quadrature x -> physical y""" + mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] + return mu + sigma * np.sqrt(2) * xscalar - def to_physical(self, z): - mu, s = self.dist_coords['mu'], self.dist_coords['sigma'] - return mu + s * z + def standard_to_physical(self, zscalar): + """Map standard z -> physical y""" + mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] + return mu + sigma * zscalar def evaluateBasisAtZ(self, z, degree): return unit_hermite(z, degree) def _quad_rule_xw(self, degree): - npts = minnum_quadrature_points(degree) - x, w = np.polynomial.hermite.hermgauss(npts) - w = w / np.sqrt(np.pi) + npts = minnum_quadrature_points(degree) + x, w = np.polynomial.hermite.hermgauss(npts) + w = w / np.sqrt(np.pi) return x, w def _x_to_z(self, x): @@ -201,13 +218,20 @@ def __init__(self, pdata): def domain(self): return self.dist_coords['a'], self.dist_coords['b'] - def to_standard(self, y): + def physical_to_standard(self, yscalar): + """Map physical y -> standard z""" a, b = self.dist_coords['a'], self.dist_coords['b'] - return (y - a) / (b - a) * 2.0 - 1.0 + return (yscalar - a) / (b - a) - def to_physical(self, z): + def quadrature_to_physical(self, xscalar): + """Map quadrature x -> physical y""" a, b = self.dist_coords['a'], self.dist_coords['b'] - return (b - a) * (z + 1.0) / 2.0 + a + return (b - a) * xscalar + a + + def standard_to_physical(self, zscalar): + """Map standard z -> physical y""" + a, b = self.dist_coords['a'], self.dist_coords['b'] + return (b - a) * zscalar + a def evaluateBasisAtZ(self, z, degree): return unit_legendre(z, degree) @@ -215,7 +239,7 @@ def evaluateBasisAtZ(self, z, degree): def _quad_rule_xw(self, degree): npts = minnum_quadrature_points(degree) x, w = np.polynomial.legendre.leggauss(npts) - w = w / 2.0 + w = w / 2.0 return x, w class ExponentialCoordinate(Coordinate): @@ -229,13 +253,20 @@ def __init__(self, pdata): def domain(self): return self.dist_coords['mu'], sp.oo - def to_standard(self, y): - mu, b = self.dist_coords['mu'], self.dist_coords['beta'] - return (y - mu) / b + def physical_to_standard(self, yscalar): + """Map physical y -> standard z""" + mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] + return (yscalar - mu) / beta + + def quadrature_to_physical(self, xscalar): + """Map quadrature x -> physical y""" + mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] + return mu + beta * xscalar - def to_physical(self, z): - mu, b = self.dist_coords['mu'], self.dist_coords['beta'] - return mu + b * z + def standard_to_physical(self, zscalar): + """Map standard z -> physical y""" + mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] + return mu + beta * zscalar def evaluateBasisAtZ(self, z, degree): return unit_laguerre(z, degree) @@ -314,7 +345,7 @@ def __str__(self): def basis_at_y(self, yscalar, degree: int): """ψ(y) = ψ(z(y)), evaluated symbolically in Y-frame.""" - z = self.to_standard(yscalar) + z = self.physical_to_standard(yscalar) return self.evaluateBasisFunction(z, degree) def getNumBasisFunctions(self): @@ -470,7 +501,7 @@ def decompose_analytic(self, f_eval, f_deg: Counter): for k, psi_k in self.basis.items(): psi_expr = 1 for cid, deg in psi_k.items(): - z = coords[cid].to_standard(coords[cid].symbol) + z = coords[cid].physical_to_standard(coords[cid].symbol) psi_expr *= coords[cid].evaluateBasisAtZ(z, deg) integrand = f_expr * psi_expr * sp.Mul(*[c.weight() From 14637df7fa0a51d5f04b303fec9fe99114a587a3 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 13:23:23 -0400 Subject: [PATCH 24/54] fixed basis scaling, but dot product for uniform needs to be fixed --- pspace/core.py | 125 ++++++++++++++++++------------------------------- 1 file changed, 46 insertions(+), 79 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 6ba77a9..eab5e82 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -1,21 +1,14 @@ #!/usr/bin/env python #=====================================================================# -# சீரற்றப் பகுதி வேறுபாட்டுச் சமன்பாடுகளுக்கான கணிதப் பகுப்பாய்வுத் தொகுதி -#————————————————————————————————————————————————————————————————————-# -# ABSTRACT MATHEMATICAL ANALYSIS MODULE FOR STOCHASTIC PARTIAL -# DIFFERENTIAL EQUATIONS -#=====================================================================# -# X பரப்புகள் (சாத்தியவியல், இடவியல், காலவியல்) -# XX பரிமாணங்கள் (அச்சுகள்) சாத்தியவியல் பரவல்களுடன் -# XXX நிலைகள் (அடித்தளச் செயல்பாடுகள் மற்றும் முழுமையாக்கம்) -#————————————————————————————————————————————————————————————————————-# -# X DOMAINS (PROBABILISTIC, SPATIAL, TEMPORAL) -# XX DIMENSIONS (AXES) with PROBABILITY DISTRIBUTIONS -# XXX MODES (BASIS FUNCTIONS AND QUADRATURES) -#=====================================================================# +# சாத்தியவியல், இடவியல், காலவியல் பகுதி வேறுபாட்டுச் +# சமன்பாடுகளுக்கான கணிதப் பகுப்பாய்வுத் தொகுதி + # ஆசிரியர் : கோமகன் பூபதி (komahan.boopathy@gmail.com) #————————————————————————————————————————————————————————————————————-# +# MATHEMATICAL ANALYSIS MODULE FOR PROBABILISTIC-SPATIO-TEMPORAL +# PARTIAL DIFFERENTIAL EQUATIONS +# # Author : Komahan Boopathy (komahan.boopathy@gmail.com) #=====================================================================# @@ -30,7 +23,7 @@ from itertools import product # Local modules -from .stochastic_utils import ( +from .stochastic_utils import ( minnum_quadrature_points, generate_basis_tensor_degree, generate_basis_total_degree, @@ -91,6 +84,10 @@ def __init__(self, coord_data): def __str__(self): return str(self.__class__.__name__) + " " + str(self.__dict__) + "\n" + def weight(self): + """Return symbolic weight function ρ(y) attached to this coordinate.""" + return self.rho + #-----------------------------------------------------------------# # VARIABLE TRANSFORMATIONS #-----------------------------------------------------------------# @@ -107,64 +104,42 @@ def standard_to_physical(self, zscalar): """Map standard z -> physical y""" raise NotImplementedError + def quadrature_to_standard(self, xscalar): + """Map quadrature x -> standard z""" + return self.physical_to_standard(self.quadrature_to_physical(xscalar)) + #-----------------------------------------------------------------# # BASIS EVALUATIONS #-----------------------------------------------------------------# - def evaluateBasisAtZ(self, zscalar, degree): + def psi_z(self, zscalar, degree): raise NotImplementedError - def evaluateBasisAtY(self, yscalar, degree): + def psi_y(self, yscalar, degree): z = self.physical_to_standard(yscalar) - return self.evaluateBasisAtZ(z, degree) + return self.psi_z(z, degree) - def evaluateBasisAtX(self, xscalar, degree): + def psi_x(self, xscalar, degree): z = self.quadrature_to_standard(xscalar) - return self.evaluateBasisAtZ(z, degree) + return self.psi_z(z, degree) #-----------------------------------------------------------------# # subclass provides a 1D Gauss rule for the needed degree #-----------------------------------------------------------------# - def weight(self): - """Return symbolic weight function ρ(y) attached to this coordinate.""" - return self.rho - - def _quad_rule_xw(self, degree): + def gaussian_quadrature(self, degree): """ Return native Gauss rule (x_nodes, w_nodes) sized for `degree`. Subclass chooses the correct family and normalization. """ raise NotImplementedError - #-----------------------------------------------------------------# - # lift native x-rule to (z,y,w) consistently - #-----------------------------------------------------------------# - def getQuadraturePointsWeights(self, degree): - """ - Return {'yq','zq','wq'} where : - - zq is the standard variable nodes (basis is orthonormal here) - - yq is the physical nodes (user functions evaluated here) - - wq integrates in z-domain (Jacobian absorbed) - """ - x, w = self._quad_rule_xw(degree) - - # Map x -> z (family dependent); many families have z == x - z = self._x_to_z(x) - - # Map z -> y using distribution parameters - y = np.array([self.standard_to_physical(zz) for zz in z]) - + x, w = self.gaussian_quadrature(degree) + z = self.quadrature_to_standard(x) + y = np.array([self.standard_to_physical(zz) for zz in z]) return {'yq': y, 'zq': z, 'wq': w} - #-----------------------------------------------------------------# - # Default identity for families where x==z - #-----------------------------------------------------------------# - - def _x_to_z(self, x): - return np.asarray(x) - #=====================================================================# # Coordinate Implementations #=====================================================================# @@ -172,10 +147,10 @@ def _x_to_z(self, x): class NormalCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - mu = sp.sympify(pdata['dist_coords']['mu']) - sigma = sp.sympify(pdata['dist_coords']['sigma']) + mu = sp.sympify(pdata['dist_coords']['mu']) + sigma = sp.sympify(pdata['dist_coords']['sigma']) self.dist_coords = {'mu': mu, 'sigma': sigma} - self.rho = sp.exp(-(self.symbol - mu)**2 / (2*sigma**2)) / (sp.sqrt(2*sp.pi) * sigma) + self.rho = sp.exp(-(self.symbol - mu)**2 / (2*sigma**2)) / (sp.sqrt(2*sp.pi) * sigma) def domain(self): return -sp.oo, sp.oo @@ -195,48 +170,42 @@ def standard_to_physical(self, zscalar): mu, sigma = self.dist_coords['mu'], self.dist_coords['sigma'] return mu + sigma * zscalar - def evaluateBasisAtZ(self, z, degree): + def psi_z(self, z, degree): return unit_hermite(z, degree) - def _quad_rule_xw(self, degree): + def gaussian_quadrature(self, degree): npts = minnum_quadrature_points(degree) x, w = np.polynomial.hermite.hermgauss(npts) w = w / np.sqrt(np.pi) return x, w - def _x_to_z(self, x): - return np.sqrt(2.0) * np.asarray(x) - class UniformCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - a = sp.sympify(pdata['dist_coords']['a']) - b = sp.sympify(pdata['dist_coords']['b']) + a = sp.sympify(pdata['dist_coords']['a']) + b = sp.sympify(pdata['dist_coords']['b']) self.dist_coords = {'a': a, 'b': b} - self.rho = sp.Rational(1, b - a) + self.rho = sp.Rational(1, b - a) def domain(self): return self.dist_coords['a'], self.dist_coords['b'] def physical_to_standard(self, yscalar): - """Map physical y -> standard z""" a, b = self.dist_coords['a'], self.dist_coords['b'] return (yscalar - a) / (b - a) def quadrature_to_physical(self, xscalar): - """Map quadrature x -> physical y""" a, b = self.dist_coords['a'], self.dist_coords['b'] return (b - a) * xscalar + a def standard_to_physical(self, zscalar): - """Map standard z -> physical y""" a, b = self.dist_coords['a'], self.dist_coords['b'] return (b - a) * zscalar + a - def evaluateBasisAtZ(self, z, degree): + def psi_z(self, z, degree): return unit_legendre(z, degree) - def _quad_rule_xw(self, degree): + def gaussian_quadrature(self, degree): npts = minnum_quadrature_points(degree) x, w = np.polynomial.legendre.leggauss(npts) w = w / 2.0 @@ -245,33 +214,30 @@ def _quad_rule_xw(self, degree): class ExponentialCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) - mu = sp.sympify(pdata['dist_coords']['mu']) - beta = sp.sympify(pdata['dist_coords']['beta']) + mu = sp.sympify(pdata['dist_coords']['mu']) + beta = sp.sympify(pdata['dist_coords']['beta']) self.dist_coords = {'mu': mu, 'beta': beta} - self.rho = sp.exp(-(self.symbol - mu)/beta) / beta + self.rho = sp.exp(-(self.symbol - mu)/beta) / beta def domain(self): return self.dist_coords['mu'], sp.oo def physical_to_standard(self, yscalar): - """Map physical y -> standard z""" mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] return (yscalar - mu) / beta def quadrature_to_physical(self, xscalar): - """Map quadrature x -> physical y""" mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] return mu + beta * xscalar def standard_to_physical(self, zscalar): - """Map standard z -> physical y""" mu, beta = self.dist_coords['mu'], self.dist_coords['beta'] return mu + beta * zscalar - def evaluateBasisAtZ(self, z, degree): + def psi_z(self, z, degree): return unit_laguerre(z, degree) - def _quad_rule_xw(self, degree): + def gaussian_quadrature(self, degree): npts = minnum_quadrature_points(degree) x, w = np.polynomial.laguerre.laggauss(npts) return x, w @@ -286,7 +252,7 @@ def __init__(self): return def newCoordinateID(self): - pid = self.next_coord_id + pid = self.next_coord_id self.next_coord_id = self.next_coord_id + 1 return pid @@ -343,10 +309,10 @@ def __str__(self): # Basis and initialization #-----------------------------------------------------------------# - def basis_at_y(self, yscalar, degree: int): + def evaluate_basis(self, yscalar, degree: int): """ψ(y) = ψ(z(y)), evaluated symbolically in Y-frame.""" z = self.physical_to_standard(yscalar) - return self.evaluateBasisFunction(z, degree) + return self.psi_z(z, degree) def getNumBasisFunctions(self): return len(self.basis) if self.basis is not None else 0 @@ -406,6 +372,7 @@ def build_quadrature(self, degrees: Counter): ctr += 1 self.print_quadrature(qmap) + return qmap #-----------------------------------------------------------------# @@ -415,7 +382,7 @@ def build_quadrature(self, degrees: Counter): def evaluateBasisDegreesY(self, y_by_cid, degrees_counter): val = 1.0 for cid, deg in degrees_counter.items(): - val *= self.coordinates[cid].evaluateBasisAtY(y_by_cid[cid], deg) + val *= self.coordinates[cid].psi_y(y_by_cid[cid], deg) return val def evaluateBasisIndexY(self, y_by_cid, basis_id): @@ -502,7 +469,7 @@ def decompose_analytic(self, f_eval, f_deg: Counter): psi_expr = 1 for cid, deg in psi_k.items(): z = coords[cid].physical_to_standard(coords[cid].symbol) - psi_expr *= coords[cid].evaluateBasisAtZ(z, deg) + psi_expr *= coords[cid].psi_z(z, deg) integrand = f_expr * psi_expr * sp.Mul(*[c.weight() for c in coords.values()]) @@ -547,7 +514,7 @@ def decompose_analytic_to_fix(self, f_eval, f_deg: Counter): psi_expr = 1 for cid, deg in psi_k.items(): y = coords[cid].symbol - psi_expr *= coords[cid].basis_at_y(y, deg) + psi_expr *= coords[cid].evaluate_basis(y, deg) # Integrand in physical space integrand = f_expr * psi_expr From 9abcea33a99d4447d8476f62740fba2d86ee767e Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 15:07:35 -0400 Subject: [PATCH 25/54] add ortho polynomials from numpy instead of recursion --- pspace/core.py | 7 +++ pspace/orthogonal_polynomials.py | 86 ++++++++++++++++++++++++++++---- tests/test_decomposition.py | 4 ++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index eab5e82..a114f71 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -206,6 +206,13 @@ def psi_z(self, z, degree): return unit_legendre(z, degree) def gaussian_quadrature(self, degree): + npts = minnum_quadrature_points(degree) + xi, w = np.polynomial.legendre.leggauss(npts) # on [-1,1] + x_shifted = 0.5 * (xi + 1.0) # map to [0,1] + w_shifted = 0.5 * w + return x_shifted, w_shifted + + def gaussian_quadrature_(self, degree): npts = minnum_quadrature_points(degree) x, w = np.polynomial.legendre.leggauss(npts) w = w / 2.0 diff --git a/pspace/orthogonal_polynomials.py b/pspace/orthogonal_polynomials.py index 4676b3b..3610e19 100644 --- a/pspace/orthogonal_polynomials.py +++ b/pspace/orthogonal_polynomials.py @@ -33,7 +33,7 @@ def tensor_indices(nterms): return flat_list -def laguerre(z,d): +def _laguerre(z,d): """ Polynomials such that _{exp(-z)}^{0,inf} = 0 """ @@ -45,13 +45,28 @@ def laguerre(z,d): den = d return ((2*d-1-z)*laguerre(z,d-1) - (d-1)*laguerre(z,d-2))/den -def unit_laguerre(z,d): +def _unit_laguerre(z,d): """ Returns unit laguerre polynomial of degree d evaluated at z """ - return laguerre(z,d) + return _laguerre(z,d) -def hermite(z, d): +def laguerre(z, d): + """ + Standard (non-associated) Laguerre polynomial L_d(z). + Orthogonal under weight exp(-z) on [0, ∞). + """ + coeffs = [0]*d + [1] # degree-d monomial + L = np.polynomial.laguerre.Laguerre(coeffs) + return L(z) + +def unit_laguerre(z, d): + """ + Orthonormal Laguerre polynomial ψ_d(z). + """ + return laguerre(z, d) / np.sqrt(1.0) # weight already normalized + +def _hermite(z, d): """ Use recursion to generate probabilist hermite polynomials @@ -66,11 +81,26 @@ def hermite(z, d): else: return z*hermite(z,d-1) - (d-1)*hermite(z,d-2) -def unit_hermite(z,d): +def _unit_hermite(z,d): """ Returns units hermite polynomial of degree n evaluated at z """ - return hermite(z,d)/np.sqrt(math.factorial(d)) + return _hermite(z,d)/np.sqrt(math.factorial(d)) + +def hermite(z, d): + """ + Probabilists' Hermite polynomial He_d(z). + Orthogonal under weight exp(-z^2/2) on (-∞, ∞). + """ + coeffs = [0]*d + [1] + H = np.polynomial.hermite_e.HermiteE(coeffs) + return H(z) + +def unit_hermite(z, d): + """ + Orthonormal Hermite polynomial ψ_d(z). + """ + return hermite(z, d) / np.sqrt(np.math.factorial(d)) def rlegendre(z, d): if d == 0: @@ -80,7 +110,7 @@ def rlegendre(z, d): return ((2*d - 1) * (2*z - 1) * rlegendre(z, d-1) - (d - 1) * rlegendre(z, d-2)) / d -def legendre(z, d): +def _legendre(z, d): """ Use recursion to generate Legendre polynomials @@ -93,8 +123,46 @@ def legendre(z, d): p = p + dp return ((-1)**d)*p -def unit_legendre(z,d): - return legendre(z,d)*np.sqrt(2*d+1) +def _unit_legendre(z,d): + return _legendre(z,d)*np.sqrt(2*d+1) + +import numpy as np + +#=====================================================================# +# Shifted Legendre Basis on [0,1] +#=====================================================================# + +def legendre(z, d): + """ + Shifted Legendre polynomial of degree d on [0,1]. + Defined as P_d^*(z) = P_d(2z - 1), where P_d is the standard Legendre. + """ + coeffs = [0] * d + [1] # coefficient vector for degree d + P = np.polynomial.legendre.Legendre(coeffs) # P_d(x) on [-1,1] + return P(2*z - 1) # shift domain to [0,1] + + +def unit_legendre(z, d): + """ + L^2-orthonormal shifted Legendre polynomial on [0,1]: + ψ_d(z) = sqrt(2d+1) * P_d^*(z). + """ + return np.sqrt(2*d + 1) * legendre(z, d) + + +#=====================================================================# +# Shifted Gauss-Legendre Quadrature on [0,1] +#=====================================================================# + +def shifted_legendre_quadrature(npts): + """ + Gauss-Legendre quadrature nodes/weights shifted from [-1,1] to [0,1]. + Integrates ∫_0^1 f(z) dz exactly for deg ≤ 2npts-1. + """ + x, w = np.polynomial.legendre.leggauss(npts) # nodes/weights on [-1,1] + z = 0.5 * (x + 1) # map to [0,1] + w = 0.5 * w # rescale weights + return z, w if __name__ == "__main__": diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index 2fb33c8..4905d82 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -101,6 +101,8 @@ def random_polynomial(cs, max_deg=2, max_terms=3): @pytest.mark.parametrize("trial", range(5)) def test_randomized_tensor_basis(trial): + random.seed(trial) + print(f"\n=== Trial {trial} : Tensor Basis ===") cf = CoordinateFactory() cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) @@ -125,6 +127,8 @@ def test_randomized_tensor_basis(trial): @pytest.mark.parametrize("trial", range(5)) def test_randomized_total_basis(trial): + random.seed(trial) + print(f"\n=== Trial {trial} : Total Degree Basis ===") cf = CoordinateFactory() cs = CoordinateSystem(BasisFunctionType.TOTAL_DEGREE) From a4767e176a3f1b7172071da4ae49565a0905fde1 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 15:26:39 -0400 Subject: [PATCH 26/54] test orthonormality prior to decomposition --- pspace/core.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pspace/core.py b/pspace/core.py index a114f71..7dedc9a 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -538,6 +538,14 @@ def decompose_analytic_to_fix(self, f_eval, f_deg: Counter): return coeffs + def check_orthonormality(self): + nbasis = self.getNumBasisFunctions() + A = np.zeros((nbasis, nbasis)) + for ii in range(nbasis): + for jj in range(nbasis): + A[ii,jj] = self.inner_product_basis(ii, jj) + return np.linalg.norm(A - np.eye(nbasis), ord=np.inf) + #-----------------------------------------------------------------# # Consistency check #-----------------------------------------------------------------# @@ -547,6 +555,10 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, """ Cross-check numerical vs analytic decomposition. """ + + # ensure basis is orthonormal first + ortho_tol = self.check_orthonormality() + from timeit import default_timer as timer start_num = timer() @@ -570,7 +582,7 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, diffs[k] = (num_val, ana_val, err) if verbose: - print(f"[Consistency Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}") + print(f"[Consistency Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}, ortho tol = {ortho_tol}") print(f"[Elapsed Time] numerical {elapsed_num} analytic = {elapsed_sym}") header = f"{'Basis':<7} {'numerical':>12} {'analytic':>12} {'error':>12}" print(header) From 8684f2f4b0fb85f4399972050f194a289eda7a74 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 15:28:54 -0400 Subject: [PATCH 27/54] test orthonormality prior to decomposition --- pspace/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 7dedc9a..e5e224e 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -4,12 +4,12 @@ # சாத்தியவியல், இடவியல், காலவியல் பகுதி வேறுபாட்டுச் # சமன்பாடுகளுக்கான கணிதப் பகுப்பாய்வுத் தொகுதி -# ஆசிரியர் : கோமகன் பூபதி (komahan.boopathy@gmail.com) +# ஆசிரியர் : கோமகன் பூபதி (komahan@gatech.edu) #————————————————————————————————————————————————————————————————————-# # MATHEMATICAL ANALYSIS MODULE FOR PROBABILISTIC-SPATIO-TEMPORAL # PARTIAL DIFFERENTIAL EQUATIONS # -# Author : Komahan Boopathy (komahan.boopathy@gmail.com) +# Author : Komahan Boopathy (komahan@gatech.edu) #=====================================================================# # External modules From bc7e67e71eb335dd3f7ebb724af59ef6fbaa53fb Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 21:03:08 -0400 Subject: [PATCH 28/54] sparse vector assembly --- pspace/core.py | 111 +++++++++++++++++++++++++++++++++++- tests/test_decomposition.py | 90 +++++++++++++++++++++-------- 2 files changed, 175 insertions(+), 26 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index e5e224e..d9b32fb 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -396,6 +396,44 @@ def evaluateBasisIndexY(self, y_by_cid, basis_id): degrees = self.basis[basis_id] return self.evaluateBasisDegreesY(y_by_cid, degrees) + #-----------------------------------------------------------------# + # Sparsity Detection Utilities + #-----------------------------------------------------------------# + + def sparse_vector(self, dmapi, dmapf): + """ + Detect sparsity for . + + dmapi : Counter({axis: degree}) for basis ψ_i + dmapf : Counter({axis: degree}) for function f + basis_type: "tensor" or "total" + """ + if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: + # Axis-by-axis cutoff + return all(dmapi[k] <= dmapf[k] for k in dmapf.keys()) + elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: + # Global cutoff (total degree) + return sum(dmapi.values()) <= sum(dmapf.values()) + else: + raise ValueError("Unknown basis_type") + + def sparse_matrix(self, dmapi, dmapj, dmapf): + """ + Detect sparsity for <ψ_i, f, ψ_j>. + + dmapi, dmapj : Counter({axis: degree}) for bases ψ_i, ψ_j + dmapf : Counter({axis: degree}) for function f + basis_type : "tensor" or "total" + """ + if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: + # Axis-by-axis cutoff + return all((dmapi[k] + dmapj[k]) <= dmapf[k] for k in dmapf.keys()) + elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: + # Global cutoff (total degree) + return (sum(dmapi.values()) + sum(dmapj.values())) <= sum(dmapf.values()) + else: + raise ValueError("Unknown basis_type") + #-----------------------------------------------------------------# # Inner products #-----------------------------------------------------------------# @@ -462,6 +500,32 @@ def decompose(self, f_eval, f_deg: Counter): return coeffs + def decompose_vector_sparse(self, f_eval, f_deg: Counter): + """ + Coefficients c_k = in Y-frame. + """ + coeffs = {} + for k, psi_k in self.basis.items(): + + #---------------------------------------------------------# + # Sparsity filter + #---------------------------------------------------------# + + if not self.sparse_vector(psi_k, f_deg): + coeffs[k] = 0.0 + continue + + need = sum_degrees(f_deg, psi_k) + qmap = self.build_quadrature(need) + + s = 0.0 + for q in qmap.values(): + y = q['Y'] + s += f_eval(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] + coeffs[k] = s + + return coeffs + def decompose_analytic(self, f_eval, f_deg: Counter): """ Analytic decomposition using SymPy: @@ -550,8 +614,8 @@ def check_orthonormality(self): # Consistency check #-----------------------------------------------------------------# - def check_decomposition_consistency(self, f_eval, f_deg: Counter, - tol=1e-10, verbose=True): + def check_decomposition_numerical_symbolic(self, f_eval, f_deg: Counter, + tol=1e-10, verbose=True): """ Cross-check numerical vs analytic decomposition. """ @@ -592,3 +656,46 @@ def check_decomposition_consistency(self, f_eval, f_deg: Counter, print("-" * len(header)) return ok, diffs + + #-----------------------------------------------------------------# + # Sparse vs full Assembly (selectively employ dot products) + #-----------------------------------------------------------------# + + def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, + tol=1e-12, verbose=True): + """ + Cross-check sparse vs full assembly of rank 1 decomposition + coefficients + """ + from timeit import default_timer as timer + + start_sparse = timer() + coeffs_sparse = self.decompose_vector_sparse(f_eval, f_deg) + elapsed_sparse = timer() - start_sparse + + start_full = timer() + coeffs_full = self.decompose(f_eval, f_deg) + elapsed_full = timer() - start_full + + diffs, ok = {}, True + for k in coeffs_sparse.keys(): + coeff_sparse = coeffs_sparse[k] + coeff_full = coeffs_full[k] + + err = abs(coeff_sparse - coeff_full) + if err > tol: + ok = False + + diffs[k] = (coeff_sparse, coeff_full, err) + + if verbose: + print(f"[Assembly Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}") + print(f"[Elapsed Time] Sparse {elapsed_sparse} Full = {elapsed_full}") + header = f"{'Basis':<7} {'Sparse':>12} {'Full':>12} {'Error':>12}" + print(header) + print("-" * len(header)) + for k, (n, a, e) in diffs.items(): + print(f"{k:<7d} {float(n):12.6f} {float(a):12.6f} {float(e):12.2e}") + print("-" * len(header)) + + return ok, diffs diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index 4905d82..4e91cba 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -96,16 +96,12 @@ def random_polynomial(cs, max_deg=2, max_terms=3): return fexpr, dfunc, fdeg #=====================================================================# -# Tests : Tensor Basis +# Problem setup #=====================================================================# -@pytest.mark.parametrize("trial", range(5)) -def test_randomized_tensor_basis(trial): - random.seed(trial) - - print(f"\n=== Trial {trial} : Tensor Basis ===") +def get_coordinate_system_type(basis_type): cf = CoordinateFactory() - cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) + cs = CoordinateSystem(basis_type) ncoords = random.randint(1, 3) print(f"[Setup] Using {ncoords} coordinates") @@ -114,35 +110,81 @@ def test_randomized_tensor_basis(trial): cs.addCoordinateAxis(coord) cs.initialize() + + return cs + +#=====================================================================# +# Tests 1 B: Tensor Degree Basis (sparse vs full assembly) +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_tensor_basis_sparse_full(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Tensor Degree Basis (sparse vs full assembly) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) + fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_consistency(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok #=====================================================================# -# Tests : Total Degree Basis +# Tests 2 B: Total Degree Basis (sparse vs full assembly) #=====================================================================# @pytest.mark.parametrize("trial", range(5)) -def test_randomized_total_basis(trial): +def test_randomized_total_basis_sparse_full(trial): random.seed(trial) - print(f"\n=== Trial {trial} : Total Degree Basis ===") - cf = CoordinateFactory() - cs = CoordinateSystem(BasisFunctionType.TOTAL_DEGREE) + print(f"\n=== Trial {trial} : Total Degree Basis (sparse vs full assembly) ===") - ncoords = random.randint(1, 3) - print(f"[Setup] Using {ncoords} coordinates") - for cid in range(ncoords): - coord = random_coordinate(cf, cid) - cs.addCoordinateAxis(coord) + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) + + fexpr, dfunc, fdeg = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + tol=1e-6, + verbose=True) + assert ok + +#=====================================================================# +# Tests 1 A : Tensor Basis (numerical vs symbolic) +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_tensor_numerical_symbolic(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Tensor Degree Basis (numerical vs symbolic) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) + + fexpr, dfunc, fdeg = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + tol=1e-6, + verbose=True) + assert ok + +#=====================================================================# +# Tests 2 A : Total Degree Basis (numerical vs symbolic) +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_total_numerical_symbolic(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Total Degree Basis (numerical vs symbolic) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) - cs.initialize() fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_consistency(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok From 628eb9dd16b8bde03df4136dbc79fc691484464c Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 14 Sep 2025 21:32:04 -0400 Subject: [PATCH 29/54] fix tests --- pspace/core.py | 2 +- tests/test_decomposition.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index d9b32fb..e0a3955 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -690,7 +690,7 @@ def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, if verbose: print(f"[Assembly Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}") - print(f"[Elapsed Time] Sparse {elapsed_sparse} Full = {elapsed_full}") + print(f"[Elapsed Time] Sparse {elapsed_sparse} Full = {elapsed_full} Ratio = {elapsed_full/elapsed_sparse}") header = f"{'Basis':<7} {'Sparse':>12} {'Full':>12} {'Error':>12}" print(header) print("-" * len(header)) diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index 4e91cba..b3caa17 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -19,14 +19,14 @@ # Helper : randomized coordinate factory with logging #=====================================================================# -def random_coordinate(cf, cid): +def random_coordinate(cf, cid, max_deg = 4): coord_type = random.choice(["normal", "uniform", "exponential"]) name = f"y{cid}" if coord_type == "normal": mu = random.uniform(-2.0, 2.0) sigma = random.uniform( 0.5, 2.0) - deg = random.randint(1, 4) + deg = random.randint(1, max_deg) coord = cf.createNormalCoordinate(cf.newCoordinateID(), name, dict(mu=mu, sigma=sigma), deg) print(f"[Coord {cid}] NORMAL(mu={mu:.3f}, sigma={sigma:.3f}), " @@ -36,7 +36,7 @@ def random_coordinate(cf, cid): elif coord_type == "uniform": a = random.uniform(-3.0, 0.0) b = a + random.uniform(1.0, 5.0) - deg = random.randint(1, 4) + deg = random.randint(1, max_deg) coord = cf.createUniformCoordinate(cf.newCoordinateID(), name, dict(a=a, b=b), deg) print(f"[Coord {cid}] UNIFORM(a={a:.3f}, b={b:.3f}), " @@ -46,7 +46,7 @@ def random_coordinate(cf, cid): elif coord_type == "exponential": mu = random.uniform(0.0, 2.0) beta = random.uniform(0.5, 2.0) - deg = random.randint(1, 4) + deg = random.randint(1, max_deg) coord = cf.createExponentialCoordinate(cf.newCoordinateID(), name, dict(mu=mu, beta=beta), deg) print(f"[Coord {cid}] EXPONENTIAL(mu={mu:.3f}, beta={beta:.3f}), " @@ -99,14 +99,14 @@ def random_polynomial(cs, max_deg=2, max_terms=3): # Problem setup #=====================================================================# -def get_coordinate_system_type(basis_type): +def get_coordinate_system_type(basis_type, max_deg = 4, max_coords = 3): cf = CoordinateFactory() cs = CoordinateSystem(basis_type) - ncoords = random.randint(1, 3) + ncoords = random.randint(1, max_coords) print(f"[Setup] Using {ncoords} coordinates") for cid in range(ncoords): - coord = random_coordinate(cf, cid) + coord = random_coordinate(cf, cid, max_deg) cs.addCoordinateAxis(coord) cs.initialize() @@ -123,7 +123,8 @@ def test_randomized_tensor_basis_sparse_full(trial): print(f"\n=== Trial {trial} : Tensor Degree Basis (sparse vs full assembly) ===") - cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg = 4, max_coords = 4) fexpr, dfunc, fdeg = random_polynomial(cs) @@ -142,7 +143,8 @@ def test_randomized_total_basis_sparse_full(trial): print(f"\n=== Trial {trial} : Total Degree Basis (sparse vs full assembly) ===") - cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg = 6, max_coords = 6) fexpr, dfunc, fdeg = random_polynomial(cs) From 672729423f0ace38e76a52135243142a8be74953 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Tue, 23 Sep 2025 21:47:45 -0400 Subject: [PATCH 30/54] cleanup stochastic utilities --- pspace/stochastic_utils.py | 39 +++++++++----------------------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 9b31bb0..9aa14a4 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -35,14 +35,11 @@ def sparse(dmapi, dmapj, dmapf): return smap def minnum_quadrature_points(degree): - return math.ceil((degree+1)/2) - -def nqpts(pdeg): """ Return the number of quadrature points necessary to integrate the monomial of degree deg. """ - return max(pdeg/2+1,1) #1 + pdeg/2 #max(deg/2+1,1) + return math.ceil((degree+1)/2) def generate_basis_tensor_degree(max_degree_params): """ @@ -66,33 +63,15 @@ def generate_basis_tensor_degree(max_degree_params): total_tensor_basis_terms = int(np.prod(pdegs)) - term_polynomial_degree = {} + basis = {} k = 0 for degrees in product(*[range(d+1) for d in pdegs]): - term_polynomial_degree[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) + basis[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) k += 1 - return term_polynomial_degree - - -def generate_basis_total_degree(max_degrees): - """ - Build total-degree basis: all multi-indices with sum(deg_i) <= max. - max_degrees : dict {cid: max_degree} - Returns : dict {basis_id: Counter({cid:deg,...})} - """ - cids = list(max_degrees.keys()) - degrees = list(max_degrees.values()) - basis = {} - k = 0 - - for deg_tuple in product(*[range(d+1) for d in degrees]): - if sum(deg_tuple) <= max(degrees): # total degree filter - basis[k] = Counter({cid: d for cid, d in zip(cids, deg_tuple)}) - k += 1 return basis -def generate_basis_total_degree_old(max_degree_params): +def generate_basis_total_degree(max_degree_params): """ Construct a total-degree index map for basis functions. @@ -104,7 +83,7 @@ def generate_basis_total_degree_old(max_degree_params): Returns ------- - term_polynomial_degree : dict + basis : dict Map {basis_index : Counter({pid: degree, ...})}. Each entry gives the parameterwise polynomial degrees for that basis term, with sum(degrees) <= max(total_degrees). @@ -114,18 +93,18 @@ def generate_basis_total_degree_old(max_degree_params): max_total_degree = max(pdegs) - term_polynomial_degree = {} + basis = {} k = 0 for degrees in product(*[range(d+1) for d in pdegs]): if sum(degrees) <= max_total_degree: - term_polynomial_degree[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) + basis[k] = Counter({pid: deg for pid, deg in zip(pids, degrees)}) k += 1 - return term_polynomial_degree + return basis if __name__ == '__main__': - max_degree_params = {0:3, 1:3, 2:3} + max_degree_params = {0:2, 1:2} out = generate_basis_tensor_degree(max_degree_params) print(len(out), out) From 1e61ed03a3d83bb9475b0415eb5f89e3374407e9 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:20:15 -0400 Subject: [PATCH 31/54] add polyfunction and matrix decomposition based on polyfunction interface --- pspace/core.py | 282 ++++++++++++++++++++++++++++-------- pspace/stochastic_utils.py | 24 +++ tests/test_decomposition.py | 4 +- 3 files changed, 245 insertions(+), 65 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index e0a3955..04d7830 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -25,11 +25,8 @@ # Local modules from .stochastic_utils import ( minnum_quadrature_points, - generate_basis_tensor_degree, - generate_basis_total_degree, - sum_degrees, - safe_zero_degrees -) + generate_basis_tensor_degree, generate_basis_total_degree, + sum_degrees, safe_zero_degrees, sum_degrees_union) from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre @@ -304,7 +301,7 @@ class CoordinateSystem: 3) integrates inner products via tensor-product quadrature. """ def __init__(self, basis_type, verbose=False): - self.coordinates = {} # cid -> Coordinate + self.coordinates = {} # {cid : Coordinate} self.basis_construction = basis_type self.basis = None # {basis_id: Counter({cid:deg,...})} self.verbose = bool(verbose) @@ -317,9 +314,9 @@ def __str__(self): #-----------------------------------------------------------------# def evaluate_basis(self, yscalar, degree: int): - """ψ(y) = ψ(z(y)), evaluated symbolically in Y-frame.""" - z = self.physical_to_standard(yscalar) - return self.psi_z(z, degree) + """ψ(y) = ψ(z(y)), evaluated in Y-frame.""" + zscalar = self.physical_to_standard(yscalar) + return self.psi_z(zscalar, degree) def getNumBasisFunctions(self): return len(self.basis) if self.basis is not None else 0 @@ -417,23 +414,6 @@ def sparse_vector(self, dmapi, dmapf): else: raise ValueError("Unknown basis_type") - def sparse_matrix(self, dmapi, dmapj, dmapf): - """ - Detect sparsity for <ψ_i, f, ψ_j>. - - dmapi, dmapj : Counter({axis: degree}) for bases ψ_i, ψ_j - dmapf : Counter({axis: degree}) for function f - basis_type : "tensor" or "total" - """ - if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: - # Axis-by-axis cutoff - return all((dmapi[k] + dmapj[k]) <= dmapf[k] for k in dmapf.keys()) - elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: - # Global cutoff (total degree) - return (sum(dmapi.values()) + sum(dmapj.values())) <= sum(dmapf.values()) - else: - raise ValueError("Unknown basis_type") - #-----------------------------------------------------------------# # Inner products #-----------------------------------------------------------------# @@ -555,54 +535,149 @@ def decompose_analytic(self, f_eval, f_deg: Counter): return coeffs - #-----------------------------------------------------------------# - # Analytic decomposition (to fix) - #-----------------------------------------------------------------# + def admissible_pair(self, deg_i: Counter, deg_j: Counter, f_deg: Counter) -> bool: + """ + Axis-wise admissibility rule for a single monomial. + + Parameters + ---------- + deg_i, deg_j : Counter + Degree structure of basis functions psi_i, psi_j. + f_deg : Counter + Degree structure of one monomial in f. - def decompose_analytic_to_fix(self, f_eval, f_deg: Counter): + Returns + ------- + bool + True if can be nonzero. + + Rule + ---- + For every axis d: + |deg_i(d) - deg_j(d)| <= f_deg(d) <= deg_i(d) + deg_j(d) """ - Analytic decomposition using SymPy: - c_k = ∫ f(y) ψ_k(y) ρ(y) dy over the domain. - Arguments - --------- - f_eval : callable({cid: sympy.Symbol}) -> sympy.Expr - User-supplied function of physical variables y. - f_deg : Counter({cid: degree}) - Polynomial degrees of f (used in numeric path). + """ + Axis-wise admissibility rule for a single monomial. + + If f_deg is empty (constant monomial), then all (i,j) pairs are admissible. + """ + + # Constant monomial ⇒ don't filter anything + if not f_deg: + return True + + axes = set(deg_i) | set(deg_j) | set(f_deg) + for d in axes: + di, dj, df = deg_i.get(d, 0), deg_j.get(d, 0), f_deg.get(d, 0) + if not (abs(di - dj) <= df <= di + dj): + return False + return True + + def monomial_sparsity_mask(self, f_deg: Counter, symmetric: bool = False): + """ + Sparsity mask for a single monomial term in f. + Constant monomial admits all (i,j). + """ + mask = set() + basis_keys = sorted(self.basis.keys()) + + if not f_deg: + # Constant term: admit everything + for ii, i in enumerate(basis_keys): + jstart = ii if symmetric else 0 + for j in basis_keys[jstart:]: + mask.add((i, j)) + return mask + + for ii, i in enumerate(basis_keys): + jstart = ii if symmetric else 0 + for j in basis_keys[jstart:]: + if self.admissible_pair(self.basis[i], self.basis[j], f_deg): + mask.add((i, j)) + return mask + + def polynomial_sparsity_mask(self, f_degrees: list[Counter], symmetric: bool = False): + """ + Sparsity mask for a full polynomial f, as union of monomial masks. + + Parameters + ---------- + f_degrees : list of Counters + Each Counter gives the degree structure of one monomial in f. + symmetric : bool + If True, only return pairs (i,j) with i <= j. Returns ------- - coeffs : dict {basis_id: sympy.Expr or constant} + mask : set of (i,j) tuples """ - coords = self.coordinates - symbols = {cid: coord.symbol for cid, coord in coords.items()} - f_expr = f_eval(symbols) + mask = set() + for f_deg in f_degrees: + mask |= self.monomial_sparsity_mask(f_deg, symmetric=symmetric) + return mask - coeffs = {} - for k, psi_k in self.basis.items(): - # Build basis polynomial ψ_k(y) = ∏ ψ_{deg}(y_cid) - psi_expr = 1 - for cid, deg in psi_k.items(): - y = coords[cid].symbol - psi_expr *= coords[cid].evaluate_basis(y, deg) + def decompose_matrix(self, f_eval, sparse=False, symmetric=True): + """ + Assemble A_ij = ∫ psi_i(y) psi_j(y) f(y) w(y) dy (dense). - # Integrand in physical space - integrand = f_expr * psi_expr + Parameters + ---------- + f_eval : PolyFunction + Callable with .degrees property for sparsity. + sparse : bool + If True, restrict to admissible pairs. + symmetric : bool + If True, compute only i ≤ j and mirror. - # Nested univariate integrals with weight per axis - val = integrand - for cid, coord in coords.items(): - y = coord.symbol - w = coord.weight(y) - a, b = coord.domain() - val = sp.integrate(val * w, (y, a, b)) + Returns + ------- + A : np.ndarray + Dense (nbasis x nbasis) coefficient matrix. + """ + nbasis = self.getNumBasisFunctions() + A = np.zeros((nbasis, nbasis)) - coeffs[k] = sp.simplify(val) + # Build admissible mask + if sparse: + mask = self.polynomial_sparsity_mask(f_eval.degrees, symmetric=symmetric) + else: + if symmetric: + mask = {(i,j) for i in self.basis for j in self.basis if i <= j} + else: + mask = {(i,j) for i in self.basis for j in self.basis} + + qcache = {} + for i, j in mask: + psi_i, psi_j = self.basis[i], self.basis[j] + need = sum_degrees_union(f_eval.degrees, psi_i, psi_j) + + key = tuple(sorted(need.items())) + qmap = qcache.get(key) + if qmap is None: + qmap = self.build_quadrature(need) + qcache[key] = qmap - return coeffs + s = 0.0 + for q in qmap.values(): + y = q['Y'] + s += (f_eval(y) + * self.evaluateBasisDegreesY(y, psi_i) + * self.evaluateBasisDegreesY(y, psi_j)) * q['W'] + + A[i,j] = s + if symmetric and i != j: + A[j,i] = s + + return A + + #-----------------------------------------------------------------# + # Consistency checks + #-----------------------------------------------------------------# def check_orthonormality(self): + """ + """ nbasis = self.getNumBasisFunctions() A = np.zeros((nbasis, nbasis)) for ii in range(nbasis): @@ -610,9 +685,6 @@ def check_orthonormality(self): A[ii,jj] = self.inner_product_basis(ii, jj) return np.linalg.norm(A - np.eye(nbasis), ord=np.inf) - #-----------------------------------------------------------------# - # Consistency check - #-----------------------------------------------------------------# def check_decomposition_numerical_symbolic(self, f_eval, f_deg: Counter, tol=1e-10, verbose=True): @@ -699,3 +771,87 @@ def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, print("-" * len(header)) return ok, diffs + + def check_decomposition_matrix_sparse_full(self, f_eval, tol=1e-12, verbose=True): + """ + Cross-check sparse vs full assembly of rank-2 (matrix) decomposition + coefficients. + """ + from timeit import default_timer as timer + + #---------------------------------------------------------------# + # Assemble sparse + full + #---------------------------------------------------------------# + + start_sparse = timer() + A_sparse = self.decompose_matrix(f_eval, sparse=True, symmetric=True) + elapsed_sparse = timer() - start_sparse + + start_full = timer() + A_full = self.decompose_matrix(f_eval, sparse=False, symmetric=True) + elapsed_full = timer() - start_full + + #---------------------------------------------------------------# + # Compute differences + #---------------------------------------------------------------# + + diffs, ok = {}, True + nbasis = self.getNumBasisFunctions() + for i in range(nbasis): + for j in range(nbasis): + vsparse = A_sparse[i, j] + vfull = A_full[i, j] + err = abs(vsparse - vfull) + if err > tol: + ok = False + diffs[(i, j)] = (vsparse, vfull, err) + + #---------------------------------------------------------------# + # Report + #---------------------------------------------------------------# + + if verbose: + print(f"[Matrix Assembly Check] {'PASSED' if ok else 'FAILED'} " + f"with tol = {tol}") + print(f"[Elapsed Time] Sparse {elapsed_sparse:.4e} " + f"Full {elapsed_full:.4e} " + f"Ratio {elapsed_full/elapsed_sparse:.2f}") + header = f"{'i':<3} {'j':<3} {'Sparse':>12} {'Full':>12} {'Error':>12}" + print(header) + print("-" * len(header)) + for (i, j), (vs, vf, e) in diffs.items(): + if abs(e) > tol: # only print significant diffs + print(f"{i:<3d} {j:<3d} {float(vs):12.6f} " + f"{float(vf):12.6f} {float(e):12.2e}") + print("-" * len(header)) + + return ok, diffs + + +class PolyFunction: + def __init__(self, terms): + """ + terms : list of (coeff, Counter) pairs + Example: + [ + (3, Counter({})), # constant + (3, Counter({0:1})), # 3*y0 + (3, Counter({0:2, 1:1})) # 3*y0^2 * y1 + ] + """ + self.terms = terms + + def __call__(self, y): + """Evaluate polynomial at dict y={cid: value}""" + total = 0.0 + for coeff, degs in self.terms: + mon = coeff + for cid, d in degs.items(): + mon *= y[cid]**d + total += mon + return total + + @property + def degrees(self): + """Return list of Counters (ignores coeffs) for sparsity mask""" + return [degs for _, degs in self.terms] diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 9aa14a4..ee4d4ea 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -102,6 +102,17 @@ def generate_basis_total_degree(max_degree_params): return basis + +def sum_degrees_union(f_degrees, psi_i, psi_j): + """Union over all monomials in f: max degree per axis.""" + degs = Counter() + for f_deg in f_degrees: + for a in set(f_deg) | set(psi_i) | set(psi_j): + degs[a] = max(degs.get(a,0), + f_deg.get(a,0) + psi_i.get(a,0) + psi_j.get(a,0)) + return degs + + if __name__ == '__main__': max_degree_params = {0:2, 1:2} @@ -111,3 +122,16 @@ def generate_basis_total_degree(max_degree_params): out = generate_basis_total_degree(max_degree_params) print(len(out), out) + + basis = { + 0: Counter({'x': 0, 'y': 0}), + 1: Counter({'x': 1, 'y': 0}), + 2: Counter({'x': 0, 'y': 1}), + 3: Counter({'x': 1, 'y': 1}) + } + + deg_f = Counter({'x': 1, 'y': 0}) + + mask = sparsity_mask(basis, deg_f) + + print(mask) diff --git a/tests/test_decomposition.py b/tests/test_decomposition.py index b3caa17..34e03ad 100644 --- a/tests/test_decomposition.py +++ b/tests/test_decomposition.py @@ -124,7 +124,7 @@ def test_randomized_tensor_basis_sparse_full(trial): print(f"\n=== Trial {trial} : Tensor Degree Basis (sparse vs full assembly) ===") cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, - max_deg = 4, max_coords = 4) + max_deg = 3, max_coords = 3) fexpr, dfunc, fdeg = random_polynomial(cs) @@ -144,7 +144,7 @@ def test_randomized_total_basis_sparse_full(trial): print(f"\n=== Trial {trial} : Total Degree Basis (sparse vs full assembly) ===") cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, - max_deg = 6, max_coords = 6) + max_deg = 3, max_coords = 3) fexpr, dfunc, fdeg = random_polynomial(cs) From 9b9217b7be1779476eafe34025354115be61517f Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:21:00 -0400 Subject: [PATCH 32/54] separate pytest for vector decomposition --- tests/{test_decomposition.py => test_vector_decomposition.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_decomposition.py => test_vector_decomposition.py} (100%) diff --git a/tests/test_decomposition.py b/tests/test_vector_decomposition.py similarity index 100% rename from tests/test_decomposition.py rename to tests/test_vector_decomposition.py From ff90681d0916b2db9631187f59f61d6714deab93 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:21:36 -0400 Subject: [PATCH 33/54] added empty pytest file for matrix decomposition --- tests/test_matrix_decomposition.py | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_matrix_decomposition.py diff --git a/tests/test_matrix_decomposition.py b/tests/test_matrix_decomposition.py new file mode 100644 index 0000000..2e1d3bd --- /dev/null +++ b/tests/test_matrix_decomposition.py @@ -0,0 +1,36 @@ +import numpy as np +from collections import Counter + +def test_decompose_matrix_basic(space): + """ + space : your stochastic space object, with: + - .basis {i: Counter} + - .getNumBasisFunctions() + - .evaluateBasisDegreesY() + - .build_quadrature() + - .decompose_matrix() + """ + + # Polynomial: f(y) = 3 + 3*y0 + 3*y0^2*y1 + from yourmodule import PolyFunction # wherever you put the class + + f_eval = PolyFunction([ + (3, Counter({})), # constant + (3, Counter({0:1})), # linear + (3, Counter({0:2, 1:1})) # mixed quadratic + ]) + + # Dense (full) assembly + A_full = space.decompose_matrix(f_eval, sparse=False, symmetric=True) + + # Sparse (polynomial_sparsity_mask) assembly + A_sparse = space.decompose_matrix(f_eval, sparse=True, symmetric=True) + + # Compare + diff = np.max(np.abs(A_full - A_sparse)) + print("max diff =", diff) + print("full matrix:\n", A_full) + print("sparse matrix:\n", A_sparse) + + # Assert equality within tolerance + assert np.allclose(A_full, A_sparse, atol=1e-10) From 8070215332dd0b7741105e2dade7cd8d2b0a9755 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:35:52 -0400 Subject: [PATCH 34/54] organized tests --- tests/test_vector_decomposition.py | 66 +++++++++++++++--------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/tests/test_vector_decomposition.py b/tests/test_vector_decomposition.py index 34e03ad..f781420 100644 --- a/tests/test_vector_decomposition.py +++ b/tests/test_vector_decomposition.py @@ -96,7 +96,8 @@ def random_polynomial(cs, max_deg=2, max_terms=3): return fexpr, dfunc, fdeg #=====================================================================# -# Problem setup +# Common coordinate system setup for given basis type, degrees of +# freedom and number of coordinates #=====================================================================# def get_coordinate_system_type(basis_type, max_deg = 4, max_coords = 3): @@ -114,79 +115,76 @@ def get_coordinate_system_type(basis_type, max_deg = 4, max_coords = 3): return cs #=====================================================================# -# Tests 1 B: Tensor Degree Basis (sparse vs full assembly) +# Symbolic and numerical vector decomposition tests #=====================================================================# @pytest.mark.parametrize("trial", range(5)) -def test_randomized_tensor_basis_sparse_full(trial): +def test_randomized_tensor_numerical_symbolic(trial): random.seed(trial) - print(f"\n=== Trial {trial} : Tensor Degree Basis (sparse vs full assembly) ===") + print(f"\n=== Trial {trial} : Tensor Degree Basis (numerical vs symbolic) ===") - cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, - max_deg = 3, max_coords = 3) + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok -#=====================================================================# -# Tests 2 B: Total Degree Basis (sparse vs full assembly) -#=====================================================================# - @pytest.mark.parametrize("trial", range(5)) -def test_randomized_total_basis_sparse_full(trial): +def test_randomized_total_numerical_symbolic(trial): random.seed(trial) - print(f"\n=== Trial {trial} : Total Degree Basis (sparse vs full assembly) ===") + print(f"\n=== Trial {trial} : Total Degree Basis (numerical vs symbolic) ===") - cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, - max_deg = 3, max_coords = 3) + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok + #=====================================================================# -# Tests 1 A : Tensor Basis (numerical vs symbolic) +# Sparsity-aware and sparsity-unware vector decomposition tests #=====================================================================# @pytest.mark.parametrize("trial", range(5)) -def test_randomized_tensor_numerical_symbolic(trial): +def test_randomized_tensor_basis_sparse_full(trial): random.seed(trial) - print(f"\n=== Trial {trial} : Tensor Degree Basis (numerical vs symbolic) ===") + print(f"\n=== Trial {trial} : Tensor Degree Basis (sparse vs full assembly) ===") - cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg = 3, max_coords = 3) fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok #=====================================================================# -# Tests 2 A : Total Degree Basis (numerical vs symbolic) +# Tests 2 B: Total Degree Basis (sparse vs full assembly) #=====================================================================# @pytest.mark.parametrize("trial", range(5)) -def test_randomized_total_numerical_symbolic(trial): +def test_randomized_total_basis_sparse_full(trial): random.seed(trial) - print(f"\n=== Trial {trial} : Total Degree Basis (numerical vs symbolic) ===") + print(f"\n=== Trial {trial} : Total Degree Basis (sparse vs full assembly) ===") - cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg = 3, max_coords = 3) fexpr, dfunc, fdeg = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, - tol=1e-6, - verbose=True) + ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + tol=1e-6, + verbose=True) assert ok From 41427b292a984340a024ebe60187a97bac58af17 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:39:30 -0400 Subject: [PATCH 35/54] standalone matrix decomposition demo --- demos/demo_matrix_decomposition.py | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 demos/demo_matrix_decomposition.py diff --git a/demos/demo_matrix_decomposition.py b/demos/demo_matrix_decomposition.py new file mode 100644 index 0000000..ddce32c --- /dev/null +++ b/demos/demo_matrix_decomposition.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python + +import numpy as np +from collections import Counter + +# Import from your package +from pspace.core import CoordinateFactory, CoordinateSystem, BasisFunctionType, PolyFunction + +def main(): + + #-------------------------------------------------------------# + # 1. Build coordinate system with 2 axes (uniform for demo) + #-------------------------------------------------------------# + + cf = CoordinateFactory() + + # y0 ~ Uniform[-1, 1], max deg = 2 + coord0 = cf.createUniformCoordinate( + coord_id=cf.newCoordinateID(), + coord_name="y0", + dist_coords={'a': -1.0, 'b': 1.0}, + max_monomial_dof=3 + ) + + # y1 ~ Uniform[-1, 1], max deg = 2 + coord1 = cf.createUniformCoordinate( + coord_id=cf.newCoordinateID(), + coord_name="y1", + dist_coords={'a': -1.0, 'b': 1.0}, + max_monomial_dof=2 + ) + + # Tensor-degree basis + cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE) + cs.addCoordinateAxis(coord0) + cs.addCoordinateAxis(coord1) + cs.initialize() + + print(f"Num basis functions = {cs.getNumBasisFunctions()}") + + #-------------------------------------------------------------# + # 2. Define polynomial f(y) = 3 + 3*y0 + 3*y0^2*y1 + #-------------------------------------------------------------# + + polyf = PolyFunction([ + (3.0, Counter({})), # constant + (3.0, Counter({0: 1})), # linear term y0 + (3.0, Counter({0: 2, 1: 1})) # mixed quadratic + ]) + + #-------------------------------------------------------------# + # 3. Run sparse vs full assembly check + #-------------------------------------------------------------# + + print(polyf.degrees) + + ok, diffs = cs.check_decomposition_matrix_sparse_full(polyf, tol=1e-10, verbose=True) + print("[Result] Matrix assembly check:", "PASSED" if ok else "FAILED") + +if __name__ == "__main__": + main() From 4eb91407f7f6a74a38aaeccf366772be443394f9 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 10:49:45 -0400 Subject: [PATCH 36/54] code comments --- pspace/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pspace/core.py b/pspace/core.py index 04d7830..faa889e 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -539,6 +539,10 @@ def admissible_pair(self, deg_i: Counter, deg_j: Counter, f_deg: Counter) -> boo """ Axis-wise admissibility rule for a single monomial. + admissible_pair = atomic check (axis-wise rule) + monomial_sparsity_mask = per-monomial mask + polynomial_sparsity_mask = per-polynomial union of masks + Parameters ---------- deg_i, deg_j : Counter From b8a23b86e59e6014560238ee4fe93c9864b0b622 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 11:04:11 -0400 Subject: [PATCH 37/54] created separate utils under tests for modular testing --- tests/test_utils.py | 119 +++++++++++++++++++++++++++++ tests/test_vector_decomposition.py | 106 ++----------------------- 2 files changed, 124 insertions(+), 101 deletions(-) create mode 100644 tests/test_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..c93da18 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,119 @@ +#=====================================================================# +# Common stuff for testing +# +# Author : Komahan Boopathy (komahan@gatech.edu) +#=====================================================================# + +# python module imports +import random +import sympy as sp +import pytest +from collections import Counter + +# core module imports +from pspace.core import ( + CoordinateFactory, + CoordinateSystem, + BasisFunctionType +) + +# local module imports + +#=====================================================================# +# Helper : randomized coordinate factory with logging +#=====================================================================# + +def random_coordinate(cf, cid, max_deg = 4): + coord_type = random.choice(["normal", "uniform", "exponential"]) + name = f"y{cid}" + + if coord_type == "normal": + mu = random.uniform(-2.0, 2.0) + sigma = random.uniform( 0.5, 2.0) + deg = random.randint(1, max_deg) + coord = cf.createNormalCoordinate(cf.newCoordinateID(), name, + dict(mu=mu, sigma=sigma), deg) + print(f"[Coord {cid}] NORMAL(mu={mu:.3f}, sigma={sigma:.3f}), " + f"deg={deg}") + return coord + + elif coord_type == "uniform": + a = random.uniform(-3.0, 0.0) + b = a + random.uniform(1.0, 5.0) + deg = random.randint(1, max_deg) + coord = cf.createUniformCoordinate(cf.newCoordinateID(), name, + dict(a=a, b=b), deg) + print(f"[Coord {cid}] UNIFORM(a={a:.3f}, b={b:.3f}), " + f"deg={deg}") + return coord + + elif coord_type == "exponential": + mu = random.uniform(0.0, 2.0) + beta = random.uniform(0.5, 2.0) + deg = random.randint(1, max_deg) + coord = cf.createExponentialCoordinate(cf.newCoordinateID(), name, + dict(mu=mu, beta=beta), deg) + print(f"[Coord {cid}] EXPONENTIAL(mu={mu:.3f}, beta={beta:.3f}), " + f"deg={deg}") + return coord + +#=====================================================================# +# Helper : build random polynomial (with cross terms) +#=====================================================================# + +def random_polynomial(cs, max_deg=2, max_terms=3): + coords = list(cs.coordinates.keys()) + symbols = {cid : cs.coordinates[cid].symbol for cid in coords} + fdeg = Counter() + terms = [] + + #---------------------------------------------------------------# + # Individual terms + #---------------------------------------------------------------# + for cid in coords: + deg = random.randint(0, max_deg) + coeff = random.randint(1, 3) + terms.append(coeff * symbols[cid]**deg) + fdeg[cid] = max(fdeg.get(cid, 0), deg) + + #---------------------------------------------------------------# + # Cross terms + #---------------------------------------------------------------# + if len(coords) >= 2: + for _ in range(random.randint(0, max_terms)): + cids = random.sample(coords, k=random.randint(2, len(coords))) + coeff = random.randint(1, 3) + term = coeff + for cid in cids: + d = random.randint(1, max_deg) + term *= symbols[cid]**d + fdeg[cid] = max(fdeg.get(cid, 0), d) + terms.append(term) + + fexpr = sum(terms) + + # numeric callable : Y is dict(cid -> float) + fnum = sp.lambdify([list(symbols.values())], fexpr, "numpy") + dfunc = lambda Y: fnum([Y[cid] for cid in coords]) + + print(f"[Polynomial] f(y) = {fexpr}, degrees={dict(fdeg)}") + return fexpr, dfunc, fdeg + +#=====================================================================# +# Common coordinate system setup for given basis type, degrees of +# freedom and number of coordinates +#=====================================================================# + +def get_coordinate_system_type(basis_type, max_deg = 4, max_coords = 3): + cf = CoordinateFactory() + cs = CoordinateSystem(basis_type) + + ncoords = random.randint(1, max_coords) + print(f"[Setup] Using {ncoords} coordinates") + for cid in range(ncoords): + coord = random_coordinate(cf, cid, max_deg) + cs.addCoordinateAxis(coord) + + cs.initialize() + + return cs diff --git a/tests/test_vector_decomposition.py b/tests/test_vector_decomposition.py index f781420..905a3cd 100644 --- a/tests/test_vector_decomposition.py +++ b/tests/test_vector_decomposition.py @@ -4,115 +4,19 @@ # Author : Komahan Boopathy (komahan@gatech.edu) #=====================================================================# -import random -import sympy as sp +# python module imports import pytest -from collections import Counter +import random +# core module imports from pspace.core import ( CoordinateFactory, CoordinateSystem, BasisFunctionType ) -#=====================================================================# -# Helper : randomized coordinate factory with logging -#=====================================================================# - -def random_coordinate(cf, cid, max_deg = 4): - coord_type = random.choice(["normal", "uniform", "exponential"]) - name = f"y{cid}" - - if coord_type == "normal": - mu = random.uniform(-2.0, 2.0) - sigma = random.uniform( 0.5, 2.0) - deg = random.randint(1, max_deg) - coord = cf.createNormalCoordinate(cf.newCoordinateID(), name, - dict(mu=mu, sigma=sigma), deg) - print(f"[Coord {cid}] NORMAL(mu={mu:.3f}, sigma={sigma:.3f}), " - f"deg={deg}") - return coord - - elif coord_type == "uniform": - a = random.uniform(-3.0, 0.0) - b = a + random.uniform(1.0, 5.0) - deg = random.randint(1, max_deg) - coord = cf.createUniformCoordinate(cf.newCoordinateID(), name, - dict(a=a, b=b), deg) - print(f"[Coord {cid}] UNIFORM(a={a:.3f}, b={b:.3f}), " - f"deg={deg}") - return coord - - elif coord_type == "exponential": - mu = random.uniform(0.0, 2.0) - beta = random.uniform(0.5, 2.0) - deg = random.randint(1, max_deg) - coord = cf.createExponentialCoordinate(cf.newCoordinateID(), name, - dict(mu=mu, beta=beta), deg) - print(f"[Coord {cid}] EXPONENTIAL(mu={mu:.3f}, beta={beta:.3f}), " - f"deg={deg}") - return coord - -#=====================================================================# -# Helper : build random polynomial (with cross terms) -#=====================================================================# - -def random_polynomial(cs, max_deg=2, max_terms=3): - coords = list(cs.coordinates.keys()) - symbols = {cid : cs.coordinates[cid].symbol for cid in coords} - fdeg = Counter() - terms = [] - - #---------------------------------------------------------------# - # Individual terms - #---------------------------------------------------------------# - for cid in coords: - deg = random.randint(0, max_deg) - coeff = random.randint(1, 3) - terms.append(coeff * symbols[cid]**deg) - fdeg[cid] = max(fdeg.get(cid, 0), deg) - - #---------------------------------------------------------------# - # Cross terms - #---------------------------------------------------------------# - if len(coords) >= 2: - for _ in range(random.randint(0, max_terms)): - cids = random.sample(coords, k=random.randint(2, len(coords))) - coeff = random.randint(1, 3) - term = coeff - for cid in cids: - d = random.randint(1, max_deg) - term *= symbols[cid]**d - fdeg[cid] = max(fdeg.get(cid, 0), d) - terms.append(term) - - fexpr = sum(terms) - - # numeric callable : Y is dict(cid -> float) - fnum = sp.lambdify([list(symbols.values())], fexpr, "numpy") - dfunc = lambda Y: fnum([Y[cid] for cid in coords]) - - print(f"[Polynomial] f(y) = {fexpr}, degrees={dict(fdeg)}") - return fexpr, dfunc, fdeg - -#=====================================================================# -# Common coordinate system setup for given basis type, degrees of -# freedom and number of coordinates -#=====================================================================# - -def get_coordinate_system_type(basis_type, max_deg = 4, max_coords = 3): - cf = CoordinateFactory() - cs = CoordinateSystem(basis_type) - - ncoords = random.randint(1, max_coords) - print(f"[Setup] Using {ncoords} coordinates") - for cid in range(ncoords): - coord = random_coordinate(cf, cid, max_deg) - cs.addCoordinateAxis(coord) - - cs.initialize() - - return cs +# local module imports +from .test_utils import (random_coordinate, random_polynomial, get_coordinate_system_type) #=====================================================================# # Symbolic and numerical vector decomposition tests From d1794c46d8f262dbbb446d2d563a6bf5ca31d515 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 13:23:55 -0400 Subject: [PATCH 38/54] - vector decompose would use polyfunction interface - unify sparse and full vector decomposition - new test_util for common testing stuff --- pspace/core.py | 247 +++++++++++++++++++++-------- pspace/stochastic_utils.py | 8 + tests/test_utils.py | 45 +++--- tests/test_vector_decomposition.py | 40 +++-- 4 files changed, 235 insertions(+), 105 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index faa889e..4117518 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -23,10 +23,13 @@ from itertools import product # Local modules -from .stochastic_utils import ( - minnum_quadrature_points, - generate_basis_tensor_degree, generate_basis_total_degree, - sum_degrees, safe_zero_degrees, sum_degrees_union) +from .stochastic_utils import (minnum_quadrature_points, + generate_basis_tensor_degree, + generate_basis_total_degree, + sum_degrees, + safe_zero_degrees, + sum_degrees_union, + sum_degrees_union_vector) from .orthogonal_polynomials import unit_hermite from .orthogonal_polynomials import unit_legendre @@ -64,6 +67,48 @@ class BasisFunctionType(Enum): TOTAL_DEGREE = 1 ADAPTIVE_DEGREE = 2 +class PolyFunction: + def __init__(self, terms): + """ + terms : list of (coeff, Counter) pairs + Example: + [ + (3, Counter({})), # constant + (3, Counter({0:1})), # 3*y0 + (3, Counter({0:2, 1:1})) # 3*y0^2 * y1 + ] + + terms: list of (coeff: float, degs: Counter) + """ + # self.terms = terms + + # enforce format check before acceptance + self.terms = [] + for t in terms: + if isinstance(t, tuple) and isinstance(t[1], Counter): + coeff, degs = t + self.terms.append((coeff, degs)) + else: + raise TypeError(f"Invalid term format: {t!r}") + + @property + def degrees(self): + """Return list of Counters, one per monomial.""" + return [degs for _, degs in self.terms] + + def __call__(self, Y): + """Evaluate polynomial at dict y={cid: value}""" + total = 0.0 + for coeff, degs in self.terms: + mon = coeff + for cid, d in degs.items(): + mon *= Y[cid]**d + total += mon + return total + + def __repr__(self): + return f"PolyFunction({self.terms})" + #=====================================================================# # Coordinate Base Class #=====================================================================# @@ -414,6 +459,19 @@ def sparse_vector(self, dmapi, dmapf): else: raise ValueError("Unknown basis_type") + def monomial_vector_sparsity_mask(self, f_deg: Counter): + mask = set() + for i, psi_i in self.basis.items(): + if self.sparse_vector(psi_i, f_deg): + mask.add(i) + return mask + + def polynomial_vector_sparsity_mask(self, f_degrees: list[Counter]): + mask = set() + for f_deg in f_degrees: + mask |= self.monomial_vector_sparsity_mask(f_deg) + return mask + #-----------------------------------------------------------------# # Inner products #-----------------------------------------------------------------# @@ -436,7 +494,8 @@ def inner_product(self, f_eval, g_eval, s += f_eval(y) * g_eval(y) * q['W'] return s - def inner_product_basis(self, i_id: int, j_id: int, + def inner_product_basis(self, + i_id: int, j_id: int, f_eval=None, f_deg: Counter|None=None): """ <ψ_i, f, ψ_j> in Y-frame. @@ -463,57 +522,76 @@ def inner_product_basis(self, i_id: int, j_id: int, # Decomposition #-----------------------------------------------------------------# - def decompose(self, f_eval, f_deg: Counter): + def decompose(self, function: PolyFunction): """ Coefficients c_k = in Y-frame. """ coeffs = {} for k, psi_k in self.basis.items(): - need = sum_degrees(f_deg, psi_k) + need = sum_degrees(function.degrees, psi_k) qmap = self.build_quadrature(need) s = 0.0 for q in qmap.values(): y = q['Y'] - s += f_eval(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] + s += function(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] coeffs[k] = s return coeffs - def decompose_vector_sparse(self, f_eval, f_deg: Counter): + #-----------------------------------------------------------------# + # Decomposition + #-----------------------------------------------------------------# + + def decompose(self, function: PolyFunction, sparse: bool = False): """ Coefficients c_k = in Y-frame. + + Parameters + ---------- + function : PolyFunction + Callable polynomial with .degrees list of Counters. + sparse : bool + If True, restrict to admissible basis functions. + + Returns + ------- + coeffs : dict + Map basis_id -> coefficient value. """ coeffs = {} - for k, psi_k in self.basis.items(): - #---------------------------------------------------------# - # Sparsity filter - #---------------------------------------------------------# + # Build admissible mask + if sparse: + mask = self.polynomial_vector_sparsity_mask(function.degrees) + else: + mask = set(self.basis.keys()) - if not self.sparse_vector(psi_k, f_deg): + for k, psi_k in self.basis.items(): + if k not in mask: coeffs[k] = 0.0 continue - need = sum_degrees(f_deg, psi_k) + # union across monomials for quadrature requirement + need = sum_degrees_union_vector(function.degrees, psi_k) qmap = self.build_quadrature(need) s = 0.0 for q in qmap.values(): y = q['Y'] - s += f_eval(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] + s += function(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] coeffs[k] = s return coeffs - def decompose_analytic(self, f_eval, f_deg: Counter): + def decompose_analytic(self, function: PolyFunction): """ Analytic decomposition using SymPy: c_k = ∫ f(y) ψ_k(y) ρ(y) dy """ coords = self.coordinates symbols = {cid: coord.symbol for cid, coord in coords.items()} - f_expr = f_eval(symbols) + f_expr = function(symbols) coeffs = {} for k, psi_k in self.basis.items(): @@ -621,15 +699,15 @@ def polynomial_sparsity_mask(self, f_degrees: list[Counter], symmetric: bool = F mask |= self.monomial_sparsity_mask(f_deg, symmetric=symmetric) return mask - def decompose_matrix(self, f_eval, sparse=False, symmetric=True): + def decompose_matrix(self, function, sparse=False, symmetric=True): """ Assemble A_ij = ∫ psi_i(y) psi_j(y) f(y) w(y) dy (dense). Parameters ---------- - f_eval : PolyFunction + function : PolyFunction Callable with .degrees property for sparsity. - sparse : bool + sparse : bool If True, restrict to admissible pairs. symmetric : bool If True, compute only i ≤ j and mirror. @@ -644,7 +722,7 @@ def decompose_matrix(self, f_eval, sparse=False, symmetric=True): # Build admissible mask if sparse: - mask = self.polynomial_sparsity_mask(f_eval.degrees, symmetric=symmetric) + mask = self.polynomial_sparsity_mask(function.degrees, symmetric=symmetric) else: if symmetric: mask = {(i,j) for i in self.basis for j in self.basis if i <= j} @@ -654,7 +732,7 @@ def decompose_matrix(self, f_eval, sparse=False, symmetric=True): qcache = {} for i, j in mask: psi_i, psi_j = self.basis[i], self.basis[j] - need = sum_degrees_union(f_eval.degrees, psi_i, psi_j) + need = sum_degrees_union(function.degrees, psi_i, psi_j) key = tuple(sorted(need.items())) qmap = qcache.get(key) @@ -665,7 +743,7 @@ def decompose_matrix(self, f_eval, sparse=False, symmetric=True): s = 0.0 for q in qmap.values(): y = q['Y'] - s += (f_eval(y) + s += (function(y) * self.evaluateBasisDegreesY(y, psi_i) * self.evaluateBasisDegreesY(y, psi_j)) * q['W'] @@ -677,6 +755,7 @@ def decompose_matrix(self, f_eval, sparse=False, symmetric=True): #-----------------------------------------------------------------# # Consistency checks + # TestCoordinateSystem and supply the instance of cs #-----------------------------------------------------------------# def check_orthonormality(self): @@ -689,8 +768,69 @@ def check_orthonormality(self): A[ii,jj] = self.inner_product_basis(ii, jj) return np.linalg.norm(A - np.eye(nbasis), ord=np.inf) + def check_decomposition_numerical_symbolic(self, + function: PolyFunction, + tol=1e-10, + verbose=True): + """ + Cross-check numerical vs analytic decomposition. + """ + + # ensure basis is orthonormal first + ortho_tol = self.check_orthonormality() + + from timeit import default_timer as timer + + #-------------------------------------------------------------# + # Numerical decomposition (quadrature) + #-------------------------------------------------------------# + + start_num = timer() + coeffs_num = self.decompose(function, sparse=True) + elapsed_num = timer() - start_num + + #-------------------------------------------------------------# + # Analytic decomposition (Sympy) + #-------------------------------------------------------------# + + start_sym = timer() + coeffs_sym = self.decompose_analytic(function) + elapsed_sym = timer() - start_sym + + #-------------------------------------------------------------# + # Compare coefficients + #-------------------------------------------------------------# + + diffs, ok = {}, True + for k in coeffs_num.keys(): + num_val = float(coeffs_num[k]) + try: + ana_val = float(coeffs_sym[k].evalf()) + except Exception: + ana_val = float(sp.N(coeffs_sym[k], 15)) + err = abs(num_val - ana_val) + if err > tol: + ok = False + diffs[k] = (num_val, ana_val, err) + + #-------------------------------------------------------------# + # Reporting + #-------------------------------------------------------------# - def check_decomposition_numerical_symbolic(self, f_eval, f_deg: Counter, + if verbose: + status = "PASSED" if ok else "FAILED" + print(f"[Consistency Check] {status} with tol = {tol}, ortho tol = {ortho_tol:.2e}") + print(f"[Elapsed Time] numerical {elapsed_num:.3e} analytic = {elapsed_sym:.3e}") + header = f"{'Basis':<7} {'numerical':>12} {'analytic':>12} {'error':>12}" + print(header) + print("-" * len(header)) + for k, (n, a, e) in diffs.items(): + print(f"{k:<7d} {n:12.6f} {a:12.6f} {e:12.2e}") + print("-" * len(header)) + + return ok, diffs + + def check_decomposition_numerical_symbolic_old(self, function : PolyFunction, tol=1e-10, verbose=True): """ Cross-check numerical vs analytic decomposition. @@ -702,11 +842,11 @@ def check_decomposition_numerical_symbolic(self, f_eval, f_deg: Counter, from timeit import default_timer as timer start_num = timer() - coeffs_num = self.decompose(f_eval, f_deg) + coeffs_num = self.decompose(function, sparse = True) elapsed_num = timer() - start_num start_sym = timer() - coeffs_sym = self.decompose_analytic(f_eval, f_deg) + coeffs_sym = self.decompose_analytic(function) elapsed_sym = timer() - start_sym diffs, ok = {}, True @@ -737,7 +877,7 @@ def check_decomposition_numerical_symbolic(self, f_eval, f_deg: Counter, # Sparse vs full Assembly (selectively employ dot products) #-----------------------------------------------------------------# - def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, + def check_decomposition_numerical_sparse_full(self, function: PolyFunction, tol=1e-12, verbose=True): """ Cross-check sparse vs full assembly of rank 1 decomposition @@ -746,11 +886,11 @@ def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, from timeit import default_timer as timer start_sparse = timer() - coeffs_sparse = self.decompose_vector_sparse(f_eval, f_deg) + coeffs_sparse = self.decompose(function, sparse = True) elapsed_sparse = timer() - start_sparse start_full = timer() - coeffs_full = self.decompose(f_eval, f_deg) + coeffs_full = self.decompose(function, sparse = False) elapsed_full = timer() - start_full diffs, ok = {}, True @@ -776,28 +916,28 @@ def check_decomposition_numerical_sparse_full(self, f_eval, f_deg: Counter, return ok, diffs - def check_decomposition_matrix_sparse_full(self, f_eval, tol=1e-12, verbose=True): + def check_decomposition_matrix_sparse_full(self, function, tol=1e-12, verbose=True): """ Cross-check sparse vs full assembly of rank-2 (matrix) decomposition coefficients. """ from timeit import default_timer as timer - #---------------------------------------------------------------# + #-------------------------------------------------------------# # Assemble sparse + full - #---------------------------------------------------------------# + #-------------------------------------------------------------# - start_sparse = timer() - A_sparse = self.decompose_matrix(f_eval, sparse=True, symmetric=True) + start_sparse = timer() + A_sparse = self.decompose_matrix(function, sparse=True, symmetric=True) elapsed_sparse = timer() - start_sparse - start_full = timer() - A_full = self.decompose_matrix(f_eval, sparse=False, symmetric=True) + start_full = timer() + A_full = self.decompose_matrix(function, sparse=False, symmetric=True) elapsed_full = timer() - start_full - #---------------------------------------------------------------# + #-------------------------------------------------------------# # Compute differences - #---------------------------------------------------------------# + #-------------------------------------------------------------# diffs, ok = {}, True nbasis = self.getNumBasisFunctions() @@ -830,32 +970,3 @@ def check_decomposition_matrix_sparse_full(self, f_eval, tol=1e-12, verbose=True print("-" * len(header)) return ok, diffs - - -class PolyFunction: - def __init__(self, terms): - """ - terms : list of (coeff, Counter) pairs - Example: - [ - (3, Counter({})), # constant - (3, Counter({0:1})), # 3*y0 - (3, Counter({0:2, 1:1})) # 3*y0^2 * y1 - ] - """ - self.terms = terms - - def __call__(self, y): - """Evaluate polynomial at dict y={cid: value}""" - total = 0.0 - for coeff, degs in self.terms: - mon = coeff - for cid, d in degs.items(): - mon *= y[cid]**d - total += mon - return total - - @property - def degrees(self): - """Return list of Counters (ignores coeffs) for sparsity mask""" - return [degs for _, degs in self.terms] diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index ee4d4ea..75ca161 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -102,6 +102,14 @@ def generate_basis_total_degree(max_degree_params): return basis +def sum_degrees_union_vector(f_degrees, psi_i): + """Union over all monomials in f with ψ_i: max degree per axis.""" + degs = Counter() + for f_deg in f_degrees: + for a in set(f_deg) | set(psi_i): + degs[a] = max(degs.get(a, 0), + f_deg.get(a, 0) + psi_i.get(a, 0)) + return degs def sum_degrees_union(f_degrees, psi_i, psi_j): """Union over all monomials in f: max degree per axis.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index c93da18..26e6478 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,11 +11,10 @@ from collections import Counter # core module imports -from pspace.core import ( - CoordinateFactory, - CoordinateSystem, - BasisFunctionType -) +from pspace.core import (CoordinateFactory, + CoordinateSystem, + BasisFunctionType, + PolyFunction) # local module imports @@ -70,34 +69,40 @@ def random_polynomial(cs, max_deg=2, max_terms=3): #---------------------------------------------------------------# # Individual terms #---------------------------------------------------------------# + for cid in coords: - deg = random.randint(0, max_deg) - coeff = random.randint(1, 3) - terms.append(coeff * symbols[cid]**deg) - fdeg[cid] = max(fdeg.get(cid, 0), deg) + deg = random.randint(0, max_deg) + coeff = random.randint(1, 3) + terms.append((coeff, Counter({cid: deg}))) + fdeg[cid] = max(fdeg.get(cid, 0), deg) #---------------------------------------------------------------# # Cross terms #---------------------------------------------------------------# + if len(coords) >= 2: for _ in range(random.randint(0, max_terms)): - cids = random.sample(coords, k=random.randint(2, len(coords))) - coeff = random.randint(1, 3) - term = coeff + cids = random.sample(coords, k=random.randint(2, len(coords))) + coeff = random.randint(1, 3) + degs = Counter() for cid in cids: - d = random.randint(1, max_deg) - term *= symbols[cid]**d + d = random.randint(1, max_deg) + degs[cid] = d fdeg[cid] = max(fdeg.get(cid, 0), d) - terms.append(term) + terms.append((coeff, degs)) + + #---------------------------------------------------------------# + # interface for polynomial functions + #---------------------------------------------------------------# - fexpr = sum(terms) + polyf = PolyFunction(terms) - # numeric callable : Y is dict(cid -> float) - fnum = sp.lambdify([list(symbols.values())], fexpr, "numpy") - dfunc = lambda Y: fnum([Y[cid] for cid in coords]) + # build sympy expression (for debug/logging) + fexpr = polyf(symbols) print(f"[Polynomial] f(y) = {fexpr}, degrees={dict(fdeg)}") - return fexpr, dfunc, fdeg + + return polyf #=====================================================================# # Common coordinate system setup for given basis type, degrees of diff --git a/tests/test_vector_decomposition.py b/tests/test_vector_decomposition.py index 905a3cd..b373bc8 100644 --- a/tests/test_vector_decomposition.py +++ b/tests/test_vector_decomposition.py @@ -1,6 +1,12 @@ #=====================================================================# -# Randomized Decomposition Tests -# +# Randomized vector decomposition tests in randomly chosen +# N-dimensional coordinate system +#---------------------------------------------------------------------# +# Combinations: +#---------------------------------------------------------------------# +# - numerical vs symbolic +# - sparse vs full decomposition +#---------------------------------------------------------------------# # Author : Komahan Boopathy (komahan@gatech.edu) #=====================================================================# @@ -9,14 +15,15 @@ import random # core module imports -from pspace.core import ( - CoordinateFactory, - CoordinateSystem, - BasisFunctionType -) +from pspace.core import (CoordinateFactory, + CoordinateSystem, + BasisFunctionType, + PolyFunction) # local module imports -from .test_utils import (random_coordinate, random_polynomial, get_coordinate_system_type) +from .test_utils import (random_coordinate, + random_polynomial, + get_coordinate_system_type) #=====================================================================# # Symbolic and numerical vector decomposition tests @@ -30,9 +37,9 @@ def test_randomized_tensor_numerical_symbolic(trial): cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE) - fexpr, dfunc, fdeg = random_polynomial(cs) + polynomial_function = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, tol=1e-6, verbose=True) assert ok @@ -45,14 +52,13 @@ def test_randomized_total_numerical_symbolic(trial): cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE) - fexpr, dfunc, fdeg = random_polynomial(cs) + polynomial_function = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_symbolic(dfunc, fdeg, + ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, tol=1e-6, verbose=True) assert ok - #=====================================================================# # Sparsity-aware and sparsity-unware vector decomposition tests #=====================================================================# @@ -66,9 +72,9 @@ def test_randomized_tensor_basis_sparse_full(trial): cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, max_deg = 3, max_coords = 3) - fexpr, dfunc, fdeg = random_polynomial(cs) + polynomial_function = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, tol=1e-6, verbose=True) assert ok @@ -86,9 +92,9 @@ def test_randomized_total_basis_sparse_full(trial): cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, max_deg = 3, max_coords = 3) - fexpr, dfunc, fdeg = random_polynomial(cs) + polynomial_function = random_polynomial(cs) - ok, diffs = cs.check_decomposition_numerical_sparse_full(dfunc, fdeg, + ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, tol=1e-6, verbose=True) assert ok From bf51fe020eacc639601860932b770572ad631aae Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 13:34:15 -0400 Subject: [PATCH 39/54] symbolic decomposition is not sparsity aware --- pspace/core.py | 28 ++++++++++++++++++++++++---- tests/test_vector_decomposition.py | 2 ++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 4117518..31501ed 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -584,17 +584,30 @@ def decompose(self, function: PolyFunction, sparse: bool = False): return coeffs - def decompose_analytic(self, function: PolyFunction): + def decompose_analytic(self, function: PolyFunction, sparse: bool = False): """ Analytic decomposition using SymPy: c_k = ∫ f(y) ψ_k(y) ρ(y) dy + + Parameters + ---------- + function : PolyFunction + sparse : bool + If True, restrict to admissible basis functions. """ coords = self.coordinates symbols = {cid: coord.symbol for cid, coord in coords.items()} f_expr = function(symbols) + # Build mask + if sparse: + mask = self.polynomial_vector_sparsity_mask(function.degrees) + else: + mask = self.basis.keys() + coeffs = {} - for k, psi_k in self.basis.items(): + for k in mask: + psi_k = self.basis[k] psi_expr = 1 for cid, deg in psi_k.items(): z = coords[cid].physical_to_standard(coords[cid].symbol) @@ -611,6 +624,12 @@ def decompose_analytic(self, function: PolyFunction): coeffs[k] = sp.simplify(val) + # Optional: fill in zeroes for basis not in mask + if sparse: + for k in self.basis: + if k not in coeffs: + coeffs[k] = 0 + return coeffs def admissible_pair(self, deg_i: Counter, deg_j: Counter, f_deg: Counter) -> bool: @@ -770,6 +789,7 @@ def check_orthonormality(self): def check_decomposition_numerical_symbolic(self, function: PolyFunction, + sparse: bool = True, tol=1e-10, verbose=True): """ @@ -786,7 +806,7 @@ def check_decomposition_numerical_symbolic(self, #-------------------------------------------------------------# start_num = timer() - coeffs_num = self.decompose(function, sparse=True) + coeffs_num = self.decompose(function, sparse=sparse) elapsed_num = timer() - start_num #-------------------------------------------------------------# @@ -794,7 +814,7 @@ def check_decomposition_numerical_symbolic(self, #-------------------------------------------------------------# start_sym = timer() - coeffs_sym = self.decompose_analytic(function) + coeffs_sym = self.decompose_analytic(function, sparse=sparse) elapsed_sym = timer() - start_sym #-------------------------------------------------------------# diff --git a/tests/test_vector_decomposition.py b/tests/test_vector_decomposition.py index b373bc8..8189f40 100644 --- a/tests/test_vector_decomposition.py +++ b/tests/test_vector_decomposition.py @@ -40,6 +40,7 @@ def test_randomized_tensor_numerical_symbolic(trial): polynomial_function = random_polynomial(cs) ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, + sparse = True, tol=1e-6, verbose=True) assert ok @@ -55,6 +56,7 @@ def test_randomized_total_numerical_symbolic(trial): polynomial_function = random_polynomial(cs) ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, + sparse = True, tol=1e-6, verbose=True) assert ok From ac1187b05378702454e2e4a3dfcc1aa48a9b6c1d Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 13:39:15 -0400 Subject: [PATCH 40/54] delete duplicate decompose --- pspace/core.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 31501ed..c014089 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -522,27 +522,6 @@ def inner_product_basis(self, # Decomposition #-----------------------------------------------------------------# - def decompose(self, function: PolyFunction): - """ - Coefficients c_k = in Y-frame. - """ - coeffs = {} - for k, psi_k in self.basis.items(): - need = sum_degrees(function.degrees, psi_k) - qmap = self.build_quadrature(need) - - s = 0.0 - for q in qmap.values(): - y = q['Y'] - s += function(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] - coeffs[k] = s - - return coeffs - - #-----------------------------------------------------------------# - # Decomposition - #-----------------------------------------------------------------# - def decompose(self, function: PolyFunction, sparse: bool = False): """ Coefficients c_k = in Y-frame. From c826577cd5c3f8c0a1a34834589fed088056d8d8 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 16:15:07 -0400 Subject: [PATCH 41/54] remove unwanted functions --- pspace/core.py | 148 +++++++++++++++---------------------------------- 1 file changed, 45 insertions(+), 103 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index c014089..3414086 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -522,63 +522,32 @@ def inner_product_basis(self, # Decomposition #-----------------------------------------------------------------# - def decompose(self, function: PolyFunction, sparse: bool = False): + def decompose(self, + function : PolyFunction, + sparse : bool = True, + analytic : bool = False): """ Coefficients c_k = in Y-frame. Parameters ---------- function : PolyFunction - Callable polynomial with .degrees list of Counters. - sparse : bool - If True, restrict to admissible basis functions. + Polynomial function to decompose. + sparse : bool + If True, restrict to admissible basis indices. + analytic : bool + If True, compute coefficients with Sympy integrals instead of quadrature. Returns ------- - coeffs : dict - Map basis_id -> coefficient value. - """ - coeffs = {} - - # Build admissible mask - if sparse: - mask = self.polynomial_vector_sparsity_mask(function.degrees) - else: - mask = set(self.basis.keys()) - - for k, psi_k in self.basis.items(): - if k not in mask: - coeffs[k] = 0.0 - continue - - # union across monomials for quadrature requirement - need = sum_degrees_union_vector(function.degrees, psi_k) - qmap = self.build_quadrature(need) - - s = 0.0 - for q in qmap.values(): - y = q['Y'] - s += function(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] - coeffs[k] = s - - return coeffs - - def decompose_analytic(self, function: PolyFunction, sparse: bool = False): - """ - Analytic decomposition using SymPy: - c_k = ∫ f(y) ψ_k(y) ρ(y) dy - - Parameters - ---------- - function : PolyFunction - sparse : bool - If True, restrict to admissible basis functions. + coeffs : dict {basis_id: coefficient} """ - coords = self.coordinates + coords = self.coordinates symbols = {cid: coord.symbol for cid, coord in coords.items()} - f_expr = function(symbols) - # Build mask + #---------------------------------------------------------------# + # Build admissible mask + #---------------------------------------------------------------# if sparse: mask = self.polynomial_vector_sparsity_mask(function.degrees) else: @@ -587,23 +556,39 @@ def decompose_analytic(self, function: PolyFunction, sparse: bool = False): coeffs = {} for k in mask: psi_k = self.basis[k] - psi_expr = 1 - for cid, deg in psi_k.items(): - z = coords[cid].physical_to_standard(coords[cid].symbol) - psi_expr *= coords[cid].psi_z(z, deg) - integrand = f_expr * psi_expr * sp.Mul(*[c.weight() - for c in coords.values()]) + if analytic: + #-------------------------------------------------------# + # Analytic Sympy integration + #-------------------------------------------------------# + psi_expr = 1 + for cid, deg in psi_k.items(): + z = coords[cid].physical_to_standard(coords[cid].symbol) + psi_expr *= coords[cid].psi_z(z, deg) + + integrand = function(symbols) * psi_expr * sp.Mul(*[c.weight() for c in coords.values()]) + val = integrand + for cid, coord in coords.items(): + y = coord.symbol + a, b = coord.domain() + val = sp.integrate(val, (y, a, b)) + + coeffs[k] = sp.simplify(val) - val = integrand - for cid, coord in coords.items(): - y = coord.symbol - a, b = coord.domain() - val = sp.integrate(val, (y, a, b)) + else: + #-------------------------------------------------------# + # Numerical quadrature + #-------------------------------------------------------# + need = sum_degrees_union_vector(function.degrees, psi_k) + qmap = self.build_quadrature(need) - coeffs[k] = sp.simplify(val) + s = 0.0 + for q in qmap.values(): + y = q['Y'] + s += function(y) * self.evaluateBasisDegreesY(y, psi_k) * q['W'] + coeffs[k] = s - # Optional: fill in zeroes for basis not in mask + # Optionally fill zeroes if sparse: for k in self.basis: if k not in coeffs: @@ -785,7 +770,7 @@ def check_decomposition_numerical_symbolic(self, #-------------------------------------------------------------# start_num = timer() - coeffs_num = self.decompose(function, sparse=sparse) + coeffs_num = self.decompose(function, sparse=sparse, analytic=False) elapsed_num = timer() - start_num #-------------------------------------------------------------# @@ -793,7 +778,7 @@ def check_decomposition_numerical_symbolic(self, #-------------------------------------------------------------# start_sym = timer() - coeffs_sym = self.decompose_analytic(function, sparse=sparse) + coeffs_sym = self.decompose(function, sparse=sparse, analytic=True) elapsed_sym = timer() - start_sym #-------------------------------------------------------------# @@ -829,49 +814,6 @@ def check_decomposition_numerical_symbolic(self, return ok, diffs - def check_decomposition_numerical_symbolic_old(self, function : PolyFunction, - tol=1e-10, verbose=True): - """ - Cross-check numerical vs analytic decomposition. - """ - - # ensure basis is orthonormal first - ortho_tol = self.check_orthonormality() - - from timeit import default_timer as timer - - start_num = timer() - coeffs_num = self.decompose(function, sparse = True) - elapsed_num = timer() - start_num - - start_sym = timer() - coeffs_sym = self.decompose_analytic(function) - elapsed_sym = timer() - start_sym - - diffs, ok = {}, True - for k in coeffs_num.keys(): - num_val = float(coeffs_num[k]) - try: - ana_val = float(coeffs_sym[k].evalf()) - except TypeError: - ana_val = float(sp.N(coeffs_sym[k], 15)) - err = abs(num_val - ana_val) - if err > tol: - ok = False - diffs[k] = (num_val, ana_val, err) - - if verbose: - print(f"[Consistency Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}, ortho tol = {ortho_tol}") - print(f"[Elapsed Time] numerical {elapsed_num} analytic = {elapsed_sym}") - header = f"{'Basis':<7} {'numerical':>12} {'analytic':>12} {'error':>12}" - print(header) - print("-" * len(header)) - for k, (n, a, e) in diffs.items(): - print(f"{k:<7d} {n:12.6f} {a:12.6f} {e:12.2e}") - print("-" * len(header)) - - return ok, diffs - #-----------------------------------------------------------------# # Sparse vs full Assembly (selectively employ dot products) #-----------------------------------------------------------------# From 61df2dccd72bba6e47c466e8c20af72a18e2516d Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 16:47:43 -0400 Subject: [PATCH 42/54] add pytest for matrix decomposition --- tests/test_matrix_decomposition.py | 102 +++++++++++++++++++---------- 1 file changed, 66 insertions(+), 36 deletions(-) diff --git a/tests/test_matrix_decomposition.py b/tests/test_matrix_decomposition.py index 2e1d3bd..e8b8009 100644 --- a/tests/test_matrix_decomposition.py +++ b/tests/test_matrix_decomposition.py @@ -1,36 +1,66 @@ -import numpy as np -from collections import Counter - -def test_decompose_matrix_basic(space): - """ - space : your stochastic space object, with: - - .basis {i: Counter} - - .getNumBasisFunctions() - - .evaluateBasisDegreesY() - - .build_quadrature() - - .decompose_matrix() - """ - - # Polynomial: f(y) = 3 + 3*y0 + 3*y0^2*y1 - from yourmodule import PolyFunction # wherever you put the class - - f_eval = PolyFunction([ - (3, Counter({})), # constant - (3, Counter({0:1})), # linear - (3, Counter({0:2, 1:1})) # mixed quadratic - ]) - - # Dense (full) assembly - A_full = space.decompose_matrix(f_eval, sparse=False, symmetric=True) - - # Sparse (polynomial_sparsity_mask) assembly - A_sparse = space.decompose_matrix(f_eval, sparse=True, symmetric=True) - - # Compare - diff = np.max(np.abs(A_full - A_sparse)) - print("max diff =", diff) - print("full matrix:\n", A_full) - print("sparse matrix:\n", A_sparse) - - # Assert equality within tolerance - assert np.allclose(A_full, A_sparse, atol=1e-10) +#=====================================================================# +# Randomized matrix decomposition tests in randomly chosen +# N-dimensional coordinate system +#---------------------------------------------------------------------# +# Combinations: +#---------------------------------------------------------------------# +# - numerical vs symbolic (to be implemented) +# - sparse vs full decomposition +#---------------------------------------------------------------------# +# Author : Komahan Boopathy (komahan@gatech.edu) +#=====================================================================# + +# python module imports +import pytest +import random + +# core module imports +from pspace.core import (CoordinateFactory, + CoordinateSystem, + BasisFunctionType, + PolyFunction) + +# local module imports +from .test_utils import (random_coordinate, + random_polynomial, + get_coordinate_system_type) + +#=====================================================================# +# Sparsity-aware and sparsity-unware matrix decomposition tests +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_tensor_basis_sparse_full(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Matrix TENSOR Basis (sparse vs full assembly) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg = 3, max_coords = 3) + + polynomial_function = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, + tol=1e-6, + verbose=True) + assert ok + +#=====================================================================# +# Tests 2 B: Total Degree Basis (sparse vs full assembly) +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_total_basis_sparse_full(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Matrix TOTAL Basis (sparse vs full assembly) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg = 3, max_coords = 3) + + polynomial_function = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, + tol=1e-6, + verbose=True) + assert ok From 24f4cad122a8a8907a247193c6fe836f4a32cf04 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 17:49:24 -0400 Subject: [PATCH 43/54] - polyfunction stores monomial degrees and polynomial degrees - polynomial degrees drive optimal quadrature and monomial degrees drive sparsity detection - added matrix decomposition sparse and full assembly test --- pspace/core.py | 49 ++++++++++++--------- pspace/stochastic_utils.py | 89 +++++++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 25 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 3414086..a99cd3c 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -28,7 +28,7 @@ generate_basis_total_degree, sum_degrees, safe_zero_degrees, - sum_degrees_union, + sum_degrees_union_matrix, sum_degrees_union_vector) from .orthogonal_polynomials import unit_hermite @@ -71,43 +71,52 @@ class PolyFunction: def __init__(self, terms): """ terms : list of (coeff, Counter) pairs - Example: + Example: [ - (3, Counter({})), # constant + (3, Counter()), # constant (3, Counter({0:1})), # 3*y0 (3, Counter({0:2, 1:1})) # 3*y0^2 * y1 ] - - terms: list of (coeff: float, degs: Counter) """ - # self.terms = terms + self._terms = [] + self._degrees = [] # list of Counters + self._max_degrees = Counter() - # enforce format check before acceptance - self.terms = [] for t in terms: if isinstance(t, tuple) and isinstance(t[1], Counter): coeff, degs = t - self.terms.append((coeff, degs)) + self._terms.append((coeff, degs)) + self._degrees.append(degs) + for k, v in degs.items(): + self._max_degrees[k] = max(self._max_degrees.get(k, 0), v) else: raise TypeError(f"Invalid term format: {t!r}") + @property + def terms(self): + return self._terms + @property def degrees(self): - """Return list of Counters, one per monomial.""" - return [degs for _, degs in self.terms] + """List[Counter]: degree structure per monomial""" + return self._degrees + + @property + def max_degrees(self): + """Counter: max degree per axis (union of monomials)""" + return self._max_degrees def __call__(self, Y): - """Evaluate polynomial at dict y={cid: value}""" total = 0.0 - for coeff, degs in self.terms: + for coeff, degs in self._terms: mon = coeff for cid, d in degs.items(): - mon *= Y[cid]**d + mon *= Y[cid] ** d total += mon return total def __repr__(self): - return f"PolyFunction({self.terms})" + return f"PolyFunction({self._terms})" #=====================================================================# # Coordinate Base Class @@ -579,7 +588,7 @@ def decompose(self, #-------------------------------------------------------# # Numerical quadrature #-------------------------------------------------------# - need = sum_degrees_union_vector(function.degrees, psi_k) + need = sum_degrees_union_vector(function.max_degrees, psi_k) qmap = self.build_quadrature(need) s = 0.0 @@ -628,14 +637,14 @@ def admissible_pair(self, deg_i: Counter, deg_j: Counter, f_deg: Counter) -> boo If f_deg is empty (constant monomial), then all (i,j) pairs are admissible. """ - # Constant monomial ⇒ don't filter anything + # Constant monomial -> don't filter anything if not f_deg: - return True + return deg_i == deg_j axes = set(deg_i) | set(deg_j) | set(f_deg) for d in axes: di, dj, df = deg_i.get(d, 0), deg_j.get(d, 0), f_deg.get(d, 0) - if not (abs(di - dj) <= df <= di + dj): + if not (abs(di - dj) <= df): return False return True @@ -715,7 +724,7 @@ def decompose_matrix(self, function, sparse=False, symmetric=True): qcache = {} for i, j in mask: psi_i, psi_j = self.basis[i], self.basis[j] - need = sum_degrees_union(function.degrees, psi_i, psi_j) + need = sum_degrees_union_matrix(function.max_degrees, psi_i, psi_j) key = tuple(sorted(need.items())) qmap = qcache.get(key) diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 75ca161..86ae29d 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -111,15 +111,94 @@ def sum_degrees_union_vector(f_degrees, psi_i): f_deg.get(a, 0) + psi_i.get(a, 0)) return degs -def sum_degrees_union(f_degrees, psi_i, psi_j): - """Union over all monomials in f: max degree per axis.""" +def sum_degrees_union_vector(f_degrees, psi_k: Counter) -> Counter: + """ + Compute required quadrature degree for product f(y)*ψ_k(y). + + Parameters + ---------- + f_degrees : Counter or list[Counter] + Degree structures of f. Each Counter is {axis: degree}. + Can be a single Counter (max degrees) or list of monomial degrees. + psi_k : Counter + Degree structure of basis function ψ_k. + + Returns + ------- + Counter + Max degree needed per axis to exactly integrate f * ψ_k. + + Notes + ----- + - For each axis d, the required degree is: + deg(d) = max over monomials [ f_deg(d) + psi_k(d) ] + - Ensures enough quadrature order for vector decomposition. + + Example + ------- + >>> f_degrees = [Counter({0:2, 1:1})] # y0^2 * y1 + >>> psi_k = Counter({1:2}) # y1^2 + >>> sum_degrees_union_vector(f_degrees, psi_k) + Counter({0: 2, 1: 3}) # product ~ y0^2 * y1^3 + """ degs = Counter() + + # Normalize input + if isinstance(f_degrees, Counter): + f_degrees = [f_degrees] + for f_deg in f_degrees: - for a in set(f_deg) | set(psi_i) | set(psi_j): - degs[a] = max(degs.get(a,0), - f_deg.get(a,0) + psi_i.get(a,0) + psi_j.get(a,0)) + axes = set(f_deg) | set(psi_k) + for a in axes: + total = f_deg.get(a, 0) + psi_k.get(a, 0) + degs[a] = max(degs.get(a, 0), total) + return degs +def sum_degrees_union_matrix(f_degrees, psi_i: Counter, psi_j: Counter) -> Counter: + """ + Compute required quadrature degree for product f(y)*ψ_i(y)*ψ_j(y). + + Parameters + ---------- + f_degrees : Counter or list[Counter] + Degree structures of f. Each Counter is {axis: degree}. + Can be a single Counter (max degrees) or list of monomial degrees. + psi_i, psi_j : Counter + Degree structures of basis functions ψ_i and ψ_j. + + Returns + ------- + Counter + Max degree needed per axis to exactly integrate f * ψ_i * ψ_j. + + Notes + ----- + - For each axis d, the required degree is: + deg(d) = max over monomials [ f_deg(d) + psi_i(d) + psi_j(d) ] + - Ensures enough quadrature order for multivariate products. + + Example + ------- + >>> f_degrees = [Counter({0:2, 1:1})] # y0^2 * y1 + >>> psi_i = Counter({0:1}) # y0 + >>> psi_j = Counter({1:2}) # y1^2 + >>> sum_degrees_union(f_degrees, psi_i, psi_j) + Counter({0: 3, 1: 3}) # product ~ y0^3 * y1^3 + """ + degs = Counter() + + # Normalize input + if isinstance(f_degrees, Counter): + f_degrees = [f_degrees] + + for f_deg in f_degrees: + axes = set(f_deg) | set(psi_i) | set(psi_j) + for a in axes: + total = f_deg.get(a, 0) + psi_i.get(a, 0) + psi_j.get(a, 0) + degs[a] = max(degs.get(a, 0), total) + + return degs if __name__ == '__main__': From cb318ca77cc3d029c84cf56b1889320dbb356ec6 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 18:13:02 -0400 Subject: [PATCH 44/54] add stress test for vector decomposition --- tests/stress_test_vector_decomposition.py | 109 ++++++++++++++++++++++ tests/test_utils.py | 6 ++ 2 files changed, 115 insertions(+) create mode 100644 tests/stress_test_vector_decomposition.py diff --git a/tests/stress_test_vector_decomposition.py b/tests/stress_test_vector_decomposition.py new file mode 100644 index 0000000..98d6f95 --- /dev/null +++ b/tests/stress_test_vector_decomposition.py @@ -0,0 +1,109 @@ +#=====================================================================# +# Stress tests: vector decomposition in high-dim / high-degree setups +#---------------------------------------------------------------------# +# Goals: +# - probe quadrature sufficiency (.max_degrees logic) +# - probe sparsity detection (.degrees masks) +# - catch conditioning errors in Normal/Exponential distributions +#---------------------------------------------------------------------# +# Author : Komahan Boopathy (komahan@gatech.edu) +#=====================================================================# + +import pytest +import random + +from pspace.core import (CoordinateFactory, + CoordinateSystem, + BasisFunctionType, + PolyFunction) + +from .test_utils import (random_coordinate, + random_polynomial, + get_coordinate_system_type) + +from .test_utils import ENABLE_ANALYTIC_TESTS + +#=====================================================================# +# Parameters for stress regime +#=====================================================================# + +MAX_DEG = 8 # push polynomial/basis degree +MAX_COORD = 5 # up to 5D coordinates +TRIALS = 3 # fewer trials (stress is heavier) +TOL = 1e-6 + +#=====================================================================# +# Sparse vs full stress tests +#=====================================================================# + +@pytest.mark.parametrize("trial", range(TRIALS)) +def test_stress_tensor_sparse_full(trial): + random.seed(3000 + trial) + print(f"\n=== STRESS Trial {trial} : Tensor Basis (sparse vs full) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg=MAX_DEG, + max_coords=MAX_COORD) + + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + + ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, + tol=TOL, + verbose=False) + assert ok + + +@pytest.mark.parametrize("trial", range(TRIALS)) +def test_stress_total_sparse_full(trial): + random.seed(4000 + trial) + print(f"\n=== STRESS Trial {trial} : Total Basis (sparse vs full) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg=MAX_DEG, + max_coords=MAX_COORD) + + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + + ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, + tol=TOL, + verbose=False) + assert ok + +#=====================================================================# +# Numerical vs symbolic stress tests +#=====================================================================# +@pytest.mark.skipif(not ENABLE_ANALYTIC_TESTS, reason="Analytic vs numerical disabled") +@pytest.mark.parametrize("trial", range(TRIALS)) +def test_stress_tensor_numerical_symbolic(trial): + random.seed(1000 + trial) # offset to avoid overlap with regular tests + print(f"\n=== STRESS Trial {trial} : Tensor Basis (deg≤{MAX_DEG}, dim≤{MAX_COORD}) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg=MAX_DEG, + max_coords=MAX_COORD) + + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + + ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, + sparse=True, + tol=TOL, + verbose=False) + assert ok + +@pytest.mark.skipif(not ENABLE_ANALYTIC_TESTS, reason="Analytic vs numerical disabled") +@pytest.mark.parametrize("trial", range(TRIALS)) +def test_stress_total_numerical_symbolic(trial): + random.seed(2000 + trial) + print(f"\n=== STRESS Trial {trial} : Total Basis (deg≤{MAX_DEG}, dim≤{MAX_COORD}) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg=MAX_DEG, + max_coords=MAX_COORD) + + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + + ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, + sparse=True, + tol=TOL, + verbose=False) + assert ok diff --git a/tests/test_utils.py b/tests/test_utils.py index 26e6478..6e401af 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -18,6 +18,12 @@ # local module imports +#=====================================================================# +# Global test flags +#=====================================================================# + +ENABLE_ANALYTIC_TESTS = False + #=====================================================================# # Helper : randomized coordinate factory with logging #=====================================================================# From 93d7166ac682d2b038b76a9608beb220ea6500db Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 18:29:42 -0400 Subject: [PATCH 45/54] add matrix analytical vs numerical decomposition tests --- pspace/core.py | 144 +++++++++++++++++++++++++++++ tests/test_matrix_decomposition.py | 45 ++++++++- 2 files changed, 184 insertions(+), 5 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index a99cd3c..fa96cf7 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -745,6 +745,80 @@ def decompose_matrix(self, function, sparse=False, symmetric=True): return A + def decompose_matrix_analytic(self, function, sparse=False, symmetric=True): + """ + Assemble A_ij = ∫ psi_i(y) psi_j(y) f(y) w(y) dy (dense), + using analytic (Sympy) integration. + + Parameters + ---------- + function : PolyFunction + Polynomial function to decompose (with .degrees and .max_degrees). + sparse : bool + If True, restrict to admissible pairs. + symmetric : bool + If True, compute only i ≤ j and mirror. + + Returns + ------- + A : np.ndarray + Dense (nbasis x nbasis) coefficient matrix. + """ + import sympy as sp + + nbasis = self.getNumBasisFunctions() + A = np.zeros((nbasis, nbasis)) + + coords = self.coordinates + symbols = {cid: coord.symbol for cid, coord in coords.items()} + + # Build admissible mask + if sparse: + mask = self.polynomial_sparsity_mask(function.degrees, symmetric=symmetric) + else: + if symmetric: + mask = {(i, j) for i in self.basis for j in self.basis if i <= j} + else: + mask = {(i, j) for i in self.basis for j in self.basis} + + for i, j in mask: + psi_i, psi_j = self.basis[i], self.basis[j] + + # Build integrand symbolically + psi_expr_i, psi_expr_j = 1, 1 + for cid, deg in psi_i.items(): + z = coords[cid].physical_to_standard(coords[cid].symbol) + psi_expr_i *= coords[cid].psi_z(z, deg) + for cid, deg in psi_j.items(): + z = coords[cid].physical_to_standard(coords[cid].symbol) + psi_expr_j *= coords[cid].psi_z(z, deg) + + # Function f(y) expanded + f_expr = 0 + for coeff, degs in function.terms: + mon = coeff + for cid, d in degs.items(): + mon *= symbols[cid] ** d + f_expr += mon + + w_expr = sp.Mul(*[c.weight() for c in coords.values()]) + + # Full integrand: f * ψ_i * ψ_j * weight + integrand = f_expr * psi_expr_i * psi_expr_j * w_expr + + val = integrand + for cid, coord in coords.items(): + y = coord.symbol + a, b = coord.domain() + val = sp.integrate(val, (y, a, b)) + + # Simplify and cast to float + A[i, j] = float(sp.simplify(val)) + if symmetric and i != j: + A[j, i] = A[i, j] + + return A + #-----------------------------------------------------------------# # Consistency checks # TestCoordinateSystem and supply the instance of cs @@ -920,3 +994,73 @@ def check_decomposition_matrix_sparse_full(self, function, tol=1e-12, verbose=Tr print("-" * len(header)) return ok, diffs + + def check_decomposition_matrix_numerical_symbolic(self, function, tol=1e-12, verbose=True): + """ + Cross-check numerical vs analytic assembly of rank-2 (matrix) + decomposition coefficients. + + Parameters + ---------- + function : PolyFunction + Polynomial function to decompose. + tol : float + Absolute tolerance for consistency check. + verbose : bool + Print detailed report if True. + + Returns + ------- + ok : bool + True if all entries match within tolerance. + diffs : dict + Mapping (i,j) -> (numerical, analytic, error). + """ + from timeit import default_timer as timer + + #-------------------------------------------------------------# + # Assemble numerical + analytic + #-------------------------------------------------------------# + + start_num = timer() + A_num = self.decompose_matrix(function, sparse=True, symmetric=True) + elapsed_num = timer() - start_num + + start_an = timer() + A_an = self.decompose_matrix_analytic(function, sparse=True, symmetric=True) + elapsed_an = timer() - start_an + + #-------------------------------------------------------------# + # Compute differences + #-------------------------------------------------------------# + + diffs, ok = {}, True + nbasis = self.getNumBasisFunctions() + for i in range(nbasis): + for j in range(nbasis): + vnum = A_num[i, j] + van = A_an[i, j] + err = abs(vnum - van) + if err > tol: + ok = False + diffs[(i, j)] = (vnum, van, err) + + #-------------------------------------------------------------# + # Report + #-------------------------------------------------------------# + + if verbose: + print(f"[Matrix Numerical vs Analytic Check] {'PASSED' if ok else 'FAILED'} " + f"with tol = {tol}") + print(f"[Elapsed Time] numerical {elapsed_num:.4e} " + f"analytic {elapsed_an:.4e} " + f"Ratio {elapsed_an/elapsed_num:.2f}") + header = f"{'i':<3} {'j':<3} {'Numerical':>12} {'Analytic':>12} {'Error':>12}" + print(header) + print("-" * len(header)) + for (i, j), (vn, va, e) in diffs.items(): + if abs(e) > tol: # only print significant diffs + print(f"{i:<3d} {j:<3d} {vn:12.6f} {va:12.6f} {e:12.2e}") + print("-" * len(header)) + + return ok, diffs diff --git a/tests/test_matrix_decomposition.py b/tests/test_matrix_decomposition.py index e8b8009..afc245f 100644 --- a/tests/test_matrix_decomposition.py +++ b/tests/test_matrix_decomposition.py @@ -4,7 +4,7 @@ #---------------------------------------------------------------------# # Combinations: #---------------------------------------------------------------------# -# - numerical vs symbolic (to be implemented) +# - numerical vs symbolic # - sparse vs full decomposition #---------------------------------------------------------------------# # Author : Komahan Boopathy (komahan@gatech.edu) @@ -45,10 +45,6 @@ def test_randomized_tensor_basis_sparse_full(trial): verbose=True) assert ok -#=====================================================================# -# Tests 2 B: Total Degree Basis (sparse vs full assembly) -#=====================================================================# - @pytest.mark.parametrize("trial", range(5)) def test_randomized_total_basis_sparse_full(trial): random.seed(trial) @@ -64,3 +60,42 @@ def test_randomized_total_basis_sparse_full(trial): tol=1e-6, verbose=True) assert ok + +#=====================================================================# +# Sparse numerical versus sparse analytical matrix decomposition tests +#=====================================================================# + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_tensor_matrix_numerical_symbolic(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Matrix TENSOR Basis (numerical vs symbolic) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg=3, max_coords=3) + + polynomial_function = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_matrix_numerical_symbolic( + polynomial_function, + tol=1e-6, + verbose=True) + assert ok + + +@pytest.mark.parametrize("trial", range(5)) +def test_randomized_total_matrix_numerical_symbolic(trial): + random.seed(trial) + + print(f"\n=== Trial {trial} : Matrix TOTAL Basis (numerical vs symbolic) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg=3, max_coords=3) + + polynomial_function = random_polynomial(cs) + + ok, diffs = cs.check_decomposition_matrix_numerical_symbolic( + polynomial_function, + tol=1e-6, + verbose=True) + assert ok From a7852c7ddc0cfc49dfe5a0eacbf41616cfe050ff Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 18:43:57 -0400 Subject: [PATCH 46/54] add matrix stress test --- tests/stress_test_matrix_decomposition.py | 116 ++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/stress_test_matrix_decomposition.py diff --git a/tests/stress_test_matrix_decomposition.py b/tests/stress_test_matrix_decomposition.py new file mode 100644 index 0000000..e0ad7c8 --- /dev/null +++ b/tests/stress_test_matrix_decomposition.py @@ -0,0 +1,116 @@ +#=====================================================================# +# Stress tests for randomized matrix decomposition in randomly chosen +# N-dimensional coordinate systems +#---------------------------------------------------------------------# +# Focus: +# - Sparse vs Full assembly of matrix decomposition +# - Timing summaries across multiple trials +#---------------------------------------------------------------------# +# Author : Komahan Boopathy (komahan@gatech.edu) +#=====================================================================# + +import pytest +import random +import numpy as np + +# core imports +from pspace.core import (CoordinateFactory, + CoordinateSystem, + BasisFunctionType, + PolyFunction) + +# local imports +from .test_utils import (random_coordinate, + random_polynomial, + get_coordinate_system_type) + + +#=====================================================================# +# Helper: store timing ratios for summary +#=====================================================================# +tensor_timings = [] +total_timings = [] + + +#=====================================================================# +# Stress Test A: Tensor Degree Basis +#=====================================================================# + +@pytest.mark.parametrize("trial", range(20)) +def test_stress_tensor_matrix_sparse_full(trial): + random.seed(trial) + + print(f"\n=== Stress Trial {trial} : Matrix TENSOR Basis (sparse vs full) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, + max_deg=4, + max_coords=4) + + polynomial_function = random_polynomial(cs, max_deg=3, max_terms=5) + + ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, + tol=1e-6, + verbose=True) + assert ok + + # record timing ratio if available + from timeit import default_timer as timer + start_sparse = timer(); cs.decompose_matrix(polynomial_function, sparse=True); elapsed_sparse = timer() - start_sparse + start_full = timer(); cs.decompose_matrix(polynomial_function, sparse=False); elapsed_full = timer() - start_full + tensor_timings.append(elapsed_full / elapsed_sparse) + + +@pytest.mark.parametrize("finalize", [True]) +def test_tensor_timing_summary(finalize): + if not tensor_timings: + pytest.skip("No tensor timings recorded") + + ratios = np.array(tensor_timings) + print("\n=== Timing Summary: Tensor Basis Matrix Decomposition ===") + print(f"Trials : {len(ratios)}") + print(f"Mean ratio : {ratios.mean():.3f}") + print(f"Std. dev : {ratios.std():.3f}") + print(f"Min ratio : {ratios.min():.3f}") + print(f"Max ratio : {ratios.max():.3f}") + + +#=====================================================================# +# Stress Test B: Total Degree Basis +#=====================================================================# + +@pytest.mark.parametrize("trial", range(20)) +def test_stress_total_matrix_sparse_full(trial): + random.seed(trial) + + print(f"\n=== Stress Trial {trial} : Matrix TOTAL Basis (sparse vs full) ===") + + cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, + max_deg=4, + max_coords=4) + + polynomial_function = random_polynomial(cs, max_deg=3, max_terms=5) + + ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, + tol=1e-6, + verbose=True) + assert ok + + # record timing ratio if available + from timeit import default_timer as timer + start_sparse = timer(); cs.decompose_matrix(polynomial_function, sparse=True); elapsed_sparse = timer() - start_sparse + start_full = timer(); cs.decompose_matrix(polynomial_function, sparse=False); elapsed_full = timer() - start_full + total_timings.append(elapsed_full / elapsed_sparse) + + +@pytest.mark.parametrize("finalize", [True]) +def test_total_timing_summary(finalize): + if not total_timings: + pytest.skip("No total timings recorded") + + ratios = np.array(total_timings) + print("\n=== Timing Summary: Total Basis Matrix Decomposition ===") + print(f"Trials : {len(ratios)}") + print(f"Mean ratio : {ratios.mean():.3f}") + print(f"Std. dev : {ratios.std():.3f}") + print(f"Min ratio : {ratios.min():.3f}") + print(f"Max ratio : {ratios.max():.3f}") From 6aff69c6c185d97b55a9d3051305639b7c1e76bc Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 19:30:54 -0400 Subject: [PATCH 47/54] stress test for vector decompositin is timed --- tests/stress_test_vector_decomposition.py | 80 +++++++++++++---------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/tests/stress_test_vector_decomposition.py b/tests/stress_test_vector_decomposition.py index 98d6f95..8ef9e31 100644 --- a/tests/stress_test_vector_decomposition.py +++ b/tests/stress_test_vector_decomposition.py @@ -5,12 +5,15 @@ # - probe quadrature sufficiency (.max_degrees logic) # - probe sparsity detection (.degrees masks) # - catch conditioning errors in Normal/Exponential distributions +# - collect timing ratios for sparse vs full assembly #---------------------------------------------------------------------# # Author : Komahan Boopathy (komahan@gatech.edu) #=====================================================================# import pytest import random +import numpy as np +from timeit import default_timer as timer from pspace.core import (CoordinateFactory, CoordinateSystem, @@ -32,6 +35,10 @@ TRIALS = 3 # fewer trials (stress is heavier) TOL = 1e-6 +# Timing collectors +tensor_timings = [] +total_timings = [] + #=====================================================================# # Sparse vs full stress tests #=====================================================================# @@ -52,6 +59,11 @@ def test_stress_tensor_sparse_full(trial): verbose=False) assert ok + # Timing comparison + start_sparse = timer(); cs.decompose(polynomial_function, sparse=True); elapsed_sparse = timer() - start_sparse + start_full = timer(); cs.decompose(polynomial_function, sparse=False); elapsed_full = timer() - start_full + tensor_timings.append(elapsed_full / elapsed_sparse) + @pytest.mark.parametrize("trial", range(TRIALS)) def test_stress_total_sparse_full(trial): @@ -69,41 +81,39 @@ def test_stress_total_sparse_full(trial): verbose=False) assert ok -#=====================================================================# -# Numerical vs symbolic stress tests -#=====================================================================# -@pytest.mark.skipif(not ENABLE_ANALYTIC_TESTS, reason="Analytic vs numerical disabled") -@pytest.mark.parametrize("trial", range(TRIALS)) -def test_stress_tensor_numerical_symbolic(trial): - random.seed(1000 + trial) # offset to avoid overlap with regular tests - print(f"\n=== STRESS Trial {trial} : Tensor Basis (deg≤{MAX_DEG}, dim≤{MAX_COORD}) ===") - - cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, - max_deg=MAX_DEG, - max_coords=MAX_COORD) - - polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) - - ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, - sparse=True, - tol=TOL, - verbose=False) - assert ok - -@pytest.mark.skipif(not ENABLE_ANALYTIC_TESTS, reason="Analytic vs numerical disabled") -@pytest.mark.parametrize("trial", range(TRIALS)) -def test_stress_total_numerical_symbolic(trial): - random.seed(2000 + trial) - print(f"\n=== STRESS Trial {trial} : Total Basis (deg≤{MAX_DEG}, dim≤{MAX_COORD}) ===") + # Timing comparison + start_sparse = timer(); cs.decompose(polynomial_function, sparse=True); elapsed_sparse = timer() - start_sparse + start_full = timer(); cs.decompose(polynomial_function, sparse=False); elapsed_full = timer() - start_full + total_timings.append(elapsed_full / elapsed_sparse) - cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, - max_deg=MAX_DEG, - max_coords=MAX_COORD) - polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) +#=====================================================================# +# Timing summaries +#=====================================================================# - ok, diffs = cs.check_decomposition_numerical_symbolic(polynomial_function, - sparse=True, - tol=TOL, - verbose=False) - assert ok +@pytest.mark.parametrize("finalize", [True]) +def test_tensor_timing_summary(finalize): + if not tensor_timings: + pytest.skip("No tensor timings recorded") + + ratios = np.array(tensor_timings) + print("\n=== Timing Summary: Tensor Basis Vector Decomposition ===") + print(f"Trials : {len(ratios)}") + print(f"Mean ratio : {ratios.mean():.3f}") + print(f"Std. dev : {ratios.std():.3f}") + print(f"Min ratio : {ratios.min():.3f}") + print(f"Max ratio : {ratios.max():.3f}") + + +@pytest.mark.parametrize("finalize", [True]) +def test_total_timing_summary(finalize): + if not total_timings: + pytest.skip("No total timings recorded") + + ratios = np.array(total_timings) + print("\n=== Timing Summary: Total Basis Vector Decomposition ===") + print(f"Trials : {len(ratios)}") + print(f"Mean ratio : {ratios.mean():.3f}") + print(f"Std. dev : {ratios.std():.3f}") + print(f"Min ratio : {ratios.min():.3f}") + print(f"Max ratio : {ratios.max():.3f}") From 3fb60755732d0394ef01a4b23a4de3ff7b723949 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 20:38:47 -0400 Subject: [PATCH 48/54] fix verbosity --- pspace/core.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index fa96cf7..4937d6c 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -884,7 +884,7 @@ def check_decomposition_numerical_symbolic(self, # Reporting #-------------------------------------------------------------# - if verbose: + if verbose or not ok: status = "PASSED" if ok else "FAILED" print(f"[Consistency Check] {status} with tol = {tol}, ortho tol = {ortho_tol:.2e}") print(f"[Elapsed Time] numerical {elapsed_num:.3e} analytic = {elapsed_sym:.3e}") @@ -928,7 +928,7 @@ def check_decomposition_numerical_sparse_full(self, function: PolyFunction, diffs[k] = (coeff_sparse, coeff_full, err) - if verbose: + if verbose or not ok: print(f"[Assembly Check] {'PASSED' if ok else 'FAILED'} with tol = {tol}") print(f"[Elapsed Time] Sparse {elapsed_sparse} Full = {elapsed_full} Ratio = {elapsed_full/elapsed_sparse}") header = f"{'Basis':<7} {'Sparse':>12} {'Full':>12} {'Error':>12}" @@ -978,7 +978,7 @@ def check_decomposition_matrix_sparse_full(self, function, tol=1e-12, verbose=Tr # Report #---------------------------------------------------------------# - if verbose: + if verbose or not ok: print(f"[Matrix Assembly Check] {'PASSED' if ok else 'FAILED'} " f"with tol = {tol}") print(f"[Elapsed Time] Sparse {elapsed_sparse:.4e} " @@ -1049,7 +1049,7 @@ def check_decomposition_matrix_numerical_symbolic(self, function, tol=1e-12, ver # Report #-------------------------------------------------------------# - if verbose: + if verbose or not ok: print(f"[Matrix Numerical vs Analytic Check] {'PASSED' if ok else 'FAILED'} " f"with tol = {tol}") print(f"[Elapsed Time] numerical {elapsed_num:.4e} " From 65778c4faa46c56a8281e7aecec8d1d6b66d31bd Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 20:53:24 -0400 Subject: [PATCH 49/54] fix tests --- tests/stress_test_matrix_decomposition.py | 33 ++++++++++++++--------- tests/stress_test_vector_decomposition.py | 6 ++--- tests/test_utils.py | 4 +-- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/stress_test_matrix_decomposition.py b/tests/stress_test_matrix_decomposition.py index e0ad7c8..2a52581 100644 --- a/tests/stress_test_matrix_decomposition.py +++ b/tests/stress_test_matrix_decomposition.py @@ -25,28 +25,35 @@ get_coordinate_system_type) + #=====================================================================# -# Helper: store timing ratios for summary +# Parameters for stress regime #=====================================================================# + +MAX_DEG = 5 # push polynomial/basis degree +MAX_COORD = 5 # up to 5D coordinates +TRIALS = 3 # fewer trials (stress is heavier) +TOL = 1e-6 + +# Timing collectors tensor_timings = [] total_timings = [] - #=====================================================================# # Stress Test A: Tensor Degree Basis #=====================================================================# -@pytest.mark.parametrize("trial", range(20)) +@pytest.mark.parametrize("trial", range(TRIALS)) def test_stress_tensor_matrix_sparse_full(trial): - random.seed(trial) + random.seed(3000 + trial) print(f"\n=== Stress Trial {trial} : Matrix TENSOR Basis (sparse vs full) ===") cs = get_coordinate_system_type(BasisFunctionType.TENSOR_DEGREE, - max_deg=4, - max_coords=4) + max_deg=MAX_DEG, + max_coords=MAX_COORD) - polynomial_function = random_polynomial(cs, max_deg=3, max_terms=5) + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG, max_cross_terms=MAX_DEG) ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, tol=1e-6, @@ -78,20 +85,20 @@ def test_tensor_timing_summary(finalize): # Stress Test B: Total Degree Basis #=====================================================================# -@pytest.mark.parametrize("trial", range(20)) +@pytest.mark.parametrize("trial", range(TRIALS)) def test_stress_total_matrix_sparse_full(trial): - random.seed(trial) + random.seed(3000 + trial) print(f"\n=== Stress Trial {trial} : Matrix TOTAL Basis (sparse vs full) ===") cs = get_coordinate_system_type(BasisFunctionType.TOTAL_DEGREE, - max_deg=4, - max_coords=4) + max_deg=MAX_DEG, + max_coords=MAX_COORD) - polynomial_function = random_polynomial(cs, max_deg=3, max_terms=5) + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG, max_cross_terms=MAX_DEG) ok, diffs = cs.check_decomposition_matrix_sparse_full(polynomial_function, - tol=1e-6, + tol=TOL, verbose=True) assert ok diff --git a/tests/stress_test_vector_decomposition.py b/tests/stress_test_vector_decomposition.py index 8ef9e31..e5e3258 100644 --- a/tests/stress_test_vector_decomposition.py +++ b/tests/stress_test_vector_decomposition.py @@ -30,7 +30,7 @@ # Parameters for stress regime #=====================================================================# -MAX_DEG = 8 # push polynomial/basis degree +MAX_DEG = 5 # push polynomial/basis degree MAX_COORD = 5 # up to 5D coordinates TRIALS = 3 # fewer trials (stress is heavier) TOL = 1e-6 @@ -52,7 +52,7 @@ def test_stress_tensor_sparse_full(trial): max_deg=MAX_DEG, max_coords=MAX_COORD) - polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG, max_cross_terms=MAX_DEG) ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, tol=TOL, @@ -74,7 +74,7 @@ def test_stress_total_sparse_full(trial): max_deg=MAX_DEG, max_coords=MAX_COORD) - polynomial_function = random_polynomial(cs, max_deg=MAX_DEG) + polynomial_function = random_polynomial(cs, max_deg=MAX_DEG, max_cross_terms=MAX_DEG) ok, diffs = cs.check_decomposition_numerical_sparse_full(polynomial_function, tol=TOL, diff --git a/tests/test_utils.py b/tests/test_utils.py index 6e401af..fddd738 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -66,7 +66,7 @@ def random_coordinate(cf, cid, max_deg = 4): # Helper : build random polynomial (with cross terms) #=====================================================================# -def random_polynomial(cs, max_deg=2, max_terms=3): +def random_polynomial(cs, max_deg=2, max_cross_terms=3): coords = list(cs.coordinates.keys()) symbols = {cid : cs.coordinates[cid].symbol for cid in coords} fdeg = Counter() @@ -87,7 +87,7 @@ def random_polynomial(cs, max_deg=2, max_terms=3): #---------------------------------------------------------------# if len(coords) >= 2: - for _ in range(random.randint(0, max_terms)): + for _ in range(random.randint(0, max_cross_terms)): cids = random.sample(coords, k=random.randint(2, len(coords))) coeff = random.randint(1, 3) degs = Counter() From 53c99c4ecd0397a236684911b3f82a3fbefc0c88 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sun, 28 Sep 2025 22:03:16 -0400 Subject: [PATCH 50/54] delete unused functions --- pspace/core.py | 6 ------ pspace/stochastic_utils.py | 41 -------------------------------------- 2 files changed, 47 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index 4937d6c..10159a8 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -263,12 +263,6 @@ def gaussian_quadrature(self, degree): w_shifted = 0.5 * w return x_shifted, w_shifted - def gaussian_quadrature_(self, degree): - npts = minnum_quadrature_points(degree) - x, w = np.polynomial.legendre.leggauss(npts) - w = w / 2.0 - return x, w - class ExponentialCoordinate(Coordinate): def __init__(self, pdata): super().__init__(pdata) diff --git a/pspace/stochastic_utils.py b/pspace/stochastic_utils.py index 86ae29d..216667e 100644 --- a/pspace/stochastic_utils.py +++ b/pspace/stochastic_utils.py @@ -25,15 +25,6 @@ def generate_basis_adaptive(f_indices, m): return {tuple(sum(idx[i] for idx in combo) for i in range(len(combo[0]))) for combo in combos} -def sparse(dmapi, dmapj, dmapf): - smap = {} - for key in dmapi.keys(): - if abs(dmapi[key] - dmapj[key]) <= dmapf[key]: - smap[key] = True - else: - smap[key] = False - return smap - def minnum_quadrature_points(degree): """ Return the number of quadrature points necessary to integrate the @@ -102,15 +93,6 @@ def generate_basis_total_degree(max_degree_params): return basis -def sum_degrees_union_vector(f_degrees, psi_i): - """Union over all monomials in f with ψ_i: max degree per axis.""" - degs = Counter() - for f_deg in f_degrees: - for a in set(f_deg) | set(psi_i): - degs[a] = max(degs.get(a, 0), - f_deg.get(a, 0) + psi_i.get(a, 0)) - return degs - def sum_degrees_union_vector(f_degrees, psi_k: Counter) -> Counter: """ Compute required quadrature degree for product f(y)*ψ_k(y). @@ -199,26 +181,3 @@ def sum_degrees_union_matrix(f_degrees, psi_i: Counter, psi_j: Counter) -> Count degs[a] = max(degs.get(a, 0), total) return degs - -if __name__ == '__main__': - - max_degree_params = {0:2, 1:2} - - out = generate_basis_tensor_degree(max_degree_params) - print(len(out), out) - - out = generate_basis_total_degree(max_degree_params) - print(len(out), out) - - basis = { - 0: Counter({'x': 0, 'y': 0}), - 1: Counter({'x': 1, 'y': 0}), - 2: Counter({'x': 0, 'y': 1}), - 3: Counter({'x': 1, 'y': 1}) - } - - deg_f = Counter({'x': 1, 'y': 0}) - - mask = sparsity_mask(basis, deg_f) - - print(mask) From 25262e24afe60a992a812ea605a46ae30680ddd1 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Fri, 3 Oct 2025 20:14:08 -0400 Subject: [PATCH 51/54] add ortho poly function and reconstruct method --- pspace/core.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/pspace/core.py b/pspace/core.py index 10159a8..de1fe18 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -118,6 +118,89 @@ def __call__(self, Y): def __repr__(self): return f"PolyFunction({self._terms})" +class OrthoPolyFunction: + """ + Polynomial function expressed in orthonormal basis (Legendre, Hermite, etc.) + Works with CoordinateSystem and its Coordinate axes. + """ + + def __init__(self, terms, coordinates): + """ + Parameters + ---------- + terms : list[(coeff: float, degs: Counter)] + List of terms (basis coefficient, degree counter per coordinate). + coordinates : dict[int, Coordinate] + Dictionary of coordinate objects {cid: Coordinate}. + """ + self._terms = terms + self._coords = coordinates + + def __call__(self, Y): + """ + Evaluate function at point Y in computational coordinates. + Y : dict {cid: float} + """ + total = 0.0 + for coeff, degs in self._terms: + term_val = coeff + for cid, d in degs.items(): + # Evaluate orthogonal basis polynomial at Y[cid] + term_val *= self._coords[cid].psi_y(Y[cid], d) + total += term_val + return total + + def toPolyFunction(self): + """ + Expand OrthoPolyFunction into monomial PolyFunction + using change-of-basis matrices (currently supports Legendre). + """ + from numpy.polynomial import legendre as npleg + from numpy.polynomial import polynomial as nppoly + from .core import PolyFunction # adjust import if needed + + monomial_terms = Counter() + + for coeff, degs in self._terms: + # Build tensor product expansion for each coordinate + expansions = [] + for cid, d in degs.items(): + coord = self._coords[cid] + if coord.dist_type.name == "UNIFORM": # Legendre basis + Pn = npleg.Legendre.basis(d) + poly = Pn.convert(kind=nppoly.Polynomial) + coeffs_power = np.array(poly.coef, dtype=float) + s = np.sqrt((2*d+1)/2.0) # orthonormal scale + coeffs_power *= s + expansions.append((cid, coeffs_power)) + else: + raise NotImplementedError("toPolyFunction only supports Legendre for now") + + # Recursive tensor product accumulation + def recurse(idx, running_coeff, running_degs): + if idx == len(expansions): + monomial_terms[running_degs] += coeff * running_coeff + return + cid, coeffs_power = expansions[idx] + for p, val in enumerate(coeffs_power): + if abs(val) < 1e-15: + continue + recurse(idx+1, running_coeff*val, + running_degs + Counter({cid: p})) + + recurse(0, 1.0, Counter()) + + # Build PolyFunction terms + terms = [(float(val), degs) for degs, val in monomial_terms.items() if abs(val) > 1e-15] + return PolyFunction(terms) + + def coeffs(self): + """Return coefficients directly.""" + return {tuple(sorted(d.items())): c for c, d in self._terms} + + def __repr__(self): + return f"OrthoPolyFunction({len(self._terms)} terms, basis=orthonormal)" + #=====================================================================# # Coordinate Base Class #=====================================================================# @@ -1058,3 +1141,26 @@ def check_decomposition_matrix_numerical_symbolic(self, function, tol=1e-12, ver print("-" * len(header)) return ok, diffs + + def reconstruct(self, coeffs: dict) -> "OrthoPolyFunction": + """ + Reconstruct an OrthoPolyFunction from basis coefficients. + + Parameters + ---------- + coeffs : dict {basis_id: float} + Coefficients for basis functions. + + Returns + ------- + OrthoPolyFunction : reconstructed function in orthonormal basis + """ + terms = [] + for k, c in coeffs.items(): + if k not in self.basis: + raise ValueError(f"Basis index {k} not found in current basis set.") + degs = self.basis[k] # Counter({cid: degree}) + if abs(c) > 1e-15: + terms.append((c, degs)) + + return OrthoPolyFunction(terms, self.coordinates) From d37395668350b0a58380887c29ab199ba7822dac Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 4 Oct 2025 20:20:25 -0400 Subject: [PATCH 52/54] add state equation --- pspace/core.py | 119 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/pspace/core.py b/pspace/core.py index de1fe18..f48e3b5 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -1142,25 +1142,108 @@ def check_decomposition_matrix_numerical_symbolic(self, function, tol=1e-12, ver return ok, diffs - def reconstruct(self, coeffs: dict) -> "OrthoPolyFunction": - """ - Reconstruct an OrthoPolyFunction from basis coefficients. +#=====================================================================# +# State Equation Interface +#=====================================================================# +class StateEquation: + """ + Generic state equation in coefficient form: + Operator * a_state = RHS + where Operator is assembled in the CoordinateSystem basis. + """ + + def __init__(self, name, operator_fn, rhs_fn, coord_system): + """ Parameters ---------- - coeffs : dict {basis_id: float} - Coefficients for basis functions. - - Returns - ------- - OrthoPolyFunction : reconstructed function in orthonormal basis + name : str + Identifier (e.g., "reconstruction", "diffusion", "helmholtz") + operator_fn : callable | PolyFunction + Defines the operator kernel f(y) for assembling A_ij = <ψ_i, f, ψ_j> + rhs_fn : callable | PolyFunction | np.ndarray + Defines RHS b_i = <ψ_i, f> or direct coefficients + coord_system : CoordinateSystem + The coordinate system in which this state equation lives. """ - terms = [] - for k, c in coeffs.items(): - if k not in self.basis: - raise ValueError(f"Basis index {k} not found in current basis set.") - degs = self.basis[k] # Counter({cid: degree}) - if abs(c) > 1e-15: - terms.append((c, degs)) - - return OrthoPolyFunction(terms, self.coordinates) + self.name = name + self.operator_fn = operator_fn + self.rhs_fn = rhs_fn + self.cs = coord_system + self.operator_matrix = None + self.rhs_vector = None + self.solution = None + + #-------------------------------------------------------------# + # Assembly + #-------------------------------------------------------------# + def assemble(self, analytic=False, sparse=True, symmetric=True): + """Assemble operator and RHS in the coordinate basis.""" + cs = self.cs + if isinstance(self.operator_fn, PolyFunction): + if analytic: + A = cs.decompose_matrix_analytic(self.operator_fn, + sparse=sparse, symmetric=symmetric) + else: + A = cs.decompose_matrix(self.operator_fn, + sparse=sparse, symmetric=symmetric) + elif callable(self.operator_fn): + raise NotImplementedError("Callable operator assembly not yet implemented") + else: + A = np.asarray(self.operator_fn) + + # RHS + if isinstance(self.rhs_fn, PolyFunction): + b = cs.decompose(self.rhs_fn, sparse=sparse) + b = np.array([b[k] for k in sorted(b.keys())]) + elif callable(self.rhs_fn): + raise NotImplementedError("Callable RHS not yet implemented") + else: + b = np.asarray(self.rhs_fn) + + self.operator_matrix = A + self.rhs_vector = b + + #-------------------------------------------------------------# + # Preconditioning / Whitening + #-------------------------------------------------------------# + def precondition(self, method="cholesky"): + """Compute and apply preconditioner P such that P⁻¹ A P⁻ᵀ ≈ I.""" + A = self.operator_matrix + if method == "cholesky": + L = np.linalg.cholesky(A) + P = L + elif method == "spectral": + eigval, eigvec = np.linalg.eigh(A) + P = eigvec @ np.diag(np.sqrt(eigval)) + else: + raise ValueError(f"Unknown preconditioner {method}") + + self.P = P + self.P_inv = np.linalg.inv(P) + self.operator_whitened = self.P_inv @ A @ self.P_inv.T + self.rhs_whitened = self.P_inv @ self.rhs_vector + + #-------------------------------------------------------------# + # Solve + #-------------------------------------------------------------# + def solve(self): + """Solve the whitened or raw system.""" + A = getattr(self, "operator_whitened", self.operator_matrix) + b = getattr(self, "rhs_whitened", self.rhs_vector) + x = np.linalg.solve(A, b) + # Back transform if whitened + if hasattr(self, "P"): + x = self.P_inv.T @ x + self.solution = x + return x + + #-------------------------------------------------------------# + # Diagnostic + #-------------------------------------------------------------# + def condition_number(self): + A = self.operator_matrix + return np.linalg.cond(A) + + def __repr__(self): + return f"StateEquation({self.name}, nbasis={self.cs.getNumBasisFunctions()})" From be72252c40ee0b8487e50eeac094d3f505858384 Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Sat, 4 Oct 2025 21:20:12 -0400 Subject: [PATCH 53/54] demo state equation solve --- demos/demo_state_equation.py | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 demos/demo_state_equation.py diff --git a/demos/demo_state_equation.py b/demos/demo_state_equation.py new file mode 100644 index 0000000..0d38a5b --- /dev/null +++ b/demos/demo_state_equation.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +import numpy as np +from collections import Counter +from pspace.core import ( + CoordinateFactory, CoordinateSystem, + BasisFunctionType, PolyFunction, StateEquation +) + +#---------------------------------------------------------------------# +# 1. Coordinate system setup +#---------------------------------------------------------------------# + +cf = CoordinateFactory() +cs = CoordinateSystem(BasisFunctionType.TENSOR_DEGREE, verbose=False) + +coord_x = cf.createUniformCoordinate(cf.newCoordinateID(), "x", dict(a=-1, b=1), max_monomial_dof=2) +coord_y = cf.createUniformCoordinate(cf.newCoordinateID(), "y", dict(a=-1, b=1), max_monomial_dof=2) + +cs.addCoordinateAxis(coord_x) +cs.addCoordinateAxis(coord_y) + +cs.initialize() + +print(f"Num basis functions = {cs.getNumBasisFunctions()}") + +#---------------------------------------------------------------------# +# 2. Define operator and RHS +#---------------------------------------------------------------------# + +# Operator: identity weight (Gram matrix) +gram_op = PolyFunction([(1.0, Counter())]) + +# RHS: g(x,y) = 1 + 2x + 3y +rhs_fn = PolyFunction([ + (1.0, Counter()), + (2.0, Counter({coord_x.id: 1})), + (3.0, Counter({coord_y.id: 1})) +]) + +#---------------------------------------------------------------------# +# 3. Build and assemble StateEquation +#---------------------------------------------------------------------# + +eq = StateEquation("reconstruction", gram_op, rhs_fn, cs) +eq.assemble(analytic=False, sparse=True) + +print("\n[Operator matrix G_phi]") +print(np.round(eq.operator_matrix, 3)) + +print("\n[RHS vector]") +print(np.round(eq.rhs_vector, 3)) + +#---------------------------------------------------------------------# +# 4. Precondition and solve +#---------------------------------------------------------------------# + +eq.precondition(method="cholesky") +a_phi = eq.solve() + +print("\n[Whitened operator condition number]") +print(np.linalg.cond(eq.operator_whitened)) + +print("\n[Solved coefficients a_phi]") +print(np.round(a_phi, 6)) + +# Verify reconstruction consistency +residual = eq.operator_matrix @ a_phi - eq.rhs_vector +print("‖Residual‖₂ =", np.linalg.norm(residual)) From 4f28962599b5de7d2b08b8e0f7968a623a2a14db Mon Sep 17 00:00:00 2001 From: Komahan Boopathy Date: Mon, 20 Oct 2025 13:22:05 -0400 Subject: [PATCH 54/54] Fix sparsity mask for total-degree bases --- pspace/core.py | 17 ++++++----------- tests/test_sparsity_logic.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 tests/test_sparsity_logic.py diff --git a/pspace/core.py b/pspace/core.py index f48e3b5..8294cfa 100644 --- a/pspace/core.py +++ b/pspace/core.py @@ -532,18 +532,13 @@ def sparse_vector(self, dmapi, dmapf): """ Detect sparsity for . - dmapi : Counter({axis: degree}) for basis ψ_i - dmapf : Counter({axis: degree}) for function f - basis_type: "tensor" or "total" + dmapi : Counter({axis: degree}) for basis ψ_i + dmapf : Counter({axis: degree}) for function f """ - if self.basis_construction == BasisFunctionType.TENSOR_DEGREE: - # Axis-by-axis cutoff - return all(dmapi[k] <= dmapf[k] for k in dmapf.keys()) - elif self.basis_construction == BasisFunctionType.TOTAL_DEGREE: - # Global cutoff (total degree) - return sum(dmapi.values()) <= sum(dmapf.values()) - else: - raise ValueError("Unknown basis_type") + for axis, deg in dmapi.items(): + if deg > dmapf.get(axis, 0): + return False + return True def monomial_vector_sparsity_mask(self, f_deg: Counter): mask = set() diff --git a/tests/test_sparsity_logic.py b/tests/test_sparsity_logic.py new file mode 100644 index 0000000..2b7d0fd --- /dev/null +++ b/tests/test_sparsity_logic.py @@ -0,0 +1,37 @@ +from collections import Counter + +from pspace.core import BasisFunctionType, CoordinateFactory, CoordinateSystem + + +def test_total_degree_sparse_vector_axis_cutoff(): + factory = CoordinateFactory() + cs = CoordinateSystem(BasisFunctionType.TOTAL_DEGREE) + + y0 = factory.createNormalCoordinate(factory.newCoordinateID(), 'y0', dict(mu=0.0, sigma=1.0), 4) + y1 = factory.createUniformCoordinate(factory.newCoordinateID(), 'y1', dict(a=-1.0, b=1.0), 4) + + cs.addCoordinateAxis(y0) + cs.addCoordinateAxis(y1) + cs.initialize() + + exceeds_axis = Counter({0: 4, 1: 0}) + function_deg = Counter({0: 2, 1: 2}) + + assert cs.sparse_vector(exceeds_axis, function_deg) is False + + +def test_total_degree_sparse_vector_within_axis(): + factory = CoordinateFactory() + cs = CoordinateSystem(BasisFunctionType.TOTAL_DEGREE) + + y0 = factory.createNormalCoordinate(factory.newCoordinateID(), 'y0', dict(mu=0.0, sigma=1.0), 3) + y1 = factory.createUniformCoordinate(factory.newCoordinateID(), 'y1', dict(a=-1.0, b=1.0), 3) + + cs.addCoordinateAxis(y0) + cs.addCoordinateAxis(y1) + cs.initialize() + + within_axis = Counter({0: 2, 1: 1}) + function_deg = Counter({0: 2, 1: 2}) + + assert cs.sparse_vector(within_axis, function_deg) is True