step2torque 0.1.0__cp313-cp313-win_amd64.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 (45) hide show
  1. step2torque/__init__.py +28 -0
  2. step2torque/_assembly.cp313-win_amd64.pyd +0 -0
  3. step2torque/_assembly.cpp +161 -0
  4. step2torque/_assembly_version.py +1 -0
  5. step2torque/_contact.py +289 -0
  6. step2torque/_cuda.py +110 -0
  7. step2torque/_currents.py +98 -0
  8. step2torque/_field.py +263 -0
  9. step2torque/_kernel.py +117 -0
  10. step2torque/_mesh.py +435 -0
  11. step2torque/_motion.py +76 -0
  12. step2torque/_parallel.hpp +59 -0
  13. step2torque/_results.py +68 -0
  14. step2torque/_source.py +175 -0
  15. step2torque/_torque.py +402 -0
  16. step2torque/_worker.py +345 -0
  17. step2torque/materials/1j22.csv +8 -0
  18. step2torque/materials/__init__.py +51 -0
  19. step2torque/materials/dt4.csv +14 -0
  20. step2torque/materials/kede_1j21.csv +11 -0
  21. step2torque/materials/rotor.csv +14 -0
  22. step2torque/materials/sources.json +15 -0
  23. step2torque/model.py +271 -0
  24. step2torque/solution.py +307 -0
  25. step2torque/visualization/__init__.py +6 -0
  26. step2torque/visualization/__main__.py +64 -0
  27. step2torque/visualization/appearance.py +52 -0
  28. step2torque/visualization/archive.py +177 -0
  29. step2torque/visualization/html.py +43 -0
  30. step2torque/visualization/lines.py +140 -0
  31. step2torque/visualization/native.py +325 -0
  32. step2torque/visualization/packed.py +122 -0
  33. step2torque/visualization/plane.py +74 -0
  34. step2torque/visualization/setup.py +290 -0
  35. step2torque/visualization/viewer.html +42 -0
  36. step2torque/visualization/viewer.js +3915 -0
  37. step2torque/visualization/viewer.py +355 -0
  38. step2torque-0.1.0.dist-info/DELVEWHEEL +2 -0
  39. step2torque-0.1.0.dist-info/METADATA +87 -0
  40. step2torque-0.1.0.dist-info/RECORD +45 -0
  41. step2torque-0.1.0.dist-info/WHEEL +5 -0
  42. step2torque-0.1.0.dist-info/entry_points.txt +2 -0
  43. step2torque-0.1.0.dist-info/licenses/LICENSE +22 -0
  44. step2torque-0.1.0.dist-info/top_level.txt +1 -0
  45. step2torque.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
@@ -0,0 +1,28 @@
1
+ """Load a STEP, configure its components, and solve without blocking.
2
+
3
+ motor = Motor("motor.step")
4
+ motor.components["coil"].make_coil(phase=1, turns=72, direction="+z")
5
+ motor.components["magnet"].make_magnet(direction="radial")
6
+ solution = motor.solve(current="optimized", power=5.5)
7
+ solution.wait()
8
+ print(solution.torque) # N m
9
+ """
10
+
11
+
12
+ # start delvewheel patch
13
+ def _delvewheel_patch_1_13_2():
14
+ import os
15
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'step2torque.libs'))):
16
+ os.add_dll_directory(libs_dir)
17
+
18
+
19
+ _delvewheel_patch_1_13_2()
20
+ del _delvewheel_patch_1_13_2
21
+ # end delvewheel patch
22
+
23
+ from .model import Motor
24
+ from .solution import Solution, solve_position
25
+ from .visualization import Plane, Viewer
26
+
27
+ __all__ = ["Motor", "Solution", "solve_position", "Plane", "Viewer"]
28
+ __version__ = "0.1.0"
@@ -0,0 +1,161 @@
1
+ // Same affine P2, degree-6 iron quadrature and B(H) energy as _field.py.
2
+ #include <pybind11/pybind11.h>
3
+ #include <pybind11/numpy.h>
4
+ #include "_parallel.hpp"
5
+ #include <array>
6
+ #include <cmath>
7
+ #include <cstdint>
8
+ #include <stdexcept>
9
+ namespace py = pybind11;
10
+ using Real = py::array_t<double, py::array::c_style>;
11
+ using Int = py::array_t<int, py::array::c_style | py::array::forcecast>;
12
+ using Index = py::array_t<int64_t, py::array::c_style | py::array::forcecast>;
13
+ struct Curve {
14
+ std::vector<double> h,b,slope,area;
15
+ std::array<double,3> evaluate(double value) const {
16
+ int i=std::upper_bound(h.begin(),h.end(),value)-h.begin()-1;
17
+ i=std::max(0,std::min(i,int(h.size())-2));
18
+ double t=value-h[i];
19
+ return {b[i]+t*slope[i],slope[i],area[i]+t*(b[i]+.5*t*slope[i])};
20
+ }
21
+ };
22
+ class Assembly {
23
+ int n,ne,nq;
24
+ int64_t nnz;
25
+ std::vector<int64_t> ptr,entries,cell_ptr,cell_entries;
26
+ std::vector<int> col,dofs,curve;
27
+ std::vector<double> inverse,volume,reference,weights,points,source;
28
+ std::vector<double> local_matrix,local_load,local_energy;
29
+ std::vector<Curve> curves;
30
+ Parallel pool;
31
+ const double *potential=nullptr;
32
+ double *matrix=nullptr,*residual=nullptr;
33
+ double factor=1,floor=.1;
34
+ public:
35
+ Assembly(Index pointers,Int columns,Int indices,Int labels,Real jac,
36
+ Real volumes,Real shapes,Real quadrature,Real locations,
37
+ py::list laws,int threads)
38
+ : n(pointers.size()-1),ne(labels.size()),nq(quadrature.size()),nnz(columns.size()),
39
+ ptr(pointers.data(),pointers.data()+pointers.size()),
40
+ col(columns.data(),columns.data()+columns.size()),
41
+ dofs(indices.data(),indices.data()+indices.size()),
42
+ curve(labels.data(),labels.data()+labels.size()),
43
+ inverse(jac.data(),jac.data()+jac.size()),
44
+ volume(volumes.data(),volumes.data()+volumes.size()),
45
+ reference(shapes.data(),shapes.data()+shapes.size()),
46
+ weights(quadrature.data(),quadrature.data()+quadrature.size()),
47
+ points(locations.data(),locations.data()+locations.size()),
48
+ source(size_t(ne)*12),local_matrix(size_t(ne)*55),local_load(size_t(ne)*10),
49
+ local_energy(ne),pool(threads) {
50
+ if(indices.size()!=int64_t(ne)*10 || shapes.size()!=nq*30 || jac.size()!=int64_t(ne)*9)
51
+ throw std::runtime_error("Compiled assembly requires the affine P2 tetrahedron map");
52
+ for(auto record:laws) {
53
+ py::tuple pair=py::cast<py::tuple>(record);
54
+ Real h=py::cast<Real>(pair[0]),b=py::cast<Real>(pair[1]);
55
+ Curve c;
56
+ c.h.assign(h.data(),h.data()+h.size());c.b.assign(b.data(),b.data()+b.size());
57
+ c.slope.resize(h.size()-1);c.area.assign(h.size(),0);
58
+ for(int i=0;i<h.size()-1;++i) {
59
+ c.slope[i]=(c.b[i+1]-c.b[i])/(c.h[i+1]-c.h[i]);
60
+ c.area[i+1]=c.area[i]+.5*(c.b[i]+c.b[i+1])*(c.h[i+1]-c.h[i]);
61
+ }
62
+ curves.push_back(std::move(c));
63
+ }
64
+ entries.resize(size_t(ne)*100);cell_ptr.assign(n+1,0);cell_entries.resize(size_t(ne)*10);
65
+ for(int row:dofs) {
66
+ if(row<0 || row>=n)throw std::runtime_error("Invalid P2 degree of freedom");
67
+ ++cell_ptr[row+1];
68
+ }
69
+ for(int i=1;i<=n;++i)cell_ptr[i]+=cell_ptr[i-1];
70
+ auto cursor=cell_ptr;
71
+ for(int64_t i=0;i<int64_t(ne)*10;++i)cell_entries[cursor[dofs[i]]++]=i;
72
+ for(int e=0;e<ne;++e)for(int i=0;i<10;++i)for(int j=0;j<10;++j) {
73
+ int row=dofs[e*10+i],column=dofs[e*10+j];
74
+ auto it=std::lower_bound(col.begin()+ptr[row],col.begin()+ptr[row+1],column);
75
+ if(it==col.begin()+ptr[row+1] || *it!=column)
76
+ throw std::runtime_error("Tangent graph differs from finite element map");
77
+ entries[int64_t(e)*100+i*10+j]=it-col.begin();
78
+ }
79
+ }
80
+ void set_source(Real values) {
81
+ if(values.size()!=int64_t(ne)*12)throw std::runtime_error("Winding source differs from mesh");
82
+ std::copy(values.data(),values.data()+values.size(),source.begin());
83
+ }
84
+ void assemble(Real phi,double scale,double lower,Real values,Real load) {
85
+ potential=phi.data();factor=scale;floor=lower;
86
+ if(phi.size()!=n || values.size()!=nnz || load.size()!=n)
87
+ throw std::runtime_error("Tangent arrays differ from finite element map");
88
+ matrix=values.mutable_data();residual=load.mutable_data();
89
+ py::gil_scoped_release release;
90
+ integrate(true);
91
+ }
92
+ double energy(Real phi,double scale) {
93
+ potential=phi.data();factor=scale;
94
+ py::gil_scoped_release release;
95
+ return integrate(false);
96
+ }
97
+ private:
98
+ double integrate(bool assemble) {
99
+ if(assemble) {std::fill(matrix,matrix+nnz,0);std::fill(residual,residual+n,0);}
100
+ pool.run(ne,[&](int begin,int end,int){for(int e=begin;e<end;++e) {
101
+ const double *jac=inverse.data()+9*e,*hs=source.data()+12*e;
102
+ const int *ids=dofs.data()+10*e;
103
+ const Curve &law=curves[curve[e]];
104
+ double local[55]={},load[10]={},cell_energy=0;
105
+ double offset=law.evaluate(.0001)[2];
106
+ for(int q=0;q<nq;++q) {
107
+ const double *ref=reference.data()+30*q,*point=points.data()+3*q;
108
+ double gradient[10][3],h[3],s[3];
109
+ for(int c=0;c<3;++c) {
110
+ s[c]=factor*(hs[c]+point[0]*(hs[3+c]-hs[c])+point[1]*(hs[6+c]-hs[c])+point[2]*(hs[9+c]-hs[c]));
111
+ h[c]=s[c];
112
+ }
113
+ for(int i=0;i<10;++i) for(int c=0;c<3;++c) {
114
+ gradient[i][c]=ref[3*i]*jac[c]+ref[3*i+1]*jac[3+c]+ref[3*i+2]*jac[6+c];
115
+ h[c]-=potential[ids[i]]*gradient[i][c];
116
+ }
117
+ double norm2=h[0]*h[0]+h[1]*h[1]+h[2]*h[2]+1e-8;
118
+ double hn=std::sqrt(norm2),w=volume[e]*weights[q];
119
+ auto bh=law.evaluate(hn);
120
+ constexpr double mu0=1.2566370614359173e-6;
121
+ cell_energy+=w*(bh[2]-offset-.5*mu0*(s[0]*s[0]+s[1]*s[1]+s[2]*s[2]));
122
+ if(!assemble) continue;
123
+ double secant=bh[0]/hn;
124
+ double anisotropic=(std::max(bh[1],floor*secant)-secant)/norm2;
125
+ double hg[10];
126
+ for(int i=0;i<10;++i) {
127
+ hg[i]=h[0]*gradient[i][0]+h[1]*gradient[i][1]+h[2]*gradient[i][2];
128
+ load[i]+=w*secant*hg[i];
129
+ }
130
+ for(int i=0;i<10;++i) for(int j=0;j<=i;++j) {
131
+ double gram=gradient[i][0]*gradient[j][0]+gradient[i][1]*gradient[j][1]+gradient[i][2]*gradient[j][2];
132
+ local[i*(i+1)/2+j]+=w*(secant*gram+anisotropic*hg[i]*hg[j]);
133
+ }
134
+ }
135
+ local_energy[e]=cell_energy;
136
+ if(assemble) {
137
+ std::copy(local,local+55,local_matrix.begin()+e*55);
138
+ std::copy(load,load+10,local_load.begin()+e*10);
139
+ }
140
+ }});
141
+ if(assemble)pool.run(n,[&](int begin,int end,int){for(int row=begin;row<end;++row) {
142
+ for(int64_t k=cell_ptr[row];k<cell_ptr[row+1];++k) {
143
+ int64_t entry=cell_entries[k];int e=entry/10,i=entry%10;
144
+ residual[row]+=local_load[entry];
145
+ for(int j=0;j<10;++j) {
146
+ int a=std::max(i,j),b=std::min(i,j);
147
+ matrix[entries[int64_t(e)*100+i*10+j]]+=local_matrix[e*55+a*(a+1)/2+b];
148
+ }
149
+ }
150
+ }});
151
+ double energy=0;for(double value:local_energy)energy+=value;
152
+ return energy;
153
+ }
154
+ };
155
+ PYBIND11_MODULE(_assembly,m) {
156
+ py::class_<Assembly>(m,"Assembly")
157
+ .def(py::init<Index,Int,Int,Int,Real,Real,Real,Real,Real,py::list,int>())
158
+ .def("set_source",&Assembly::set_source)
159
+ .def("assemble",&Assembly::assemble)
160
+ .def("energy",&Assembly::energy);
161
+ }
@@ -0,0 +1 @@
1
+ SHA256 = 'e14b65543e07120fb0dabd8dddab806d8618979dda1f412211678ea15706bd02'
@@ -0,0 +1,289 @@
1
+ """Zero-thickness air-gap contact: B_n = (mu0 / gap) * [phi]."""
2
+
3
+ import ngsolve as ng
4
+ import numpy as np
5
+ import pyngcore as core
6
+ import netgen.meshing as nm
7
+ from scipy.sparse import coo_matrix
8
+ from scipy.sparse.csgraph import connected_components
9
+
10
+ from .materials import MU0
11
+
12
+
13
+ def add_contact(mesh, space, matrix, contact):
14
+ """Add the exact P2 surface mass matrix on paired, coincident traces.
15
+
16
+ The mesh is continuous away from the contact. Its physical contact rim
17
+ shares vertex/edge DOFs, so the jump vanishes at the rim. Rotational
18
+ sector cuts retain independent, periodically identified contact traces.
19
+ This is a physical Robin term, with no penalty or stabilization factor.
20
+ """
21
+ gap = float(contact["gap"])
22
+ if gap <= 0:
23
+ raise ValueError("Contact gap must be positive")
24
+ points = np.asarray(mesh.ngmesh.Coordinates())
25
+ cells = mesh.ngmesh.Elements3D().NumPy()["nodes"] - 1
26
+ rule = ng.IntegrationRule(ng.ET.TRIG, 4)
27
+ q = np.array([p.point[:2] for p in rule])
28
+ weights = np.array([p.weight for p in rule])
29
+ rows, columns, entries = [], [], []
30
+ for face in contact["faces"]:
31
+ triangle = points[face["vertices"]]
32
+ locations = (
33
+ triangle[0]
34
+ + q[:, :1] * (triangle[1] - triangle[0])
35
+ + q[:, 1:] * (triangle[2] - triangle[0])
36
+ )
37
+ jacobian = np.linalg.norm(
38
+ np.cross(triangle[1] - triangle[0], triangle[2] - triangle[0])
39
+ )
40
+ traces, dofs = [], []
41
+ for side, sign in (("minus_cell", 1), ("plus_cell", -1)):
42
+ cell = int(face[side])
43
+ element = ng.ElementId(ng.VOL, cell)
44
+ xyz = points[cells[cell]]
45
+ reference = np.linalg.solve((xyz[:3] - xyz[3]).T, (locations - xyz[3]).T).T
46
+ fe = space.GetFE(element)
47
+ shape = np.array([fe.CalcShape(*p) for p in reference])
48
+ shape[abs(shape) < 1e-12] = 0
49
+ traces.append(sign * shape)
50
+ dofs.extend(space.GetDofNrs(element))
51
+ unique, inverse = np.unique(dofs, return_inverse=True)
52
+ jump = np.zeros((len(rule), len(unique)))
53
+ for j, values in enumerate(np.concatenate(traces, axis=1).T):
54
+ jump[:, inverse[j]] += values
55
+ local = MU0 / gap * jacobian * (jump.T * weights) @ jump
56
+ i, j = np.nonzero(local)
57
+ rows.extend(unique[i])
58
+ columns.extend(unique[j])
59
+ entries.extend(local[i, j])
60
+ values, cols, ptr = (np.asarray(x) for x in matrix.CSR())
61
+ volume_rows = np.repeat(np.arange(matrix.height), np.diff(ptr).astype(np.int64))
62
+ # Fill native arrays directly; Python conversion of the full volume graph
63
+ # would cost more than assembling the contact itself.
64
+ count = len(values)
65
+ i, j = core.Array_I_S(count + len(rows)), core.Array_I_S(count + len(rows))
66
+ data = core.Array_D_S(count + len(rows))
67
+ i.NumPy()[:count], i.NumPy()[count:] = volume_rows, rows
68
+ j.NumPy()[:count], j.NumPy()[count:] = cols, columns
69
+ data.NumPy()[:count], data.NumPy()[count:] = values, entries
70
+ return ng.la.SparseMatrixd.CreateFromCOO(i, j, data, matrix.height, matrix.width)
71
+
72
+
73
+ def split(mesh, gap, materials, moving=()):
74
+ """Separate potential traces on actual, conforming iron mating faces."""
75
+ if not gap:
76
+ return mesh, None
77
+ names = mesh.GetMaterials()
78
+ iron = {
79
+ name
80
+ for name, law in materials.items()
81
+ if "h" in law or ("br" not in law and law.get("mur", 1) > 1)
82
+ }
83
+ descriptors = [nm.FaceDescriptor(fd) for fd in mesh.ngmesh.FaceDescriptors()]
84
+ selected = {}
85
+ for i, fd in enumerate(descriptors, 1):
86
+ if not fd.domin or not fd.domout:
87
+ continue
88
+ a, b = names[fd.domin - 1], names[fd.domout - 1]
89
+ if a != b and a in iron and b in iron and (a in moving) == (b in moving):
90
+ selected[i] = tuple(sorted((a, b)))
91
+ if not selected:
92
+ return mesh, None
93
+ tets = mesh.ngmesh.Elements3D().NumPy()["nodes"] - 1
94
+ boundary = mesh.ngmesh.Elements2D().NumPy().copy()
95
+ triangles, indices = boundary["nodes"] - 1, boundary["index"]
96
+ contact = np.isin(indices, list(selected))
97
+ local = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]])
98
+ faces = tets[:, local].reshape(-1, 3)
99
+ corners = np.argsort(faces, axis=1)
100
+ occurrences = (4 * np.arange(mesh.ne)[:, None, None] + local).reshape(-1, 3)
101
+ occurrences = np.take_along_axis(occurrences, corners, axis=1)
102
+ keys = face_keys(faces)
103
+ order = np.argsort(keys)
104
+ keys, occurrences = keys[order], occurrences[order]
105
+ positions = np.searchsorted(keys, face_keys(triangles))
106
+ if not np.all(keys[positions] == face_keys(triangles)):
107
+ raise ValueError("Contact mesh has an unmatched surface face")
108
+ same = keys[1:] == keys[:-1]
109
+ if np.any(same[:-1] & same[1:]):
110
+ raise ValueError("Contact mesh has a nonmanifold face")
111
+ paired = np.flatnonzero(same)
112
+ cut = positions[contact]
113
+ if not np.all(same[cut]):
114
+ raise ValueError("Contact must have iron on both sides")
115
+ joined = paired[~np.isin(paired, cut)]
116
+ a, b = occurrences[joined].ravel(), occurrences[joined + 1].ravel()
117
+ graph = coo_matrix(
118
+ (np.ones(len(a), dtype=np.int8), (a, b)), shape=(4 * mesh.ne, 4 * mesh.ne)
119
+ ).tocsr()
120
+ _, labels = connected_components(graph, directed=False)
121
+ _, first = np.unique(labels, return_index=True)
122
+ nodes = tets.ravel()[first]
123
+ _, original = np.unique(nodes, return_index=True)
124
+ copies = np.isin(nodes, triangles[contact])
125
+ copies[original] = False
126
+ points = np.asarray(mesh.ngmesh.Coordinates())
127
+ new_points = np.concatenate([points, points[nodes[copies]]])
128
+ numbers = nodes.copy()
129
+ numbers[copies] = mesh.nv + np.arange(copies.sum())
130
+ new_tets = numbers[labels].reshape(-1, 4)
131
+
132
+ owners = order[positions] // 4
133
+
134
+ def trace(cell, vertices):
135
+ return new_tets[cell, np.argmax(tets[cell, :, None] == vertices, axis=0)]
136
+
137
+ new_triangles = np.array([trace(ci, tri) for ci, tri in zip(owners, triangles)])
138
+ # Interior contact edges need independent P2 edge DOFs even when both
139
+ # endpoints lie on the physical rim. One conforming bisection supplies them.
140
+ edges, tied = {}, set()
141
+ for si in np.flatnonzero(contact):
142
+ pos = positions[si]
143
+ lo, hi = order[pos] // 4, order[pos + 1] // 4
144
+ tri = triangles[si]
145
+ equal = trace(lo, tri) == trace(hi, tri)
146
+ for i, j in ((0, 1), (0, 2), (1, 2)):
147
+ edge = tuple(sorted((int(tri[i]), int(tri[j]))))
148
+ key = selected[int(indices[si])], edge
149
+ edges[key] = edges.get(key, 0) + 1
150
+ if equal[i] and equal[j]:
151
+ tied.add(key)
152
+ refine = sorted({edge for pair, edge in tied if edges[pair, edge] == 2})
153
+ if refine:
154
+ return split(bisect(mesh, refine), gap, materials, moving)
155
+
156
+ interfaces, pairs, records = [], {}, []
157
+ extra_triangles, extra_indices = [], []
158
+ domains = mesh.ngmesh.Elements3D().NumPy()["index"]
159
+ for index, pair in selected.items():
160
+ if pair not in pairs:
161
+ pairs[pair] = len(interfaces)
162
+ interfaces.append(dict(parts=list(pair), area_m2=0.0))
163
+ fd = descriptors[index - 1]
164
+ other = nm.FaceDescriptor(fd)
165
+ other.domin, other.domout = fd.domout, 0
166
+ fd.domout = 0
167
+ k = pairs[pair]
168
+ fd.bcname = f"contact_{k}_minus"
169
+ other.bc = len(descriptors) + 1
170
+ other.bcname = f"contact_{k}_plus"
171
+ descriptors.append(other)
172
+ for si in np.flatnonzero(indices == index):
173
+ pos = positions[si]
174
+ lo, hi = int(order[pos] // 4), int(order[pos + 1] // 4)
175
+ if domains[lo] != fd.domin:
176
+ lo, hi = hi, lo
177
+ tri = triangles[si]
178
+ lower, upper = trace(lo, tri), trace(hi, tri)
179
+ new_triangles[si] = lower
180
+ extra_triangles.append(upper)
181
+ extra_indices.append(len(descriptors))
182
+ xyz = points[tri]
183
+ interfaces[k]["area_m2"] += float(
184
+ np.linalg.norm(np.cross(xyz[1] - xyz[0], xyz[2] - xyz[0])) / 2
185
+ )
186
+ records.append(
187
+ dict(
188
+ interface=k,
189
+ minus_cell=lo,
190
+ plus_cell=hi,
191
+ vertices=lower.tolist(),
192
+ plus_vertices=upper.tolist(),
193
+ )
194
+ )
195
+ # Match each periodic boundary triangle's two traces independently. This
196
+ # works for rotational or translated sectors without assuming a face normal.
197
+ surface_lookup = {tuple(sorted(tri)): i for i, tri in enumerate(triangles)}
198
+ identifications = set()
199
+ groups = {}
200
+ for a, b, ident in mesh.ngmesh.GetIdentifications():
201
+ groups.setdefault(ident, {})[a.nr - 1] = b.nr - 1
202
+ for ident, mapping in groups.items():
203
+ for si, tri in enumerate(triangles):
204
+ target = [mapping.get(int(i), -1) for i in tri]
205
+ other = surface_lookup.get(tuple(sorted(target)))
206
+ if other is None:
207
+ continue
208
+ for j, node in enumerate(target):
209
+ k = list(triangles[other]).index(node)
210
+ identifications.add(
211
+ (int(new_triangles[si, j]), int(new_triangles[other, k]), ident)
212
+ )
213
+ native = rebuild(
214
+ mesh,
215
+ new_points,
216
+ new_tets,
217
+ domains,
218
+ np.concatenate([new_triangles, extra_triangles]),
219
+ np.r_[indices, extra_indices],
220
+ descriptors,
221
+ identifications,
222
+ )
223
+ return native, dict(gap=gap, faces=records, interfaces=interfaces)
224
+
225
+
226
+ def face_keys(vertices):
227
+ dtype = np.dtype([(name, np.int32) for name in ("a", "b", "c")])
228
+ return (
229
+ np.ascontiguousarray(np.sort(vertices, axis=1), dtype=np.int32)
230
+ .view(dtype)
231
+ .ravel()
232
+ )
233
+
234
+
235
+ def rebuild(
236
+ mesh, points, tets, domains, triangles, indices, descriptors, identifications
237
+ ):
238
+ native = nm.Mesh(dim=3)
239
+ for fd in descriptors:
240
+ native.Add(fd)
241
+ native.SetBCName(fd.bc - 1, fd.bcname)
242
+ for p in points:
243
+ native.Add(nm.MeshPoint(nm.Pnt(*p)))
244
+ for tet, domain in zip(tets, domains):
245
+ native.Add(nm.Element3D(index=int(domain), vertices=(tet + 1).tolist()))
246
+ for tri, index in zip(triangles, indices):
247
+ native.Add(nm.Element2D(index=int(index), vertices=(tri + 1).tolist()))
248
+ for i, name in enumerate(mesh.GetMaterials(), 1):
249
+ native.SetMaterial(i, name)
250
+ for a, b, ident in sorted(identifications):
251
+ native.AddPointIdentification(
252
+ a + 1, b + 1, ident, mesh.ngmesh.GetIdentificationType(ident)
253
+ )
254
+ return ng.Mesh(native)
255
+
256
+
257
+ def bisect(mesh, edges):
258
+ points = np.asarray(mesh.ngmesh.Coordinates()).tolist()
259
+ cells = mesh.ngmesh.Elements3D().NumPy()
260
+ tets, domains = cells["nodes"] - 1, cells["index"].copy()
261
+ surfaces = mesh.ngmesh.Elements2D().NumPy()
262
+ triangles, indices = surfaces["nodes"] - 1, surfaces["index"].copy()
263
+ for a, b in edges:
264
+ c = len(points)
265
+ points.append(((np.array(points[a]) + points[b]) / 2).tolist())
266
+ for volume in (True, False):
267
+ nodes, labels = (tets, domains) if volume else (triangles, indices)
268
+ hit = np.any(nodes == a, axis=1) & np.any(nodes == b, axis=1)
269
+ first, second = nodes[hit].copy(), nodes[hit].copy()
270
+ first[first == a], second[second == b] = c, c
271
+ nodes = np.concatenate([nodes[~hit], first, second])
272
+ labels = np.r_[labels[~hit], labels[hit], labels[hit]]
273
+ if volume:
274
+ tets, domains = nodes, labels
275
+ else:
276
+ triangles, indices = nodes, labels
277
+ identifications = {
278
+ (a.nr - 1, b.nr - 1, ident) for a, b, ident in mesh.ngmesh.GetIdentifications()
279
+ }
280
+ return rebuild(
281
+ mesh,
282
+ points,
283
+ tets,
284
+ domains,
285
+ triangles,
286
+ indices,
287
+ [nm.FaceDescriptor(fd) for fd in mesh.ngmesh.FaceDescriptors()],
288
+ identifications,
289
+ )
step2torque/_cuda.py ADDED
@@ -0,0 +1,110 @@
1
+ """Stateful cuDSS Cholesky for real NGSolve matrices with fixed sparsity."""
2
+
3
+ import importlib.metadata as metadata
4
+ import logging
5
+ import time
6
+ import weakref
7
+
8
+ import numpy as np
9
+ import scipy.sparse as sp
10
+
11
+
12
+ class CuDSS:
13
+ def __init__(self, matrix, free_dofs):
14
+ import nvmath.sparse.advanced as sparse
15
+
16
+ self.matrix = matrix
17
+ self.free = np.flatnonzero(np.asarray(free_dofs, dtype=bool))
18
+ self.rhs = np.zeros(len(self.free))
19
+ self.solver = None
20
+ self.sparse = sparse
21
+ self.stats = dict(
22
+ export_s=0.0,
23
+ setup_s=0.0,
24
+ factor_s=0.0,
25
+ solve_s=0.0,
26
+ factorizations=0,
27
+ solves=0,
28
+ )
29
+ self.Update()
30
+
31
+ def Update(self):
32
+ started = time.perf_counter()
33
+ values, columns, pointers = (np.asarray(x) for x in self.matrix.CSR())
34
+ if self.solver is None:
35
+ rows = np.repeat(
36
+ np.arange(self.matrix.height), np.diff(pointers).astype(np.int64)
37
+ )
38
+ mapping = np.full(self.matrix.height, -1, dtype=np.int32)
39
+ mapping[self.free] = np.arange(len(self.free), dtype=np.int32)
40
+ active = (mapping[rows] >= 0) & (mapping[columns] >= 0)
41
+ if np.any(active & (rows > columns)) and not np.any(
42
+ active & (rows < columns)
43
+ ):
44
+ raise ValueError("NGSolve CSR must expose the full symmetric matrix.")
45
+ self.entries = np.flatnonzero(active & (rows <= columns))
46
+ r, c = mapping[rows[self.entries]], mapping[columns[self.entries]]
47
+ assert np.count_nonzero(r == c) == len(self.free)
48
+ order = np.lexsort((c, r))
49
+ self.entries = self.entries[order]
50
+ indptr = np.r_[
51
+ 0, np.cumsum(np.bincount(r, minlength=len(self.free)), dtype=np.int64)
52
+ ]
53
+ assert indptr[-1] < 2**31
54
+ self.upper = sp.csr_matrix(
55
+ (values[self.entries].copy(), c[order], indptr.astype(np.int32)),
56
+ shape=(len(self.free), len(self.free)),
57
+ )
58
+ else:
59
+ # CSR values share NGSolve storage; the sparsity map stays fixed.
60
+ self.upper.data[:] = values[self.entries]
61
+ self.stats["last_export_s"] = time.perf_counter() - started
62
+ self.stats["export_s"] += self.stats["last_export_s"]
63
+ if self.solver is None:
64
+ library = metadata.distribution("nvidia-cudss-cu12")
65
+ threading = [
66
+ library.locate_file(path)
67
+ for path in library.files
68
+ if str(path).endswith("libcudss_mtlayer_gomp.so.0")
69
+ ]
70
+ logger = logging.Logger("cudss", level=logging.ERROR)
71
+ options = dict(
72
+ sparse_system_type=self.sparse.DirectSolverMatrixType.SPD,
73
+ sparse_system_view=self.sparse.DirectSolverMatrixViewType.UPPER,
74
+ blocking=True,
75
+ logger=logger,
76
+ )
77
+ if threading:
78
+ options["multithreading_lib"] = str(threading[0])
79
+ started = time.perf_counter()
80
+ self.solver = self.sparse.DirectSolver(
81
+ self.upper, self.rhs, options=options, execution="cuda"
82
+ )
83
+ # The native object requires explicit cleanup, including after the last pose.
84
+ self.cleanup = weakref.finalize(self, self.solver.free)
85
+ self.solver.plan()
86
+ self.stats["setup_s"] += time.perf_counter() - started
87
+ else:
88
+ self.solver.reset_operands(a=self.upper)
89
+ started = time.perf_counter()
90
+ self.solver.factorize()
91
+ self.stats["factor_s"] += time.perf_counter() - started
92
+ self.stats["factorizations"] += 1
93
+
94
+ def __mul__(self, rhs):
95
+ started = time.perf_counter()
96
+ self.rhs[:] = rhs.FV().NumPy()[self.free]
97
+ self.solver.reset_operands(b=self.rhs)
98
+ values = self.solver.solve()
99
+ if not np.isfinite(values).all():
100
+ raise RuntimeError("CUDA linear solve returned a non-finite field")
101
+ answer = self.matrix.CreateColVector()
102
+ array = answer.FV().NumPy()
103
+ array[:] = 0
104
+ array[self.free] = values
105
+ self.stats["solve_s"] += time.perf_counter() - started
106
+ self.stats["solves"] += 1
107
+ return answer
108
+
109
+ def close(self):
110
+ self.cleanup()