rbflab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. rbflab/__init__.py +70 -0
  2. rbflab/assembly.py +73 -0
  3. rbflab/block_methods.py +292 -0
  4. rbflab/cuda_backend.py +85 -0
  5. rbflab/descriptor.py +36 -0
  6. rbflab/diagnostics.py +56 -0
  7. rbflab/differentiable_lhi.py +147 -0
  8. rbflab/discrete_operators.py +227 -0
  9. rbflab/eigen_build.py +15 -0
  10. rbflab/evolution.py +378 -0
  11. rbflab/geometry.py +190 -0
  12. rbflab/hermite.py +39 -0
  13. rbflab/kernel_compiler.py +283 -0
  14. rbflab/kernels.py +187 -0
  15. rbflab/legacy_cpp.py +120 -0
  16. rbflab/lhi_backends.py +293 -0
  17. rbflab/lhi_stokes.py +367 -0
  18. rbflab/mesh_adapters.py +43 -0
  19. rbflab/methods.py +302 -0
  20. rbflab/nodal.py +233 -0
  21. rbflab/operator_backends.py +128 -0
  22. rbflab/operators.py +168 -0
  23. rbflab/precision.py +263 -0
  24. rbflab/problems.py +101 -0
  25. rbflab/rbf_fd.py +148 -0
  26. rbflab/rbf_ra.py +76 -0
  27. rbflab/reference.py +131 -0
  28. rbflab/scalar_backends.py +186 -0
  29. rbflab/scalar_fd.py +55 -0
  30. rbflab/scaling.py +30 -0
  31. rbflab/space_stokes.py +328 -0
  32. rbflab/spaces.py +126 -0
  33. rbflab/sparse_precision.py +175 -0
  34. rbflab/stencils.py +86 -0
  35. rbflab/stokes.py +166 -0
  36. rbflab/stokes_polynomials.py +53 -0
  37. rbflab/stokes_ra.py +98 -0
  38. rbflab/strategies.py +45 -0
  39. rbflab/symbolic.py +275 -0
  40. rbflab/symbolic_kernel.py +172 -0
  41. rbflab/symbolic_system.py +149 -0
  42. rbflab/time_data.py +62 -0
  43. rbflab/torch_backend.py +277 -0
  44. rbflab/unsteady_stokes.py +167 -0
  45. rbflab/viz.py +216 -0
  46. rbflab-0.1.0.dist-info/METADATA +85 -0
  47. rbflab-0.1.0.dist-info/RECORD +50 -0
  48. rbflab-0.1.0.dist-info/WHEEL +5 -0
  49. rbflab-0.1.0.dist-info/licenses/LICENSE +21 -0
  50. rbflab-0.1.0.dist-info/top_level.txt +1 -0
rbflab/__init__.py ADDED
@@ -0,0 +1,70 @@
1
+ """RBFLAB: radial basis functions for interpolation and PDEs."""
2
+ from .precision import Precision, PrecisionData
3
+ from .kernels import IMQ, Gaussian, PHS, Hybrid, DivergenceFree
4
+ from .operators import Identity, Derivative, Laplacian, NormalDerivative, Robin
5
+ from .geometry import PointCloud, gmsh_square, gmsh_cube, unit_box_grid
6
+ from .problems import LinearPDE, BoundaryCondition, Dirichlet
7
+ from .nodal import rbf_fd_weights
8
+ from .rbf_fd import RBFFD
9
+ from .methods import GlobalCollocation, LHI, interpolate
10
+
11
+ __all__ = [
12
+ "from_rbfmeshgen", "nodal_diagnostics", "TimeData", "UnsteadyStokesProblem", "LHIUnsteadyStokes", "GlobalUnsteadyStokes", "StokesProblem", "GlobalStokes", "StencilPolicy", "Precision", "PrecisionData", "IMQ", "Gaussian", "PHS", "Hybrid", "DivergenceFree", "Identity", "Derivative", "Laplacian",
13
+ "NormalDerivative", "Robin", "PointCloud", "gmsh_square", "gmsh_cube", "unit_box_grid", "LinearPDE",
14
+ "BoundaryCondition", "Dirichlet", "GlobalCollocation", "LHI", "RBFFD", "rbf_fd_weights", "interpolate",
15
+ ]
16
+
17
+
18
+ from .stencils import StencilPolicy
19
+
20
+ from .mesh_adapters import from_rbfmeshgen
21
+ from .diagnostics import nodal_diagnostics
22
+
23
+ from .stokes import StokesProblem, GlobalStokes
24
+
25
+ from .unsteady_stokes import TimeData, UnsteadyStokesProblem, GlobalUnsteadyStokes
26
+
27
+ from .lhi_stokes import LHIUnsteadyStokes
28
+
29
+ from .strategies import growing_hybrid_stokes, growing_stencil_size
30
+ __all__ += ["growing_hybrid_stokes", "growing_stencil_size"]
31
+
32
+ from .evolution import EvolutionPDE, EvolutionSystem, EvolutionTrajectory
33
+ __all__ += ["EvolutionPDE", "EvolutionSystem", "EvolutionTrajectory"]
34
+
35
+ from .symbolic import SymbolicScalar
36
+ __all__ += ["SymbolicScalar"]
37
+
38
+ from .operators import SpatialOperator
39
+ __all__ += ["SpatialOperator"]
40
+
41
+ from .time_data import InitialData
42
+ __all__ += ["InitialData"]
43
+
44
+ from .symbolic_system import SymbolicSystem, BlockPDE
45
+ from .block_methods import BlockGlobal, BlockLHI
46
+ __all__ += ["SymbolicSystem", "BlockPDE", "BlockGlobal", "BlockLHI"]
47
+
48
+ from .spaces import ScalarSpace, DivergenceFreeSpace, PressureSpace
49
+ __all__ += ["ScalarSpace", "DivergenceFreeSpace", "PressureSpace"]
50
+
51
+ from .legacy_cpp import LegacyCppLHIBackend
52
+ __all__ += ["LegacyCppLHIBackend"]
53
+
54
+ from .lhi_backends import PythonBackend, CppBackend
55
+ __all__ += ['PythonBackend', 'CppBackend']
56
+
57
+ from .symbolic_kernel import Kernel, Wendland
58
+ __all__ += ["Kernel", "Wendland"]
59
+
60
+ from .cuda_backend import CudaLHIBackend
61
+ __all__ += ["CudaLHIBackend"]
62
+
63
+ from .torch_backend import TorchBackend, TorchKernel
64
+ __all__ += ['TorchBackend', 'TorchKernel']
65
+
66
+ from .differentiable_lhi import DifferentiableLHI
67
+ __all__ += ['DifferentiableLHI']
68
+
69
+ from .discrete_operators import DiscreteOperator, OperatorSet
70
+ __all__ += ["DiscreteOperator", "OperatorSet"]
rbflab/assembly.py ADDED
@@ -0,0 +1,73 @@
1
+ """Shared functional assembly and equilibrated dense solves."""
2
+ import warnings
3
+ import numpy as np
4
+ from scipy.linalg import lu_factor, lu_solve, LinAlgWarning
5
+
6
+
7
+ def functional_matrix(kernel, x, left, y, right):
8
+ from .operators import bind_operators
9
+ left, right = bind_operators(left,x), bind_operators(right,y)
10
+ out = np.empty((len(x), len(y)))
11
+ lg, rg = {}, {}
12
+ for i, op in enumerate(left):
13
+ lg.setdefault(op, []).append(i)
14
+ for i, op in enumerate(right):
15
+ rg.setdefault(op, []).append(i)
16
+ if len(lg)>8 or len(rg)>8:
17
+ # Point-dependent coefficients otherwise produce one tiny kernel call
18
+ # per point pair. Group derivative indices and multiply coefficient
19
+ # vectors outside the vectorized kernel blocks instead.
20
+ from .operators import Operator
21
+ out.fill(0)
22
+ terms=[]
23
+ for operators in (left,right):
24
+ groups={}
25
+ for i,op in enumerate(operators):
26
+ for alpha,c in op.terms:
27
+ rows,coefficients=groups.setdefault(alpha,([],[]))
28
+ rows.append(i);coefficients.append(float(c))
29
+ terms.append(groups)
30
+ for alpha,(rows,ca) in terms[0].items():
31
+ for beta,(cols,cb) in terms[1].items():
32
+ block=kernel.matrix(x[rows],y[cols],Operator(((alpha,1),)),Operator(((beta,1),)))
33
+ out[np.ix_(rows,cols)]+=np.asarray(ca)[:,None]*block*np.asarray(cb)[None,:]
34
+ return out
35
+ for opx, rows in lg.items():
36
+ for opy, cols in rg.items():
37
+ out[np.ix_(rows, cols)] = kernel.matrix(x[rows], y[cols], opx, opy)
38
+ return out
39
+
40
+
41
+ class Factor:
42
+ """Diagonal equilibration; no jitter, pseudoinverse or silent regularization."""
43
+ def __init__(self, matrix, general=False, compute_condition=True):
44
+ self.matrix = np.asarray(matrix, dtype=float)
45
+ if self.matrix.ndim != 2 or self.matrix.shape[0] != self.matrix.shape[1] or not np.isfinite(self.matrix).all():
46
+ raise ValueError("Expected a finite square matrix")
47
+ diagonal = np.max(np.abs(self.matrix), axis=1) if general else np.abs(np.diag(self.matrix))
48
+ if np.any(diagonal == 0):
49
+ raise np.linalg.LinAlgError("Zero functional diagonal")
50
+ self.scale = 1 / np.sqrt(diagonal)
51
+ self.scaled = self.scale[:, None] * self.matrix * self.scale[None, :]
52
+ if type(compute_condition) is not bool:raise TypeError("compute_condition must be bool")
53
+ self.condition = float(np.linalg.cond(self.scaled)) if compute_condition else None
54
+ with warnings.catch_warnings():
55
+ warnings.simplefilter("error", LinAlgWarning)
56
+ try:
57
+ self.lu = lu_factor(self.scaled)
58
+ except LinAlgWarning as exc:
59
+ raise np.linalg.LinAlgError(str(exc)) from exc
60
+ if self.condition is not None and (not np.isfinite(self.condition) or self.condition > 1e15):
61
+ warnings.warn(f"Ill-conditioned equilibrated system: {self.condition:.3e}",
62
+ RuntimeWarning, stacklevel=2)
63
+
64
+ def solve(self, rhs, transpose=False):
65
+ rhs = np.asarray(rhs)
66
+ scale = self.scale if rhs.ndim == 1 else self.scale[:, None]
67
+ return scale * lu_solve(self.lu, scale * rhs, trans=1 if transpose else 0)
68
+
69
+
70
+ def relative_residual(matrix, solution, rhs):
71
+ error = np.linalg.norm(matrix @ solution - rhs, ord=np.inf)
72
+ # RHS-relative residual: sensitive to inaccurate local/global solves.
73
+ return float(error / max(np.linalg.norm(rhs, ord=np.inf), np.finfo(float).tiny))
@@ -0,0 +1,292 @@
1
+ """Stationary block Hermite collocation and local Hermite assembly.
2
+
3
+ Fields use independent scalar kernels. Differential coupling lives in explicit
4
+ functional rows, including incompressibility; it is not built into the kernels.
5
+ """
6
+ from .symbolic_kernel import BoundKernel
7
+ import warnings
8
+ from dataclasses import dataclass,field
9
+ import numpy as np
10
+ from scipy.spatial import cKDTree
11
+ from .operators import Identity
12
+ from .kernels import ScalarKernel, PHS, Hybrid
13
+ from .nodal import Arithmetic,NodalBasis
14
+ from .precision import Precision
15
+ from .stencils import StencilPolicy
16
+ from .symbolic_system import BlockPDE
17
+ from .evolution import _sparse,_factor,_mv
18
+
19
+
20
+ def _kernels(selection,problem):
21
+ if not isinstance(selection,dict):return [selection]*len(problem.fields)
22
+ resolved={}
23
+ for key,kernel in selection.items():
24
+ keys=key if isinstance(key,tuple) else (key,)
25
+ for item in keys:
26
+ index=problem.field_index(item)
27
+ if index in resolved:raise ValueError("Kernel assigned more than once to a field")
28
+ resolved[index]=kernel
29
+ if len(resolved)!=len(problem.fields):raise ValueError("Supply a kernel for every field")
30
+ return [resolved[i] for i in range(len(problem.fields))]
31
+
32
+
33
+ def _arithmetic(kernels,digits):
34
+ if any(not isinstance(kernel,(ScalarKernel,PHS,Hybrid,BoundKernel)) for kernel in kernels):
35
+ raise TypeError("Block fields require scalar IMQ, Gaussian, PHS or Hybrid kernels")
36
+ arithmetics=[Arithmetic(kernel,digits) for kernel in kernels]
37
+ master=arithmetics[0]
38
+ if master.ctx:
39
+ for a in arithmetics[1:]:a.ctx=master.ctx;a.backend.ctx=master.ctx
40
+ return arithmetics
41
+
42
+
43
+ def _validate(problem,cloud):
44
+ if not isinstance(problem,BlockPDE):raise TypeError("Expected a compiled BlockPDE")
45
+ if cloud.dimension!=problem.dimension:raise ValueError("Cloud and block problem dimensions must match")
46
+ if not len(cloud.interior):raise ValueError("Block PDEs require interior nodes")
47
+ for c in problem.constraints:
48
+ if not np.isfinite(np.asarray(c.point,dtype=float)).all():raise ValueError("Constraint points must be finite")
49
+
50
+
51
+ def _boundary(problem,cloud,a):
52
+ result={}
53
+ for bc in problem.boundary:
54
+ labels=list(cloud.boundary) if bc.on=='boundary' else ([bc.on] if isinstance(bc.on,str) else list(bc.on))
55
+ for label in labels:
56
+ if label not in cloud.boundary:raise ValueError(f"Unknown boundary label: {label}")
57
+ ids=cloud.boundary[label];data=bc.rhs
58
+ if getattr(data,'requires_normals',False):
59
+ if label not in cloud.normals:raise ValueError(f"Normals required on {label}")
60
+ data=data.bind_normals(cloud.normals[label])
61
+ vals=a.data(data,cloud.points[ids])
62
+ for j,node in enumerate(ids):
63
+ key=(int(node),bc.slot)
64
+ if key in result:continue
65
+ normal=cloud.normals[label][j] if label in cloud.normals else None
66
+ ops={}
67
+ for f,op in bc.operators.items():
68
+ if getattr(op,'requires_normals',False) and normal is None:raise ValueError(f"Normals required on {label}")
69
+ ops[f]=op.at_point(cloud.points[node],normal)
70
+ if not any(op.terms for op in ops.values()):raise ValueError("Zero boundary functional")
71
+ result[key]=(ops,vals[j])
72
+ if {node for node,slot in result}!=set(cloud.boundary_indices):raise ValueError("Every boundary node needs a block boundary condition")
73
+ return result
74
+
75
+
76
+ class BlockBasis:
77
+ """Hermite representers of block functionals plus a polynomial tail per field."""
78
+ def __init__(self,arithmetics,points,functionals,degree,scaling='physical'):
79
+ self.arithmetics=arithmetics;self.a=arithmetics[0]
80
+ self.points=np.asarray(points,dtype=float);self.functionals=functionals
81
+ self.n=len(points);self.bases=[];self.offsets=[];offset=self.n
82
+ for f,a in enumerate(arithmetics):
83
+ ids=[i for i,row in enumerate(functionals) if f in row]
84
+ if not ids:raise ValueError(f"Field {f} has no source functionals")
85
+ basis=NodalBasis(a,self.points[ids],degree,source_operators=[functionals[i][f] for i in ids],kernel_scaling=scaling)
86
+ self.bases.append((ids,basis));self.offsets.append(offset);offset+=len(basis.powers)
87
+ self.size=offset
88
+
89
+ def evaluation(self,points,functionals):
90
+ points=np.asarray(points,dtype=float);out=self.a.zeros(len(points),self.size)
91
+ for f,(sources,basis) in enumerate(self.bases):
92
+ targets=[i for i,row in enumerate(functionals) if f in row]
93
+ if not targets:continue
94
+ block=basis.evaluation(points[targets],[functionals[i][f] for i in targets])
95
+ cols=sources+list(range(self.offsets[f],self.offsets[f]+len(basis.powers)))
96
+ if self.a.ctx:
97
+ for i,row in enumerate(targets):
98
+ for j,col in enumerate(cols):out[row,col]+=block[i,j]
99
+ else:out[np.ix_(targets,cols)]+=block
100
+ return out
101
+
102
+ def matrix(self):
103
+ out=self.a.zeros(self.size,self.size)
104
+ out[:self.n,:]=self.evaluation(self.points,self.functionals)
105
+ P=out[:self.n,self.n:]
106
+ if self.size>self.n:
107
+ # Check polynomial unisolvency before numerical factorization.
108
+ check=np.array(P.tolist() if self.a.ctx else P,dtype=float)
109
+ scale=np.max(abs(check),axis=0)
110
+ check=check/np.where(scale>0,scale,1)
111
+ if np.linalg.matrix_rank(check)<self.size-self.n:
112
+ raise np.linalg.LinAlgError("Block polynomial functionals are not unisolvent; increase the stencil or change the polynomial degree")
113
+ out[self.n:,:self.n]=P.T
114
+ return out
115
+
116
+ def padded(self,data):return self.a.vector(list(data)+[self.a.number(0)]*(self.size-self.n))
117
+
118
+
119
+ @dataclass
120
+ class BlockGlobal:
121
+ """Symmetric block Hermite collocation; one scalar kernel or a field mapping."""
122
+ kernels: object
123
+ polynomial_degree: int | None = None
124
+ precision: Precision = field(default_factory=Precision)
125
+
126
+ def assemble(self,problem,cloud):
127
+ _validate(problem,cloud)
128
+ if not isinstance(self.precision,Precision):raise TypeError("Expected Precision")
129
+ if self.precision.local_digits is not None or self.precision.global_dtype!='float64':raise ValueError("BlockGlobal uses global_digits")
130
+ kernels=_kernels(self.kernels,problem);arithmetics=_arithmetic(kernels,self.precision.global_digits);a=arithmetics[0]
131
+ points=[];ops=[];rhs=[]
132
+ for eq in problem.equations:
133
+ points.extend(cloud.interior);ops.extend([eq.operators]*len(cloud.interior));rhs.extend(a.data(eq.rhs,cloud.interior))
134
+ for (node,slot),(row,value) in _boundary(problem,cloud,a).items():
135
+ points.append(cloud.points[node]);ops.append(row);rhs.append(value)
136
+ for c in problem.constraints:
137
+ point=np.asarray(c.point,dtype=float)[None,:]
138
+ points.append(point[0]);ops.append({c.field:Identity(problem.dimension)});rhs.extend(a.data(c.value,point))
139
+ basis=BlockBasis(arithmetics,points,ops,self.polynomial_degree)
140
+ system=BlockGlobalSystem(problem,basis,basis.matrix(),basis.padded(rhs))
141
+ return system
142
+
143
+
144
+ class BlockGlobalSystem:
145
+ def __init__(self,problem,basis,matrix,rhs):
146
+ self.problem,self.basis,self.matrix,self.rhs=problem,basis,matrix,rhs
147
+ self.arithmetic=basis.a;self.factor=None
148
+
149
+ def solve(self):
150
+ if self.factor is None:self.factor=self.arithmetic.factor(self.matrix)
151
+ z=self.factor.solve(self.rhs);a=self.arithmetic
152
+ diagnostics={'relative_residual':float(a.norm(_mv(self.matrix,z)-self.rhs)/(a.norm(self.rhs) or a.number(1))),
153
+ 'unknowns':len(z),'scaled_condition':self.factor.condition,'method':'block_global',
154
+ 'digits':a.ctx.dps if a.ctx else None}
155
+ return BlockSolution(self,z,diagnostics)
156
+
157
+
158
+ @dataclass
159
+ class BlockLHI:
160
+ """Experimental block LHI, interior values and local PDE/BC constraints.
161
+
162
+ A point gauge adds a bordered row and a compatibility multiplier in its
163
+ specified equation slot. Inspect constraint_multipliers in diagnostics.
164
+ """
165
+ kernels: object
166
+ stencil_size: int = 25
167
+ polynomial_degree: int | None = None
168
+ precision: Precision = field(default_factory=Precision)
169
+ stencil_policy: StencilPolicy = field(default_factory=StencilPolicy)
170
+
171
+ def assemble(self,problem,cloud):
172
+ _validate(problem,cloud)
173
+ if not isinstance(self.precision,Precision):raise TypeError("Expected Precision")
174
+ if self.precision.global_digits is not None:raise ValueError("BlockLHI uses local_digits and global_dtype")
175
+ if type(self.stencil_size) is not int or not 3<=self.stencil_size<=len(cloud.points):raise ValueError("Invalid stencil_size")
176
+ if not isinstance(self.stencil_policy,StencilPolicy):raise TypeError("Expected StencilPolicy")
177
+ kernels=_kernels(self.kernels,problem);arithmetics=_arithmetic(kernels,self.precision.local_digits)
178
+ local=arithmetics[0];a=local if self.precision.global_dtype=='mpmath' else Arithmetic(kernels[0])
179
+ bd=_boundary(problem,cloud,local);ni=len(cloud.interior);nf=len(problem.fields);nc=len(problem.constraints)
180
+ interior={int(node):i for i,node in enumerate(cloud.interior_indices)}
181
+ rhs_data=[local.data(eq.rhs,cloud.points) for eq in problem.equations]
182
+ size=nf*ni+nc;rows=[{} for _ in range(size)];rhs=[a.number(0)]*size
183
+ tree=cKDTree(cloud.points);stencils=[];identity=Identity(problem.dimension)
184
+ for center in cloud.interior_indices:
185
+ neighbors=self.stencil_policy.select(tree,cloud.points[center],self.stencil_size,self.polynomial_degree)
186
+ selected=set(int(j) for j in neighbors);sc=[int(j) for j in neighbors if int(j) in interior]
187
+ points=[];ops=[];unknowns=[];known=[]
188
+ for f in range(nf):
189
+ for node in sc:
190
+ points.append(cloud.points[node]);ops.append({f:identity})
191
+ unknowns.append(f*ni+interior[node]);known.append(local.number(0))
192
+ ns=len(unknowns)
193
+ for (node,slot),(row,value) in bd.items():
194
+ if node in selected:points.append(cloud.points[node]);ops.append(row);known.append(value)
195
+ for f,eq in enumerate(problem.equations):
196
+ for node in sc:
197
+ if node!=center:
198
+ points.append(cloud.points[node]);ops.append(eq.operators);known.append(rhs_data[f][node])
199
+ basis=BlockBasis(arithmetics,points,ops,self.polynomial_degree,self.stencil_policy.scaling)
200
+ factor=local.factor(basis.matrix())
201
+ q=basis.evaluation(np.repeat(cloud.points[[center]],nf,axis=0),[eq.operators for eq in problem.equations]).T
202
+ weights=factor.solve(q,transpose=True).T[:,:basis.n]
203
+ stencil=dict(basis=basis,factor=factor,unknowns=unknowns,known=known,ns=ns)
204
+ stencils.append(stencil)
205
+ for f in range(nf):
206
+ row=f*ni+interior[int(center)];rhs[row]=a.number(rhs_data[f][center])
207
+ for j,col in enumerate(unknowns):rows[row][col]=a.number(weights[f,j])
208
+ rhs[row]-=sum(a.number(weights[f,j])*a.number(known[j]) for j in range(ns,len(known)))
209
+ owners=cKDTree(cloud.interior)
210
+ for k,c in enumerate(problem.constraints):
211
+ point=np.asarray(c.point,dtype=float)[None,:];_,owner=owners.query(point[0]);s=stencils[int(owner)]
212
+ q=s['basis'].evaluation(point,[{c.field:identity}]).T
213
+ w=s['factor'].solve(q,transpose=True)
214
+ row=nf*ni+k
215
+ for j,col in enumerate(s['unknowns']):rows[row][col]=a.number(w[j,0])
216
+ rhs[row]=a.number(local.data(c.value,point)[0])-sum(a.number(w[j,0])*a.number(s['known'][j]) for j in range(s['ns'],len(s['known'])))
217
+ for i in range(ni):rows[c.equation*ni+i][row]=a.number(1)
218
+ return BlockLHISystem(problem,cloud,a,local,_sparse(rows,a),a.vector(rhs),stencils,owners,nf*ni)
219
+
220
+
221
+ class BlockLHISystem:
222
+ def __init__(self,problem,cloud,a,local,matrix,rhs,stencils,tree,physical_unknowns):
223
+ self.problem,self.cloud,self.arithmetic,self.local=problem,cloud,a,local
224
+ self.matrix,self.rhs,self.stencils,self.tree=matrix,rhs,stencils,tree
225
+ self.physical_unknowns=physical_unknowns;self.factor=None
226
+
227
+ def solve(self):
228
+ a=self.arithmetic
229
+ if self.factor is None:self.factor=_factor(self.matrix,a)
230
+ condition=None
231
+ if not a.ctx and len(self.rhs)<=400:
232
+ condition=float(np.linalg.cond(self.matrix.toarray()))
233
+ if condition>1e12:
234
+ warnings.warn("Ill-conditioned mixed LHI system; a small algebraic residual does not establish a unique accurate field",RuntimeWarning,stacklevel=2)
235
+ z=self.factor.solve(self.rhs)
236
+ multipliers=[float(v) for v in z[self.physical_unknowns:]]
237
+ if any(abs(v)>1e-6*(1+float(a.norm(self.rhs))) for v in multipliers):
238
+ warnings.warn("Mixed LHI has non-negligible constraint multipliers: the original PDE rows are not satisfied exactly; inspect residuals",RuntimeWarning,stacklevel=2)
239
+ diagnostics={'experimental':True,'global_condition':condition,'relative_residual':float(a.norm(_mv(self.matrix,z)-self.rhs)/(a.norm(self.rhs) or a.number(1))),
240
+ 'unknowns':len(z),'method':'block_lhi','local_digits':self.local.ctx.dps if self.local.ctx else None,
241
+ 'global_digits':a.ctx.dps if a.ctx else None,
242
+ 'constraint_multipliers':multipliers,
243
+ 'max_local_condition':max(s['factor'].condition for s in self.stencils)}
244
+ return BlockSolution(self,z,diagnostics)
245
+
246
+
247
+ class BlockSolution:
248
+ def __init__(self,system,unknowns,diagnostics):
249
+ self.system,self.unknowns,self.diagnostics=system,unknowns,diagnostics
250
+ if isinstance(system,BlockLHISystem):
251
+ self.a=system.local;self.coefficients=[]
252
+ for s in system.stencils:
253
+ data=[self.a.number(unknowns[j]) for j in s['unknowns']]+s['known'][s['ns']:]
254
+ self.coefficients.append(s['factor'].solve(s['basis'].padded(data)))
255
+ else:self.a=system.arithmetic
256
+
257
+ def _rows(self,points,functionals):
258
+ from .methods import _query
259
+ points=_query(points,self.system.problem.dimension)
260
+ if len(functionals)!=len(points):raise ValueError("One functional per query point required")
261
+ if isinstance(self.system,BlockGlobalSystem):
262
+ return _mv(self.system.basis.evaluation(points,functionals),self.unknowns)
263
+ result=self.a.vector([0]*len(points))
264
+ if not len(points):return result
265
+ _,owners=self.system.tree.query(points)
266
+ for owner in np.unique(owners):
267
+ ids=np.flatnonzero(owners==owner);s=self.system.stencils[int(owner)]
268
+ block=s['basis'].evaluation(points[ids],[functionals[int(i)] for i in ids])
269
+ values=_mv(block,self.coefficients[int(owner)])
270
+ for j,i in enumerate(ids):result[int(i)]=values[j]
271
+ return result
272
+
273
+ def evaluate(self,points,*,field=None,operator=None,extended=False):
274
+ from .methods import _query
275
+ points=_query(points,self.system.problem.dimension)
276
+ if extended and not self.a.ctx:raise ValueError("Extended evaluation requires extended arithmetic")
277
+ p=self.system.problem;fields=range(len(p.fields)) if field is None else [p.field_index(field)]
278
+ op=operator or Identity(p.dimension);out=self.a.zeros(len(points),len(fields))
279
+ for j,f in enumerate(fields):
280
+ values=self._rows(points,[{f:op}]*len(points))
281
+ for i,v in enumerate(values):out[i,j]=v
282
+ if extended:return out
283
+ result=np.asarray(out.tolist() if self.a.ctx else out,dtype=float).reshape(len(points),len(fields))
284
+ return result[:,0] if field is not None else result
285
+
286
+ def residuals(self,points,*,extended=False):
287
+ if extended and not self.a.ctx:raise ValueError("Extended evaluation requires extended arithmetic")
288
+ p=self.system.problem;out=self.a.zeros(len(points),len(p.equations))
289
+ for j,eq in enumerate(p.equations):
290
+ vals=self._rows(points,[eq.operators]*len(points));rhs=self.a.data(eq.rhs,points)
291
+ for i,v in enumerate(vals):out[i,j]=v-rhs[i]
292
+ return out if extended else np.asarray(out.tolist() if self.a.ctx else out,dtype=float)
rbflab/cuda_backend.py ADDED
@@ -0,0 +1,85 @@
1
+ """Experimental CUDA Float64 local Stokes weights; global solve stays on CPU."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from functools import lru_cache
5
+ import hashlib,json,os,re,subprocess,tempfile,time
6
+ import numpy as np
7
+ from .kernel_compiler import as_bound,stokes_header,ROOT
8
+ from .legacy_cpp import _linux_path
9
+ from .lhi_backends import _prepare,_finish
10
+
11
+ @lru_cache(maxsize=1)
12
+ def cuda_toolchain():
13
+ prefix=['wsl.exe','-d','Ubuntu','--'] if os.name=='nt' else []
14
+ nvcc=subprocess.check_output(prefix+['nvcc','--version'],text=True)
15
+ capability=subprocess.check_output(prefix+['nvidia-smi','--query-gpu=compute_cap','--format=csv,noheader'],text=True).splitlines()[0].strip().replace('.','')
16
+ if not capability.isdigit():raise RuntimeError('Cannot determine CUDA architecture')
17
+ return prefix,nvcc,capability
18
+
19
+
20
+ def compile_cuda(velocity,pressure,cache_dir=None):
21
+ prefix,nvcc,arch=cuda_toolchain();v,p=as_bound(velocity),as_bound(pressure)
22
+ dependencies=[ROOT/'cpp/cuda_lhi.cu',Path(__file__),Path(__file__).with_name('kernel_compiler.py'),Path(__file__).with_name('symbolic_kernel.py')]
23
+ identity=dict(velocity=repr(v.family),pressure=repr(p.family),nvcc=nvcc,arch=arch,flags=['-O3','--fmad=false','-ccbin=g++-12'],sources={str(x.name):hashlib.sha256(x.read_bytes()).hexdigest() for x in dependencies})
24
+ key=hashlib.sha256(json.dumps(identity,sort_keys=True).encode()).hexdigest();root=Path(cache_dir) if cache_dir else Path.home()/'.cache/rbflab/cuda';folder=root/key
25
+ manifest=folder/'manifest.json'
26
+ if manifest.is_file():
27
+ stored=json.loads(manifest.read_text())
28
+ if not all((folder/n).is_file() and hashlib.sha256((folder/n).read_bytes()).hexdigest()==h for n,h in stored['files'].items()):raise RuntimeError('CUDA cache integrity failure')
29
+ return folder/'cuda_lhi'
30
+ if folder.exists():raise RuntimeError('Incomplete CUDA cache; select a fresh cache directory')
31
+ code=stokes_header(v.family,p.family)
32
+ code=re.sub(r'Real\("([^"\n]+)"\)',lambda m:'('+m.group(1)+'.0)' if re.fullmatch(r'-?\d+',m.group(1)) else '('+m.group(1)+')',code)
33
+ code=code.replace('std::vector<Real> params','const double* params').replace('const std::vector<Real>& params','const double* params')
34
+ code=re.sub(r'params.at\((\d+)\)',r'params[\1]',code).replace('Real','double').replace('inline double','__device__ inline double')
35
+ code=re.sub(r'throw std::runtime_error\("[^"\n]+"\);','return nan("");',code)
36
+ source=(ROOT/'cpp/cuda_lhi.cu').read_text().replace('// RBFLAB_GENERATED_SPACE',code)
37
+ root.mkdir(parents=True,exist_ok=True)
38
+ with tempfile.TemporaryDirectory(prefix='build-',dir=root) as tmp:
39
+ temp=Path(tmp);(temp/'kernel.cu').write_text(source)
40
+ path=_linux_path(temp)
41
+ result=subprocess.run(prefix+['nvcc','-O3','-std=c++17','--fmad=false','-ccbin','g++-12','-arch=sm_'+arch,path+'/kernel.cu','-lcublas','-o',path+'/cuda_lhi'],capture_output=True,text=True,timeout=300)
42
+ if result.returncode:raise RuntimeError('CUDA compile failed: '+result.stderr)
43
+ files={n:hashlib.sha256((temp/n).read_bytes()).hexdigest() for n in ('kernel.cu','cuda_lhi')}
44
+ (temp/'manifest.json').write_text(json.dumps(dict(identity=identity,files=files),indent=2));temp.rename(folder)
45
+ return folder/'cuda_lhi'
46
+
47
+ @dataclass(frozen=True)
48
+ class CudaLHIBackend:
49
+ """Feasibility backend: 2D, physical, unaugmented, Float64; no condition estimate."""
50
+ batch_size: int = 256
51
+ shape_rule: str = 'fixed'
52
+ cache_dir: str | None = None
53
+ timeout: float = 300
54
+ def __post_init__(self):
55
+ if type(self.batch_size) is not int or not 1<=self.batch_size<=4096:raise ValueError('batch_size must be 1..4096')
56
+ if self.shape_rule not in ('fixed','legacy_hardy'):raise ValueError('Unknown shape rule')
57
+ def assemble(self,method,problem,cloud):
58
+ begin=time.perf_counter()
59
+ if method.precision.local_digits is not None or method.precision.global_dtype!='float64':raise ValueError('CUDA prototype requires Float64 local/global arithmetic')
60
+ if cloud.dimension!=2 or method.stencil_policy.scaling!='physical' or method.polynomial_degree is not None:raise NotImplementedError('CUDA prototype requires unaugmented physical-coordinate 2D stencils')
61
+ pk=method.pressure_kernel or method.kernel
62
+ start=time.perf_counter();executable=compile_cuda(method.kernel,pk,self.cache_dir);compile_seconds=time.perf_counter()-start
63
+ a,tasks,stencils=_prepare(method,problem,cloud,self.shape_rule)
64
+ nv=len(as_bound(method.kernel).values);np_=len(as_bound(pk).values)
65
+ num=lambda v:format(float(v),'.17g')
66
+ lines=[f'{len(tasks)} {self.batch_size} {nv} {np_}']
67
+ for task in tasks:
68
+ lines.append(' '.join([str(len(task['points'])),num(task['mu']),*(num(v) for v in task['origin']),*(num(v) for v in as_bound(task['vk']).values),*(num(v) for v in as_bound(task['pk']).values)]))
69
+ lines.extend(' '.join([str(code),*(num(v) for v in point)]) for code,point in zip(task['codes'],task['points']))
70
+ with tempfile.TemporaryDirectory(prefix='rbflab_cuda_') as tmp:
71
+ folder=Path(tmp);source=folder/'input';target=folder/'output';source.write_text('\n'.join(lines)+'\n')
72
+ start=time.perf_counter();prefix=cuda_toolchain()[0]
73
+ runtime_prefix=prefix+['env','LD_LIBRARY_PATH=/usr/lib/wsl/lib'] if os.name=='nt' else prefix
74
+ result=subprocess.run([*runtime_prefix,_linux_path(executable),_linux_path(source),_linux_path(target)],capture_output=True,text=True,timeout=self.timeout);process=time.perf_counter()-start
75
+ if result.returncode:raise RuntimeError('CUDA local backend: '+result.stderr)
76
+ gpu=json.loads(result.stdout);results=[]
77
+ for i,line in enumerate(target.read_text().splitlines()):
78
+ items=line.split();n=len(tasks[i]['points'])
79
+ if int(items[0])!=i or int(items[1])!=n or len(items)!=4+4*n:raise RuntimeError('Invalid CUDA weight output')
80
+ if not all(np.isfinite(float(v)) for v in items[3:]):raise RuntimeError('Nonfinite CUDA output')
81
+ results.append(dict(condition=None,residual=items[3],weights=[items[4+k*n:4+(k+1)*n] for k in range(4)]))
82
+ if len(results)!=len(tasks):raise RuntimeError('Incomplete CUDA result')
83
+ system=_finish(method,problem,cloud,a,tasks,stencils,results,dict(backend='cuda_float64',compile_seconds=compile_seconds,cuda_process_seconds=process,gpu=gpu,batch_size=self.batch_size,local_digits=None,compute_condition=False,off_node_backend='lazy Python',shape_rule=self.shape_rule))
84
+ system.backend_diagnostics['assembly_seconds']=time.perf_counter()-begin
85
+ return system
rbflab/descriptor.py ADDED
@@ -0,0 +1,36 @@
1
+ """Small Float64 index-one descriptor spectrum diagnostic.
2
+
3
+ For M udot + A u=0, left-null(M) gives algebraic constraints C u=0.
4
+ Restrict u to null(C), project onto range(M), then solve the reduced pencil.
5
+ Rank thresholds make this a numerical diagnostic, not a formal DAE certificate.
6
+ """
7
+ import numpy as np
8
+ from scipy.linalg import eig,svd,null_space
9
+
10
+
11
+ def descriptor_spectrum(stiffness,mass,rtol=None):
12
+ a,m=np.asarray(stiffness),np.asarray(mass);n=len(m)
13
+ rtol=rtol or 100*n*np.finfo(float).eps
14
+ u,s,vh=svd(m);rank=int(np.sum(s>rtol*s[0])) if s[0]>0 else 0
15
+ info={'mass_rank':rank,'algebraic_modes':n-rank,'rank_relative_tolerance':rtol}
16
+ if rank==0:
17
+ if np.linalg.matrix_rank(a)<n:raise ValueError('Pure algebraic constraints are rank deficient')
18
+ return np.array([],dtype=complex),dict(info,eigenpair_relative_residual_max=None)
19
+ if rank==n:
20
+ values,vectors=eig(-a,m)
21
+ else:
22
+ constraints=u[:,rank:].T@a
23
+ z=null_space(constraints,rcond=rtol)
24
+ if z.shape[1]!=rank:
25
+ raise ValueError('Descriptor constraints are rank deficient; finite spectrum unresolved')
26
+ reduced_m=u[:,:rank].T@m@z;reduced_a=u[:,:rank].T@a@z
27
+ if np.linalg.matrix_rank(reduced_m,tol=rtol*np.linalg.norm(reduced_m,2))<rank:
28
+ raise ValueError('Descriptor has a singular reduced mass; higher-index spectrum unresolved')
29
+ values,w=eig(-reduced_a,reduced_m);vectors=z@w
30
+ residual=[]
31
+ norm_a,norm_m=np.linalg.norm(a,2),np.linalg.norm(m,2)
32
+ for value,vector in zip(values,vectors.T):
33
+ if not np.isfinite(value):continue
34
+ residual.append(float(np.linalg.norm(-a@vector-value*(m@vector))/(norm_a+abs(value)*norm_m)/np.linalg.norm(vector)))
35
+ info['eigenpair_relative_residual_max']=max(residual,default=None)
36
+ return values,info
rbflab/diagnostics.py ADDED
@@ -0,0 +1,56 @@
1
+ """Discrete nodal consistency and small-system stability diagnostics."""
2
+ import numpy as np
3
+
4
+
5
+ def nodal_diagnostics(system, exact_unknowns, *, condition_limit=200):
6
+ """Diagnose an assembled LHI/RBF-FD system, before reconstruction.
7
+
8
+ Pass exact values in matrix-column order: interior values for LHI, all
9
+ values for RBF-FD. This API does not accept global RBF coefficients.
10
+ Row normalization removes equation magnitude, not physical units.
11
+ A fixed alternating perturbation of the row-normalized RHS measures one
12
+ response direction, not worst-case amplification. The system is factored
13
+ lazily if necessary. Float64 small systems additionally report the row-scaled infinity-norm
14
+ condition; extended systems keep consistency arithmetic extended and omit
15
+ that Float64 condition estimate. No claim of a stability certificate.
16
+ """
17
+ matrix=system.matrix
18
+ if hasattr(matrix,"matvec"):
19
+ ctx=matrix.ctx
20
+ from .precision import mp_number
21
+ exact=ctx.matrix([mp_number(ctx,v) for v in exact_unknowns])
22
+ if any(not ctx.isfinite(v) for v in exact):
23
+ raise ValueError("Exact values must be finite")
24
+ if len(exact)!=len(system.rhs):
25
+ raise ValueError("Expected one exact value per unknown")
26
+ defect=matrix.matvec(exact)-system.rhs
27
+ norm=lambda x:ctx.norm(x,"inf")
28
+ scales=[max((abs(v) for v in row.values()),default=ctx.zero) for row in matrix.rows]
29
+ if any(v==0 for v in scales):
30
+ raise ValueError("Cannot normalize an empty equation")
31
+ from .sparse_precision import MPSparseLU
32
+ factor=system.factor or MPSparseLU(matrix)
33
+ response=factor.solve(ctx.matrix([scale*(-1)**i for i,scale in enumerate(scales)]))
34
+ normalized=ctx.matrix([defect[i]/scale for i,scale in enumerate(scales)])
35
+ return {"consistency_inf":float(norm(defect)),
36
+ "row_scaled_consistency_inf":float(norm(normalized)),
37
+ "consistency_decimal":ctx.nstr(norm(defect),16),
38
+ "digits":ctx.dps,"row_scaled_condition_inf":None,
39
+ "alternating_rhs_gain_inf":float(norm(response))}
40
+ exact=np.asarray(exact_unknowns,dtype=float)
41
+ if exact.shape!=(matrix.shape[1],) or not np.isfinite(exact).all():
42
+ raise ValueError("Expected one finite exact value per unknown")
43
+ defect=matrix@exact-system.rhs
44
+ scales=np.asarray(abs(matrix).max(axis=1).toarray()).ravel()
45
+ if np.any(scales==0):
46
+ raise ValueError("Cannot normalize an empty equation")
47
+ from scipy.sparse.linalg import splu
48
+ factor=system.factor or splu(matrix)
49
+ response=factor.solve(scales*(-1.)**np.arange(len(scales)))
50
+ condition=None
51
+ if matrix.shape[0]<=condition_limit:
52
+ condition=float(np.linalg.cond(matrix.toarray()/scales[:,None],np.inf))
53
+ return {"consistency_inf":float(np.max(abs(defect))),
54
+ "row_scaled_consistency_inf":float(np.max(abs(defect)/scales)),
55
+ "digits":None,"row_scaled_condition_inf":condition,
56
+ "alternating_rhs_gain_inf":float(np.max(abs(response)))}