peclet.voro 1.0.0
Device-native moving-particle Voronoi dynamics
Loading...
Searching...
No Matches
mesh_optimizer.hpp
Go to the documentation of this file.
1
20#ifndef PECLET_VORO_MESH_OPTIMIZER_HPP
21#define PECLET_VORO_MESH_OPTIMIZER_HPP
22
23#include <algorithm>
24#include <array>
25#include <cmath>
26#include <cstdio>
27#include <Kokkos_Core.hpp>
28#include <limits>
29#include <numeric>
30#include <type_traits>
31#include <unordered_map>
32#include <vector>
33
34#include "peclet/core/amr/momentum.hpp" // greedyColoring
35#include "peclet/core/solver/graph_amg.hpp" // smoothed-aggregation AMG (O(N) CG preconditioner)
36#include "peclet/voro/ot_optimizer.hpp" // OtResult + detail::toHostVec/toHostVecT
37#include "peclet/voro/sdf.hpp"
39
40namespace peclet::voro {
41
42// CG preconditioner for the Gauss-Newton Hessian solve. Jacobi = O(N^4/3) (iterations grow with N);
43// ColoredGS = a stronger single-level smoother; GraphAMG = smoothed-aggregation multigrid, the only
44// one whose iteration count stays mesh-independent ⇒ an O(N) solve at large N (see
45// peclet::core::solver::GraphAMG).
47
62template <class Real, bool Weighted = false, class Sdf = NoSdf>
63OtResult meshVolumeOptimize(std::vector<Real>& pos, std::vector<Real>& weight,
64 const std::vector<Real>& vsetIn, const Real L[3], int N, int sw,
65 const Sdf& sdf, int maxNewton, Real tol, int cgIters = 300,
66 Precond prec = Precond::Jacobi, bool verbose = false,
67 Real muBarrier = 0, Real muDecay = (Real)0.7, bool freeEnergy = false) {
68 using peclet::core::Index;
69 using MemSpace = peclet::core::MemSpace;
70 const Real Larr[3] = {L[0], L[1], L[2]};
71 constexpr bool kHasSdf = !std::is_same_v<Sdf, peclet::voro::NoSdf>;
72 const int nD = Weighted ? 4 * N : 3 * N; // DOFs: [0,3N) positions 3i+d ; [3N,4N) weights 3N+i
73 auto wdof = [N](int i) { return 3 * N + i; };
74
75 Kokkos::View<long*, MemSpace> gd;
76 const double boxVol = (double)L[0] * L[1] * L[2];
77 double sumVset = 0;
78 for (int i = 0; i < N; ++i) sumVset += vsetIn[i];
79 std::vector<double> vset(N);
80 for (int i = 0; i < N; ++i) vset[i] = vsetIn[i] * (boxVol / sumVset);
81
82 // Per-cell objective pieces {energy, dE/dV, d²E/dV²}. Two modes (chosen by `freeEnergy`):
83 // • default: relative energy (V/V_ref−1)² (barCell = the collapse barrier, added with weight μ).
84 // • free-energy: e = −V_ref·log(V) (freeCell), pressure V_ref/V equalises to V∝V_ref and −log V
85 // resists collapse on its own. Both relax below a small V (matching quadratic) so a slightly
86 // infeasible start (V≤0 from tessellation flicker) stays finite and is driven feasible.
87 const double barEps = 0.1;
88 auto barCell = [&](double V, double vref, double mu) {
89 struct { double e, d1, d2; } r{0.0, 0.0, 0.0};
90 if (mu <= 0.0) return r;
91 const double t = V / vref;
92 if (t > barEps) {
93 r.e = -mu * std::log(t);
94 r.d1 = -mu / V;
95 r.d2 = mu / (V * V);
96 } else {
97 const double dt = t - barEps, ie = 1.0 / barEps;
98 r.e = mu * (-std::log(barEps) - dt * ie + 0.5 * dt * dt * ie * ie);
99 r.d1 = mu * (-ie + dt * ie * ie) / vref;
100 r.d2 = mu * ie * ie / (vref * vref);
101 }
102 return r;
103 };
104 const double feMin = 0.1;
105 auto freeCell = [&](double V, double vref) {
106 struct { double e, d1, d2; } r{0.0, 0.0, 0.0};
107 const double Vmin = feMin * vref, iv = 1.0 / Vmin;
108 if (V > Vmin) {
109 r.e = -vref * std::log(V);
110 r.d1 = -vref / V;
111 r.d2 = vref / (V * V);
112 } else {
113 const double dV = V - Vmin;
114 r.e = -vref * (std::log(Vmin) + dV * iv - 0.5 * dV * dV * iv * iv);
115 r.d1 = -vref * (iv - dV * iv * iv);
116 r.d2 = vref * iv * iv;
117 }
118 return r;
119 };
120
121 struct Geo {
122 std::vector<double> vol, dvr, area, conn;
123 std::vector<int> off, cnt;
124 std::vector<peclet::voro::gid_t> nbr;
125 double E = 0, maxErr = 0, meanErr = 0;
126 long nBad = 0;
127 };
128 auto build = [&](const std::vector<Real>& x, const std::vector<Real>& w, Geo& G) {
129 Kokkos::View<Real*, MemSpace> dpos("mo.pos", 3 * N), dw;
130 Kokkos::deep_copy(dpos, Kokkos::View<const Real*, Kokkos::HostSpace>(x.data(), 3 * N));
131 if constexpr (Weighted) {
132 dw = Kokkos::View<Real*, MemSpace>("mo.w", N);
133 Kokkos::deep_copy(dw, Kokkos::View<const Real*, Kokkos::HostSpace>(w.data(), N));
134 }
135 auto res = buildTessellation<Real, Weighted, Sdf>(dpos, dw, N, Larr, sw, N, gd, sdf, true);
136 G.vol = detail::toHostVec<Real>(res.view.cellVolume);
137 G.dvr = detail::toHostVec<Real>(res.view.facetConnect);
138 G.area = detail::toHostVec<Real>(res.view.facetArea);
139 G.conn = detail::toHostVec<Real>(res.view.facetConnVec);
140 G.off = detail::toHostVecT<int>(res.view.cellFacetOffset);
141 G.cnt = detail::toHostVecT<int>(res.view.cellFacetCount);
142 G.nbr = detail::toHostVecT<peclet::voro::gid_t>(res.view.facetNeighbor);
143 G.E = G.maxErr = G.meanErr = 0;
144 G.nBad = 0;
145 for (int i = 0; i < N; ++i) {
146 const double e = G.vol[i] / vset[i] - 1.0; // relative deviation from target (diagnostic)
147 G.E += freeEnergy ? freeCell(G.vol[i], vset[i]).e : e * e;
148 G.maxErr = std::max(G.maxErr, std::fabs(e));
149 G.meanErr += std::fabs(e);
150 if (G.vol[i] <= 0.0) ++G.nBad;
151 }
152 G.meanErr /= N;
153 };
154
155 // Per-cell sparse gradient ∇V_c over the DOFs (dof index, value). Positions from facetConnect,
156 // weights from A/(2d). `Rc` scales it into the objective gradient / Hessian rank-1 update.
157 auto stencil = [&](const Geo& G, int c, std::vector<std::pair<int, double>>& st) {
158 st.clear();
159 const int nF = (int)G.nbr.size();
160 double sx = 0, sy = 0, sz = 0, sw2 = 0;
161 for (int f = G.off[c]; f < G.off[c] + G.cnt[c] && f < nF; ++f) {
162 const long j = (long)G.nbr[f];
163 if (j < 0 || j >= N) continue;
164 const double dx = G.dvr[3 * f], dy = G.dvr[3 * f + 1], dz = G.dvr[3 * f + 2];
165 st.emplace_back(3 * (int)j, dx);
166 st.emplace_back(3 * (int)j + 1, dy);
167 st.emplace_back(3 * (int)j + 2, dz);
168 sx -= dx;
169 sy -= dy;
170 sz -= dz;
171 if constexpr (Weighted) {
172 const double A = std::sqrt(G.area[3 * f] * G.area[3 * f] +
173 G.area[3 * f + 1] * G.area[3 * f + 1] +
174 G.area[3 * f + 2] * G.area[3 * f + 2]);
175 const double d = std::sqrt(G.conn[3 * f] * G.conn[3 * f] +
176 G.conn[3 * f + 1] * G.conn[3 * f + 1] +
177 G.conn[3 * f + 2] * G.conn[3 * f + 2]);
178 const double wgt = (d > 0) ? A / (2 * d) : 0;
179 st.emplace_back(wdof((int)j), -wgt);
180 sw2 += wgt;
181 }
182 }
183 // SDF WALL-FACET term of ∂V_c/∂x_c (pore-space meshing). dV/dn for a facet is its outward area
184 // vector, which the tessellator already publishes (facetArea) for the wall facets of its own EXACT
185 // clipped cell — no cell rebuild. Sum those into gw = Σ_wall A_k û_k, then chain through the
186 // seed-foot wall model n_wall(x) = −φ(x)·û, û = ∇φ/|∇φ|: dn = −∇φ (dx·∇φ)/|∇φ|² (+ a curvature
187 // term ∝ ∇²φ for a curved wall). This is exactly addSdfWallForce's Jacobian J_wallᵀ evaluated at
188 // the seed. Added into the own-seed force (sx,sy,sz) so the Gauss-Newton outer product stays
189 // consistent. Guarded to the SDF case (NoSdf has no eval / no wall facets).
190 if constexpr (kHasSdf) {
191 // gw = Σ_wall (dV/dn_k) = Σ of the published wall-facet outward area vectors (facetArea).
192 double gw[3] = {0, 0, 0};
193 for (int f = G.off[c]; f < G.off[c] + G.cnt[c] && f < nF; ++f)
194 if (G.nbr[f] == kBoundaryFacet) { // wall facet (SDF cut); box faces are −1, real nbrs ≥ 0
195 gw[0] += G.area[3 * f];
196 gw[1] += G.area[3 * f + 1];
197 gw[2] += G.area[3 * f + 2];
198 }
199 if (gw[0] != 0.0 || gw[1] != 0.0 || gw[2] != 0.0) {
200 Real gg[3];
201 sdfGradient<Real>(sdf, pos[3 * c], pos[3 * c + 1], pos[3 * c + 2], gg);
202 const double gn = std::sqrt((double)gg[0] * gg[0] + (double)gg[1] * gg[1] + (double)gg[2] * gg[2]);
203 if (gn > 1e-12) {
204 const double u[3] = {gg[0] / gn, gg[1] / gn, gg[2] / gn};
205 const double ug = u[0] * gw[0] + u[1] * gw[1] + u[2] * gw[2];
206 const double perp[3] = {gw[0] - ug * u[0], gw[1] - ug * u[1], gw[2] - ug * u[2]};
207 Real Hm[3][3];
208 sdfHessian<Real>(sdf, pos[3 * c], pos[3 * c + 1], pos[3 * c + 2], Hm);
209 const double Hp[3] = {Hm[0][0] * perp[0] + Hm[0][1] * perp[1] + Hm[0][2] * perp[2],
210 Hm[1][0] * perp[0] + Hm[1][1] * perp[1] + Hm[1][2] * perp[2],
211 Hm[2][0] * perp[0] + Hm[2][1] * perp[1] + Hm[2][2] * perp[2]};
212 // Foot vector n = −φ ∇φ/|∇φ|² (Newton step to sdf(x+n)=0), valid for a general level-set
213 // function — NOT the |∇φ|=1 shortcut n=−φû. Its leading Jacobian dn/dx = −û ûᵀ is
214 // |∇φ|-INDEPENDENT (the |∇φ|² cancels), so the leading wall force is −(û·gw)û with no gn
215 // factor; the φ/|∇φ| curvature term (∝ ∇²φ) handles a curved wall to first order.
216 const double cH = (double)sdf.eval(pos[3 * c], pos[3 * c + 1], pos[3 * c + 2]) / gn;
217 sx += -ug * u[0] - cH * Hp[0];
218 sy += -ug * u[1] - cH * Hp[1];
219 sz += -ug * u[2] - cH * Hp[2];
220 }
221 }
222 }
223 st.emplace_back(3 * c, sx);
224 st.emplace_back(3 * c + 1, sy);
225 st.emplace_back(3 * c + 2, sz);
226 if constexpr (Weighted) st.emplace_back(wdof(c), sw2);
227 };
228
229 Geo G;
230 build(pos, weight, G);
231 // Renormalise the targets to the ACTUAL total cell volume from the first build — the box volume for
232 // a pure/periodic Voronoi partition, but the FLUID volume when an SDF clips the cells against a
233 // solid (seeds meshing a pore space). Targeting the box volume there makes every target ~1/porosity
234 // too large, so the gradient is dominated by an unachievable uniform-growth mode and the line search
235 // rejects every step. For NoSdf the total is the box volume ⇒ this is an exact no-op (sc == 1).
236 {
237 double totVol = 0.0, sVset = 0.0;
238 for (int i = 0; i < N; ++i) {
239 totVol += G.vol[i];
240 sVset += vset[i];
241 }
242 const double sc = (sVset > 0.0) ? totVol / sVset : 1.0;
243 for (int i = 0; i < N; ++i) vset[i] *= sc;
244 G.E = G.maxErr = G.meanErr = 0;
245 G.nBad = 0;
246 for (int i = 0; i < N; ++i) {
247 const double e = G.vol[i] / vset[i] - 1.0; // relative deviation from target (diagnostic)
248 G.E += freeEnergy ? freeCell(G.vol[i], vset[i]).e : e * e;
249 G.maxErr = std::max(G.maxErr, std::fabs(e));
250 G.meanErr += std::fabs(e);
251 if (G.vol[i] <= 0.0) ++G.nBad;
252 }
253 G.meanErr /= N;
254 }
255 auto pack = [&](const std::vector<Real>& x, const std::vector<Real>& w, std::vector<Real>& q) {
256 q.assign(x.begin(), x.end());
257 if constexpr (Weighted) q.insert(q.end(), w.begin(), w.end());
258 };
259 auto unpack = [&](const std::vector<Real>& q, std::vector<Real>& x, std::vector<Real>& w) {
260 x.assign(q.begin(), q.begin() + 3 * N);
261 if constexpr (Weighted) w.assign(q.begin() + 3 * N, q.end());
262 };
263
264 auto barrierE = [&](const Geo& G, double mu) -> double {
265 if (freeEnergy || mu <= 0.0) return 0.0; // free-energy mode carries its own collapse resistance
266 double b = 0.0;
267 for (int i = 0; i < N; ++i) b += barCell(G.vol[i], vset[i], mu).e;
268 return b;
269 };
270
271 OtResult R;
272 std::vector<double> g(nD), dq(nD);
273 std::vector<std::pair<int, double>> st;
274 const bool useHessian = (prec != Precond::SteepestDescent);
275 for (int it = 0; it < maxNewton; ++it) {
276 // Barrier-continuation weight for this Newton step: μ_it = μ0·decay^it → 0 (free-energy mode
277 // carries its own collapse resistance, so no separate barrier).
278 const double muCur = freeEnergy ? 0.0 : (double)muBarrier * std::pow((double)muDecay, it);
279 // gradient + assembled Gauss-Newton Hessian (rows as maps → CSR).
280 std::fill(g.begin(), g.end(), 0.0);
281 std::vector<std::unordered_map<int, double>> row(nD);
282 for (int c = 0; c < N; ++c) {
283 // Relative energy E = Σ (V_c/V_ref − 1)²: residual r_c = V_c/V_ref − 1, so the objective
284 // gradient picks up ∇V_c with coefficient Rc = 2 r_c/V_ref and the Gauss-Newton rank-1 weight
285 // is Hw = 2/V_ref². (V_ref = vset, renormalised to the total cell volume above; per-cell so a
286 // graded V_ref is a drop-in.) The log-barrier adds ∂(−μ log V_c)/∂V_c = −μ/V_c to Rc and the
287 // GN term +μ/V_c² to Hw (both ∝ ∇V_c, so they ride the same stencil).
288 double Rc, Hw;
289 if (freeEnergy) { // e=−V_ref log V ⇒ dE/dV=−V_ref/V (the pressure), GN weight V_ref/V²
290 const auto fc = freeCell(G.vol[c], vset[c]);
291 Rc = fc.d1;
292 Hw = fc.d2;
293 } else {
294 Rc = 2.0 * (G.vol[c] / vset[c] - 1.0) / vset[c];
295 Hw = 2.0 / (vset[c] * vset[c]);
296 if (muCur > 0.0) {
297 const auto bc = barCell(G.vol[c], vset[c], muCur);
298 Rc += bc.d1; // add the (relaxed) barrier's dE/dV and GN Hessian weight d²E/dV²
299 Hw += bc.d2;
300 }
301 }
302 stencil(G, c, st);
303 for (auto& [a, va] : st) {
304 g[a] += Rc * va;
305 if (useHessian) {
306 auto& ra = row[a];
307 for (auto& [b, vb] : st) ra[b] += Hw * va * vb;
308 }
309 }
310 }
311 double gnorm = 0;
312 for (int i = 0; i < nD; ++i) gnorm = std::max(gnorm, std::fabs(g[i]));
313
314 if (verbose && it == 0) { // FD-validate the objective gradient (one DOF)
315 const int d = 3 * (N / 2);
316 const double h = 1e-7;
317 std::vector<Real> xp = pos, wp = weight;
318 Geo Gp, Gm;
319 xp[d] += (Real)h;
320 build(xp, wp, Gp);
321 xp[d] -= (Real)(2 * h);
322 build(xp, wp, Gm);
323 std::printf(" FD dE/dx_%d: analytic=%.4e fd=%.4e\n", d, g[d],
324 ((Gp.E + barrierE(Gp, muCur)) - (Gm.E + barrierE(Gm, muCur))) / (2 * h));
325 if constexpr (Weighted) { // also validate a weight-DOF gradient
326 const int dw = wdof(N / 3);
327 std::vector<Real> wq = weight;
328 wq[N / 3] += (Real)h;
329 Geo Gwp, Gwm;
330 build(pos, wq, Gwp);
331 wq[N / 3] -= (Real)(2 * h);
332 build(pos, wq, Gwm);
333 std::printf(" FD dE/dw_%d: analytic=%.4e fd=%.4e\n", N / 3, g[dw],
334 (Gwp.E - Gwm.E) / (2 * h));
335 }
336 }
337
338 R.iters = it;
339 R.maxVolErr = G.maxErr;
340 R.meanVolErr = G.meanErr;
341 R.nEmpty = G.nBad;
342 if (verbose)
343 std::printf(" [vmesh] iter %2d E=%.4e maxVolErr=%.3e gnorm=%.3e nBad=%ld mu=%.2e\n", it, G.E,
344 G.maxErr, gnorm, G.nBad, muCur);
345 if (gnorm < tol) {
346 R.converged = true;
347 break;
348 }
349
350 // Search direction. Steepest descent uses dq = −g directly (the "volume-pressure" force
351 // −Σ_c (∂E/∂V_c)∇V_c) — a guaranteed descent direction, no Hessian solve, robust where the
352 // rank-deficient Gauss-Newton Hessian sends Newton into infeasible territory. Otherwise solve
353 // H dq = −g by preconditioned CG.
354 double rz0 = 0, rz = 0; // (reported in the verbose line below; set on the CG path)
355 if (!useHessian) {
356 for (int i = 0; i < nD; ++i) dq[i] = -g[i];
357 } else {
358 // flatten to CSR (Hcol/Hval incl. diagonal; Hdiag separate; off-diagonal CSR for colouring).
359 std::vector<Index> Hstart(nD + 1, 0), Hcol, ostart(nD + 1, 0), onbr;
360 std::vector<double> Hval, Hdiag(nD, 0.0);
361 for (int i = 0; i < nD; ++i) {
362 Hstart[i] = (Index)Hcol.size();
363 ostart[i] = (Index)onbr.size();
364 for (auto& [j, v] : row[i]) {
365 Hcol.push_back(j);
366 Hval.push_back(v);
367 if (j == i)
368 Hdiag[i] = v;
369 else
370 onbr.push_back(j);
371 }
372 }
373 Hstart[nD] = (Index)Hcol.size();
374 ostart[nD] = (Index)onbr.size();
375
376 auto matvec = [&](const std::vector<double>& v, std::vector<double>& out) {
377 for (int i = 0; i < nD; ++i) {
378 double s = 0;
379 for (Index k = Hstart[i]; k < Hstart[i + 1]; ++k) s += Hval[k] * v[Hcol[k]];
380 out[i] = s;
381 }
382 };
383
384 // preconditioner z ≈ H⁻¹ r.
385 peclet::core::amr::Coloring col;
386 std::vector<Index> colIdxHost;
387 if (prec == Precond::ColoredGS) {
388 col = peclet::core::amr::greedyColoring(ostart, onbr, (Index)nD);
389 colIdxHost = detail::toHostVecT<Index>(col.idx);
390 }
391 // Smoothed-aggregation AMG: rebuilt each Newton step (H moves with the geometry) from the same
392 // assembled Hessian CSR. Nodal aggregation over the 3-DOF-per-seed position blocks (the weighted
393 // path segregates the N weight DOFs after the 3N positions, so it falls back to scalar
394 // aggregation, s=1 — correct, just not block-aware; a block-aware weighted variant is a later
395 // refinement). One V-cycle per CG iteration keeps the iteration count flat as N grows.
396 peclet::core::solver::GraphAMG amg;
397 if (prec == Precond::GraphAMG) {
398 peclet::core::solver::HostCsrOp Aop;
399 Aop.n = nD;
400 Aop.diag = Hdiag;
401 Aop.start.assign((std::size_t)nD + 1, 0);
402 for (int i = 0; i < nD; ++i) {
403 for (Index k = Hstart[i]; k < Hstart[i + 1]; ++k)
404 if (Hcol[k] != i) {
405 Aop.nbr.push_back(Hcol[k]);
406 Aop.coef.push_back(Hval[k]);
407 }
408 Aop.start[(std::size_t)i + 1] = (Index)Aop.nbr.size();
409 }
410 peclet::core::solver::AmgParams ap;
411 ap.ndofPerNode = Weighted ? 1 : 3;
412 amg.build(Aop, ap);
413 }
414 auto precond = [&](const std::vector<double>& r, std::vector<double>& z) {
415 if (prec == Precond::Jacobi) {
416 for (int i = 0; i < nD; ++i) z[i] = std::fabs(Hdiag[i]) > 1e-30 ? r[i] / Hdiag[i] : r[i];
417 return;
418 }
419 if (prec == Precond::GraphAMG) {
420 amg.apply(r, z);
421 return;
422 }
423 // symmetric multicolour Gauss–Seidel (forward + backward sweep), z = 0 start.
424 std::fill(z.begin(), z.end(), 0.0);
425 auto sweep = [&](bool forward) {
426 for (int ci = 0; ci < col.nColors; ++ci) {
427 const int cc = forward ? ci : col.nColors - 1 - ci;
428 for (Index t = col.hStart[cc]; t < col.hStart[cc + 1]; ++t) {
429 const Index i = colIdxHost[t];
430 double s = r[i];
431 for (Index k = Hstart[i]; k < Hstart[i + 1]; ++k)
432 if (Hcol[k] != i) s -= Hval[k] * z[Hcol[k]];
433 if (std::fabs(Hdiag[i]) > 1e-30) z[i] = s / Hdiag[i];
434 }
435 }
436 };
437 sweep(true);
438 sweep(false);
439 };
440
441 // Jacobi-/GS-preconditioned CG for H dq = −g.
442 std::fill(dq.begin(), dq.end(), 0.0);
443 std::vector<double> rr(nD), z(nD), p(nD), Ap(nD);
444 for (int i = 0; i < nD; ++i) rr[i] = -g[i];
445 precond(rr, z);
446 p = z;
447 rz = 0;
448 for (int i = 0; i < nD; ++i) rz += rr[i] * z[i];
449 rz0 = rz;
450 for (int k = 0; k < cgIters && rz > 1e-18 * rz0; ++k) {
451 matvec(p, Ap);
452 double pAp = 0;
453 for (int i = 0; i < nD; ++i) pAp += p[i] * Ap[i];
454 if (pAp <= 0) break;
455 const double a = rz / pAp;
456 for (int i = 0; i < nD; ++i) {
457 dq[i] += a * p[i];
458 rr[i] -= a * Ap[i];
459 }
460 precond(rr, z);
461 double rzn = 0;
462 for (int i = 0; i < nD; ++i) rzn += rr[i] * z[i];
463 const double beta = rzn / rz;
464 for (int i = 0; i < nD; ++i) p[i] = z[i] + beta * p[i];
465 rz = rzn;
466 }
467 } // else (Gauss-Newton CG path)
468
469 // Armijo backtracking on the TOTAL energy E + barrier along dq.
470 double gdq = 0;
471 for (int i = 0; i < nD; ++i) gdq += g[i] * dq[i];
472 const double Ecur = G.E + barrierE(G, muCur);
473 std::vector<Real> q, qtry, xt, wt;
474 pack(pos, weight, q);
475 double alpha = 1.0;
476 bool accepted = false;
477 Geo Gt;
478 for (int bt = 0; bt < 24; ++bt) {
479 qtry.resize(nD);
480 for (int i = 0; i < nD; ++i) qtry[i] = q[i] + (Real)(alpha * dq[i]);
481 unpack(qtry, xt, wt);
482 build(xt, wt, Gt);
483 const double Etry = Gt.E + barrierE(Gt, muCur);
484 // Validity gate. With the barrier on (μ>0) a collapsed trial has Etry=+∞ ⇒ rejected by isfinite,
485 // so feasibility (all V>0) is enforced automatically. With no barrier: NoSdf requires nBad==0;
486 // the SDF-without-barrier case accepts on the energy decrease alone.
487 const bool cellsValid = (muCur > 0.0)
488 ? std::isfinite(Etry)
489 : (kHasSdf ? std::isfinite(Gt.E) : (Gt.nBad == 0));
490 if (cellsValid && Etry <= Ecur + 1e-4 * alpha * gdq) {
491 pos = xt;
492 if constexpr (Weighted) weight = wt;
493 G = Gt;
494 accepted = true;
495 break;
496 }
497 alpha *= 0.5;
498 }
499 if (verbose)
500 std::printf(" prec=%s cg rz %.2e->%.2e alpha=%.4f\n",
501 prec == Precond::Jacobi ? "jac" : "cgs", rz0, rz, alpha);
502 if (!accepted) break;
503 }
504 return R;
505}
506
507// ================================================================================================
508// Interfacial-energy optimiser (Surface-Evolver-style) — move seed positions to minimise the total
509// area of the faces between cells of DIFFERENT type: E = Σ_{f: type_i≠type_j} σ A_f. A two-phase
510// interface-tension minimiser (foam/emulsion coarsening).
511//
512// The face-area gradient ∂A_f/∂x is not published, so each cell is RECONSTRUCTED as a ConvexCell
513// from its facet neighbours and the validated per-triangle area-Jacobian geomVolumeAreaGrad is
514// gathered into ∂A_k/∂n_l, then routed to positions by chainToDofs<Voronoi>. Steepest descent with
515// an Armijo line search on E. Host (the oracle per docs/DEVICE_RESIDENCY_PLAN.md); the device port
516// mirrors the volume path (assemble on device, reuse the same reconstruction kernel per cell).
517// ================================================================================================
518template <class Real, class Sdf = NoSdf>
519OtResult interfaceMinimize(std::vector<Real>& pos, const std::vector<int>& type, double sigma,
520 const Real L[3], int N, int sw, const Sdf& sdf, int maxIter, Real tol,
521 bool verbose = false) {
522 using MemSpace = peclet::core::MemSpace;
523 using RCell = ConvexCell<Real, 128, 256>;
524 const Real Larr[3] = {L[0], L[1], L[2]}, Lh[3] = {L[0] / 2, L[1] / 2, L[2] / 2};
525 Kokkos::View<Real*, MemSpace> dw;
526 Kokkos::View<long*, MemSpace> gd;
527
528 // Build the tessellation at `x`, download the neighbour CSR, then per cell reconstruct the
529 // ConvexCell and gather the interfacial-area energy E and its position gradient g (3N).
530 auto energyGrad = [&](const std::vector<Real>& x, std::vector<double>& g, double& E) {
531 Kokkos::View<Real*, MemSpace> dpos("if.pos", 3 * N);
532 Kokkos::deep_copy(dpos, Kokkos::View<const Real*, Kokkos::HostSpace>(x.data(), 3 * N));
533 auto res = buildTessellation<Real, false, Sdf>(dpos, dw, N, Larr, sw, N, gd, sdf, true);
534 auto off = detail::toHostVecT<int>(res.view.cellFacetOffset);
535 auto cnt = detail::toHostVecT<int>(res.view.cellFacetCount);
536 auto nbr = detail::toHostVecT<peclet::voro::gid_t>(res.view.facetNeighbor);
537 const int nF = (int)nbr.size();
538 g.assign(3 * N, 0.0);
539 E = 0;
540 for (int c = 0; c < N; ++c) {
541 // gather + sort real neighbours by distance (min-image) → reconstruct the cell.
542 std::vector<std::array<Real, 4>> nb; // dist², rx, ry, rz
543 std::vector<int> ids;
544 for (int f = off[c]; f < off[c] + cnt[c] && f < nF; ++f) {
545 const long j = (long)nbr[f];
546 if (j < 0 || j >= N) continue;
547 Real r[3];
548 for (int d = 0; d < 3; ++d) {
549 Real rr = x[3 * j + d] - x[3 * c + d];
550 rr = rr > Lh[d] ? rr - L[d] : (rr < -Lh[d] ? rr + L[d] : rr);
551 r[d] = rr;
552 }
553 nb.push_back({r[0] * r[0] + r[1] * r[1] + r[2] * r[2], r[0], r[1], r[2]});
554 ids.push_back((int)j);
555 }
556 const int M = (int)ids.size();
557 std::vector<int> ord(M);
558 for (int i = 0; i < M; ++i) ord[i] = i;
559 std::sort(ord.begin(), ord.end(), [&](int a, int b) { return nb[a][0] < nb[b][0]; });
560 std::vector<Real> rx(M), ry(M), rz(M);
561 std::vector<int> id2(M);
562 for (int i = 0; i < M; ++i) {
563 rx[i] = nb[ord[i]][1];
564 ry[i] = nb[ord[i]][2];
565 rz[i] = nb[ord[i]][3];
566 id2[i] = ids[ord[i]];
567 }
568 RCell cell;
569 buildConvexCell(cell, Larr, rx.data(), ry.data(), rz.data(), id2.data(), M);
570 if (cell.empty() || cell.overflow) continue;
571
572 // gather per-face area magnitudes Ag[k] and the area Jacobian dA[k][l][cc] = ∂A_k/∂n_l.
573 const int np = cell.np;
574 std::vector<double> Ag(np, 0.0), dA((size_t)np * np * 3, 0.0);
575 for (int t = 0; t < cell.nt; ++t) {
576 if (!cell.alive[t]) continue;
577 int pl[3];
578 double cb[3], gr[3][3][3];
579 cell.geomVolumeAreaGrad(t, pl, cb, gr);
580 for (int ii = 0; ii < 3; ++ii) {
581 Ag[pl[ii]] += cb[ii];
582 for (int jj = 0; jj < 3; ++jj)
583 for (int cc = 0; cc < 3; ++cc)
584 dA[((size_t)pl[ii] * np + pl[jj]) * 3 + cc] += gr[ii][jj][cc];
585 }
586 }
587 // interfacial energy of this cell (½ per face — each interface face is shared by two cells),
588 // and dGeom/dn_l accumulated over the cell's interface faces.
589 std::vector<double> gn(3 * np, 0.0);
590 for (int k = 0; k < np; ++k) {
591 const int j = cell.pnbr[k];
592 if (j < 0 || j >= N || type[j] == type[c]) continue; // only different-type faces
593 E += 0.5 * sigma * Ag[k];
594 for (int l = 0; l < np; ++l)
595 for (int cc = 0; cc < 3; ++cc)
596 gn[3 * l + cc] += 0.5 * sigma * dA[((size_t)k * np + l) * 3 + cc];
597 }
598 // route ∂E_c/∂n_l → positions (self + neighbours) and scatter into g.
599 double gx[128], gy[128], gz[128];
600 for (int l = 0; l < np; ++l) {
601 gx[l] = gn[3 * l];
602 gy[l] = gn[3 * l + 1];
603 gz[l] = gn[3 * l + 2];
604 }
605 const double seed3[3] = {(double)x[3 * c], (double)x[3 * c + 1], (double)x[3 * c + 2]};
606 double fSelf[3], fwSelf, fnx[128], fny[128], fnz[128], fwn[128];
607 chainToDofs<Voronoi>(cell, seed3, (const double*)nullptr, 0.0, (const double*)nullptr,
608 (double)L[0], gx, gy, gz, fSelf, fwSelf, fnx, fny, fnz, fwn);
609 g[3 * c] += fSelf[0];
610 g[3 * c + 1] += fSelf[1];
611 g[3 * c + 2] += fSelf[2];
612 for (int l = 0; l < np; ++l) {
613 const int j = cell.pnbr[l];
614 if (j < 0 || j >= N) continue;
615 g[3 * j] += fnx[l];
616 g[3 * j + 1] += fny[l];
617 g[3 * j + 2] += fnz[l];
618 }
619 }
620 };
621
622 OtResult R;
623 std::vector<double> g;
624 double E;
625 energyGrad(pos, g, E);
626 const double E0 = E;
627 if (verbose) { // FD-validate the interfacial-area gradient (one DOF)
628 const int d = 3 * (N / 2);
629 const double h = 1e-7;
630 std::vector<Real> xp = pos;
631 std::vector<double> gp, gm;
632 double Ep, Em;
633 xp[d] += (Real)h;
634 energyGrad(xp, gp, Ep);
635 xp[d] -= (Real)(2 * h);
636 energyGrad(xp, gm, Em);
637 std::printf(" FD dE/dx_%d: analytic=%.4e fd=%.4e\n", d, g[d], (Ep - Em) / (2 * h));
638 }
639 for (int it = 0; it < maxIter; ++it) {
640 double gnorm = 0;
641 for (double v : g) gnorm = std::max(gnorm, std::fabs(v));
642 R.iters = it;
643 R.maxVolErr = E; // report energy in maxVolErr slot
644 if (verbose) std::printf(" [iface] iter %2d E=%.6e gnorm=%.3e\n", it, E, gnorm);
645 if (gnorm < tol) {
646 R.converged = true;
647 break;
648 }
649 // steepest descent with Armijo line search on E (dq = −g, g·dq = −|g|² < 0). Bound the initial
650 // step to a trust region (≈2% of the spacing) so it does not overshoot across the non-smooth
651 // topology-change kinks of the interfacial energy.
652 const double gdq = -std::inner_product(g.begin(), g.end(), g.begin(), 0.0);
653 const double spacing = std::cbrt((double)L[0] * L[1] * L[2] / N);
654 double alpha = std::min(1.0, 0.02 * spacing / std::max(gnorm, 1e-30));
655 bool accepted = false;
656 std::vector<Real> xtry(3 * N);
657 std::vector<double> gt;
658 double Et;
659 for (int bt = 0; bt < 30; ++bt) {
660 for (int i = 0; i < 3 * N; ++i) xtry[i] = pos[i] - (Real)(alpha * g[i]);
661 energyGrad(xtry, gt, Et);
662 if (Et <= E + 1e-4 * alpha * gdq) {
663 pos = xtry;
664 g = gt;
665 E = Et;
666 accepted = true;
667 break;
668 }
669 alpha *= 0.5;
670 }
671 if (!accepted) break;
672 }
673 R.meanVolErr = E / std::max(E0, 1e-30); // final/initial energy ratio
674 return R;
675}
676
677// ================================================================================================
678// Device-resident volume optimiser (pure Voronoi positions) — every per-Newton computation runs on
679// the Kokkos device: gradient assembly, matrix-free Gauss-Newton matvec, Jacobi-preconditioned CG,
680// line search, and the DOF update. Only convergence scalars (energy, dot products) cross to host.
681// This is the device path per docs/DEVICE_RESIDENCY_PLAN.md; the host meshVolumeOptimize is its
682// oracle. (Weight DOFs, colored-GS-on-device, and MPI halo exchange are the recorded next
683// increments.)
684// ================================================================================================
685template <class Real, class Sdf = NoSdf>
686OtResult meshVolumeOptimizeDevice(std::vector<Real>& posHost, const std::vector<Real>& vsetIn,
687 const Real L[3], int N, int sw, const Sdf& sdf, int maxNewton,
688 Real tol, int cgIters = 300, bool verbose = false) {
689 using MemSpace = peclet::core::MemSpace;
690 using Exec = peclet::core::ExecSpace;
691 using DV = Kokkos::View<double*, MemSpace>;
692 const Real Larr[3] = {L[0], L[1], L[2]};
693 const double gamma = 1.0, boxVol = (double)L[0] * L[1] * L[2];
694 const int nD = 3 * N;
695
696 double sumVset = 0;
697 for (int i = 0; i < N; ++i) sumVset += vsetIn[i];
698 DV vset("mo.vset", N);
699 {
700 auto h = Kokkos::create_mirror_view(vset);
701 for (int i = 0; i < N; ++i) h(i) = vsetIn[i] * (boxVol / sumVset);
702 Kokkos::deep_copy(vset, h);
703 }
704 Kokkos::View<Real*, MemSpace> dpos("mo.pos", 3 * N), dw;
705 Kokkos::deep_copy(dpos, Kokkos::View<const Real*, Kokkos::HostSpace>(posHost.data(), 3 * N));
706 Kokkos::View<long*, MemSpace> gd;
707
708 DV g("mo.g", nD), dq("mo.dq", nD), diag("mo.diag", nD);
709 DV rr("mo.r", nD), z("mo.z", nD), pp("mo.p", nD), Ap("mo.Ap", nD), yy("mo.y", N);
710
711 // Build the tessellation at `x` and return E + max/mean vol error + empty count (device reduce).
712 auto evaluate = [&](const Kokkos::View<Real*, MemSpace>& x, TessellatorResult<Real>& res,
713 double& E, double& maxErr, double& meanErr, long& nBad) {
714 res = buildTessellation<Real, false, Sdf>(x, dw, N, Larr, sw, N, gd, sdf, true);
715 auto vol = res.view.cellVolume;
716 auto vs = vset;
717 double e = 0, mx = 0, mn = 0;
718 long nb = 0;
719 Kokkos::parallel_reduce(
720 "mo.E", Kokkos::RangePolicy<Exec>(0, N),
721 KOKKOS_LAMBDA(int i, double& le, double& lmx, double& lmn, long& lnb) {
722 const double d = vol(i) - vs(i);
723 le += gamma * d * d;
724 lmx = Kokkos::max(lmx, Kokkos::fabs(d));
725 lmn += Kokkos::fabs(d);
726 if (vol(i) <= 0.0) ++lnb;
727 },
728 e, Kokkos::Max<double>(mx), mn, nb);
729 E = e;
730 maxErr = mx;
731 meanErr = mn / N;
732 nBad = nb;
733 };
734
736 double E, maxErr, meanErr;
737 long nBad;
738 evaluate(dpos, res, E, maxErr, meanErr, nBad);
739
740 OtResult R;
741 for (int it = 0; it < maxNewton; ++it) {
742 auto off = res.view.cellFacetOffset;
743 auto cnt = res.view.cellFacetCount;
744 auto nbr = res.view.facetNeighbor;
745 auto dvr = res.view.facetConnect; // ∂V_c/∂r per facet
746 auto vol = res.view.cellVolume;
747 auto vs = vset;
748
749 // gradient g and Gauss-Newton diagonal (device, atomic scatter).
750 Kokkos::deep_copy(g, 0.0);
751 Kokkos::deep_copy(diag, 0.0);
752 Kokkos::parallel_for(
753 "mo.grad", Kokkos::RangePolicy<Exec>(0, N), KOKKOS_LAMBDA(int c) {
754 const double Rc = 2.0 * gamma * (vol(c) - vs(c));
755 double sx = 0, sy = 0, sz = 0;
756 for (int f = off(c); f < off(c) + cnt(c); ++f) {
757 const int j = (int)nbr(f);
758 if (j < 0 || j >= N) continue;
759 const double dx = dvr(3 * f), dy = dvr(3 * f + 1), dz = dvr(3 * f + 2);
760 Kokkos::atomic_add(&g(3 * j), Rc * dx);
761 Kokkos::atomic_add(&g(3 * j + 1), Rc * dy);
762 Kokkos::atomic_add(&g(3 * j + 2), Rc * dz);
763 Kokkos::atomic_add(&diag(3 * j), 2.0 * gamma * dx * dx);
764 Kokkos::atomic_add(&diag(3 * j + 1), 2.0 * gamma * dy * dy);
765 Kokkos::atomic_add(&diag(3 * j + 2), 2.0 * gamma * dz * dz);
766 sx -= dx;
767 sy -= dy;
768 sz -= dz;
769 }
770 Kokkos::atomic_add(&g(3 * c), Rc * sx);
771 Kokkos::atomic_add(&g(3 * c + 1), Rc * sy);
772 Kokkos::atomic_add(&g(3 * c + 2), Rc * sz);
773 Kokkos::atomic_add(&diag(3 * c), 2.0 * gamma * sx * sx);
774 Kokkos::atomic_add(&diag(3 * c + 1), 2.0 * gamma * sy * sy);
775 Kokkos::atomic_add(&diag(3 * c + 2), 2.0 * gamma * sz * sz);
776 });
777 double gnorm = 0;
778 Kokkos::parallel_reduce(
779 "mo.gnorm", Kokkos::RangePolicy<Exec>(0, nD),
780 KOKKOS_LAMBDA(int i, double& m) { m = Kokkos::max(m, Kokkos::fabs(g(i))); },
781 Kokkos::Max<double>(gnorm));
782
783 R.iters = it;
784 R.maxVolErr = maxErr;
785 R.meanVolErr = meanErr;
786 R.nEmpty = nBad;
787 if (verbose)
788 std::printf(" [dvmesh] iter %2d E=%.4e maxVolErr=%.3e gnorm=%.3e nBad=%ld\n", it, E, maxErr,
789 gnorm, nBad);
790 if (gnorm < tol) {
791 R.converged = true;
792 break;
793 }
794
795 // matrix-free Gauss-Newton apply H v = 2γ Jᵀ(J v) (two device passes: J into yy, Jᵀ scatter).
796 auto Hmul = [&](const DV& v, DV& out) {
797 auto y = yy;
798 Kokkos::parallel_for(
799 "mo.Jv", Kokkos::RangePolicy<Exec>(0, N), KOKKOS_LAMBDA(int c) {
800 double yc = 0;
801 for (int f = off(c); f < off(c) + cnt(c); ++f) {
802 const int j = (int)nbr(f);
803 if (j < 0 || j >= N) continue;
804 for (int d = 0; d < 3; ++d) yc += dvr(3 * f + d) * (v(3 * j + d) - v(3 * c + d));
805 }
806 y(c) = yc;
807 });
808 Kokkos::deep_copy(out, 0.0);
809 Kokkos::parallel_for(
810 "mo.JTy", Kokkos::RangePolicy<Exec>(0, N), KOKKOS_LAMBDA(int c) {
811 const double s = 2.0 * gamma * y(c);
812 for (int f = off(c); f < off(c) + cnt(c); ++f) {
813 const int j = (int)nbr(f);
814 if (j < 0 || j >= N) continue;
815 for (int d = 0; d < 3; ++d) {
816 Kokkos::atomic_add(&out(3 * j + d), s * dvr(3 * f + d));
817 Kokkos::atomic_add(&out(3 * c + d), -s * dvr(3 * f + d));
818 }
819 }
820 });
821 };
822 auto dot = [&](const DV& a, const DV& b) {
823 double s = 0;
824 Kokkos::parallel_reduce(
825 "mo.dot", Kokkos::RangePolicy<Exec>(0, nD),
826 KOKKOS_LAMBDA(int i, double& l) { l += a(i) * b(i); }, s);
827 return s;
828 };
829
830 // Jacobi-preconditioned CG for H dq = −g (device).
831 Kokkos::deep_copy(dq, 0.0);
832 {
833 auto G = g, D = diag;
834 Kokkos::parallel_for(
835 "mo.r0", Kokkos::RangePolicy<Exec>(0, nD), KOKKOS_LAMBDA(int i) {
836 rr(i) = -G(i);
837 z(i) = Kokkos::fabs(D(i)) > 1e-30 ? rr(i) / D(i) : rr(i);
838 pp(i) = z(i);
839 });
840 }
841 double rz = dot(rr, z), rz0 = rz;
842 for (int k = 0; k < cgIters && rz > 1e-18 * rz0; ++k) {
843 Hmul(pp, Ap);
844 const double pAp = dot(pp, Ap);
845 if (pAp <= 0) break;
846 const double a = rz / pAp;
847 {
848 auto D = diag;
849 Kokkos::parallel_for(
850 "mo.cgupd", Kokkos::RangePolicy<Exec>(0, nD), KOKKOS_LAMBDA(int i) {
851 dq(i) += a * pp(i);
852 rr(i) -= a * Ap(i);
853 z(i) = Kokkos::fabs(D(i)) > 1e-30 ? rr(i) / D(i) : rr(i);
854 });
855 }
856 const double rzn = dot(rr, z);
857 const double beta = rzn / rz;
858 Kokkos::parallel_for(
859 "mo.pupd", Kokkos::RangePolicy<Exec>(0, nD),
860 KOKKOS_LAMBDA(int i) { pp(i) = z(i) + beta * pp(i); });
861 rz = rzn;
862 }
863 const double gdq = dot(g, dq);
864
865 // Armijo line search on E: x_try = x + α dq (device), rebuild + reduce E; backtrack.
866 double alpha = 1.0;
867 bool accepted = false;
868 Kokkos::View<Real*, MemSpace> xtry("mo.xtry", 3 * N);
869 for (int bt = 0; bt < 24; ++bt) {
870 const double al = alpha;
871 auto X = dpos, DQ = dq, XT = xtry;
872 Kokkos::parallel_for(
873 "mo.trial", Kokkos::RangePolicy<Exec>(0, nD),
874 KOKKOS_LAMBDA(int i) { XT(i) = X(i) + (Real)(al * DQ(i)); });
876 double Et, mxT, mnT;
877 long nbT;
878 evaluate(xtry, resT, Et, mxT, mnT, nbT);
879 if (nbT == 0 && Et <= E + 1e-4 * alpha * gdq) {
880 Kokkos::deep_copy(dpos, xtry);
881 res = resT;
882 E = Et;
883 maxErr = mxT;
884 meanErr = mnT;
885 nBad = nbT;
886 accepted = true;
887 break;
888 }
889 alpha *= 0.5;
890 }
891 if (!accepted) break;
892 }
893 // download the optimised positions.
894 auto h = Kokkos::create_mirror_view(dpos);
895 Kokkos::deep_copy(h, dpos);
896 for (int i = 0; i < 3 * N; ++i) posHost[i] = h(i);
897 return R;
898}
899
900} // namespace peclet::voro
901
902#endif // PECLET_VORO_MESH_OPTIMIZER_HPP
KOKKOS_INLINE_FUNCTION Dual< Real, K > dnum(Real c)
Definition convex_cell.hpp:52
Definition convex_cell.hpp:40
OtResult interfaceMinimize(std::vector< Real > &pos, const std::vector< int > &type, double sigma, const Real L[3], int N, int sw, const Sdf &sdf, int maxIter, Real tol, bool verbose=false)
Definition mesh_optimizer.hpp:519
OtResult meshVolumeOptimize(std::vector< Real > &pos, std::vector< Real > &weight, const std::vector< Real > &vsetIn, const Real L[3], int N, int sw, const Sdf &sdf, int maxNewton, Real tol, int cgIters=300, Precond prec=Precond::Jacobi, bool verbose=false, Real muBarrier=0, Real muDecay=(Real) 0.7, bool freeEnergy=false)
Definition mesh_optimizer.hpp:63
OtResult meshVolumeOptimizeDevice(std::vector< Real > &posHost, const std::vector< Real > &vsetIn, const Real L[3], int N, int sw, const Sdf &sdf, int maxNewton, Real tol, int cgIters=300, bool verbose=false)
Definition mesh_optimizer.hpp:686
Precond
Definition mesh_optimizer.hpp:46
KOKKOS_INLINE_FUNCTION void buildConvexCell(ConvexCell< Real, MAXP, MAXT, TrackAdj > &c, const Real L[3], const Real *relx, const Real *rely, const Real *relz, const int *ids, int nNbr, Real wSelf=Real(0), const Real *weights=nullptr)
Definition convex_cell.hpp:1687
constexpr int kBoundaryFacet
Definition sdf.hpp:33
Semi-discrete optimal-transport VOLUME control on the power tessellation — the first energy-minimisat...
Definition convex_cell.hpp:139
Definition ot_optimizer.hpp:46
double meanVolErr
mean |V_i − V_set_i|
Definition ot_optimizer.hpp:49
double maxVolErr
max_i |V_i − V_set_i|
Definition ot_optimizer.hpp:48
long nEmpty
buried/empty cells at the final state (should be 0 for a feasible target)
Definition ot_optimizer.hpp:51
int iters
Definition ot_optimizer.hpp:47
bool converged
Definition ot_optimizer.hpp:50
Definition tessellator.hpp:63