rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
TransientPlasmaChannelSolver.cpp
Go to the documentation of this file.
2#include "PlasmaChannel1D.h"
3
10
11#include <algorithm>
12#include <cstdio>
13#include <cstdlib>
14#include <sstream>
15#include <functional>
16#include <memory>
17#include <stdexcept>
18
19namespace rizer {
20
21namespace {
22
23// Method-of-lines RHS for the stiff (CVODE) backend:
24// @f[
25// \dot{y} = f(y)
26// @f]
27// is the channel's spatial operator (transport + chemistry +
28// Joule/elastic). Because PlasmaChannel1D::eval returns each node's
29// @f$d\phi/dt@f$ (source/capacitance plus transport/capacitance) minus the
30// transient term @f$r_{dt}(\phi-\phi_{prev})@f$, calling it with
31// @f$r_{dt}=0@f$ yields exactly @f$\dot{y}@f$. The wall is a ghost-cell flux
32// (no algebraic constraint), so the whole system is a clean ODE.
33class ChannelRHS : public Cantera::FuncEval
34{
35public:
47 ChannelRHS(PlasmaChannel1D* channel, Cantera::OneDim* sim, std::vector<double> y0,
48 std::size_t n_vars, std::size_t channel_offset, std::size_t n_points,
49 std::function<double(double)> fieldAt)
50 : m_channel(channel), m_sim(sim), m_y0(std::move(y0)), m_n_vars(n_vars),
51 m_channel_offset(channel_offset), m_n_points(n_points),
52 m_field(std::move(fieldAt)) {}
53
54 std::size_t neq() const override { return m_y0.size(); }
55
61 void getState(Cantera::span<double> y) override {
62 std::copy(m_y0.begin(), m_y0.end(), y.begin());
63 }
64
70 void eval(double t, Cantera::span<const double> y, Cantera::span<double> ydot,
71 Cantera::span<const double> /*params*/) override {
72 m_channel->setElectricField(m_field(t));
73 // @f$r_{dt}=0@f$ -> the OneDim residual is exactly the
74 // method-of-lines RHS @f$d\phi/dt@f$. Route through OneDim::eval
75 // (the proven path that manages the mask workspace), not the
76 // domain's eval directly.
77 m_sim->eval(Cantera::npos, y, ydot, 0.0, 0);
78 }
79
89 void getConstraints(Cantera::span<double> constraints) override {
90 std::fill(constraints.begin(), constraints.end(), 0.0);
91 for (std::size_t j = 0; j < m_n_points; j++) {
92 for (std::size_t comp = 2; comp < m_n_vars; comp++) {
93 // 1.0 => y_i >= 0
94 constraints[m_channel_offset + m_n_vars * j + comp] = 1.0;
95 }
96 }
97 }
98
99private:
100 PlasmaChannel1D* m_channel; // Non-owning; owned by solveChannelTransient's
101 // `channel`
102 Cantera::OneDim* m_sim; // Non-owning; owned by solveChannelTransient's
103 // `sim`
104 std::vector<double> m_y0; // Initial global state, units per-component
105 // (Tg/Te [K], Y_k [-])
106 std::size_t m_n_vars; // Components per node, 2+K [-]
107 std::size_t m_channel_offset; // This domain's offset into the global
108 // state [-]
109 std::size_t m_n_points; // Number of radial grid points [-]
110 std::function<double(double)> m_field; // E(t) [V/m], t in [s]
111};
112
113} // namespace
114
116{
117 using std::make_shared;
118
119 auto channel = make_shared<PlasmaChannel1D>(opts.cfg);
120
121 auto left = make_shared<Cantera::Empty1D>();
122 auto right = make_shared<Cantera::Empty1D>();
123 std::vector<std::shared_ptr<Cantera::Domain1D>> domains{left, channel, right};
124 Cantera::Sim1D sim(domains);
125 sim.getInitialSoln(); // Apply PlasmaChannel1D::initialValue
126
127 const std::size_t n_species = channel->nSpecies();
128 const std::size_t n_points = channel->nPoints();
129 const std::size_t n_vars = 2 + n_species; // Per-node: Tg, Te, Y_1..Y_K
130 const std::size_t channel_offset =
131 channel->loc(); // This domain's offset in the global state
132
133 std::vector<double> x(sim.size(), 0.0), xnew(sim.size());
134
135 // Initialize x from the populated solution (getInitialSoln wrote it into
136 // the internal m_state). NOTE: getState() copies the Newton work buffer
137 // m_xnew (empty here), so read the real state via the domain's values()
138 // (Domain1D::getValues reads m_state).
139 for (std::size_t comp = 0; comp < n_vars; comp++) {
140 std::vector<double> vals = channel->values(channel->componentName(comp));
141 for (std::size_t j = 0; j < n_points; j++) {
142 x[channel_offset + n_vars * j + comp] = vals[j];
143 }
144 }
145
146 // Populate the transient mask before the first step. timeStep ->
147 // initTimeInteg calls updateTransient()+factorize() BEFORE any residual
148 // eval, so without this warm-up the mask is all-zero and the (zero) steady
149 // Jacobian is singular on step 1. One eval(npos) fills m_mask (our diag=1).
150 sim.eval(Cantera::npos, x, xnew, 1.0 / opts.dt, 0);
151
152 // Static metadata (grid, species names, sizes) is fixed for the whole run
153 // and only needs setting once, up front; only t/Tg/Te/ne/Y grow per frame.
154 ChannelHistory history;
155 history.npts = n_points;
156 history.nsp = n_species;
157 for (std::size_t j = 0; j < n_points; j++) history.r.push_back(channel->z(j));
158 for (std::size_t k = 0; k < n_species; k++) {
159 history.species.push_back(channel->componentName(2 + k));
160 }
161
162 // Appends one frame (all nodes) to `history` from the CURRENT flat state
163 // `x`, in the flat layout x[channel_offset + n_vars*j + component] shared
164 // by both backends below. Captured by reference so both the BDF and
165 // Backward-Euler loops can call it identically at their own cadence.
166 auto record = [&](double t) {
167 history.t.push_back(t);
168 for (std::size_t j = 0; j < n_points; j++) {
169 const double Tg = x[channel_offset + n_vars * j + 0];
170 const double Te = x[channel_offset + n_vars * j + 1];
171 history.Tg.push_back(Tg);
172 history.Te.push_back(Te);
173 history.ne.push_back(
174 channel->electronDensity(Tg, Te, &x[channel_offset + n_vars * j + 2]));
175 for (std::size_t k = 0; k < n_species; k++) {
176 history.Y.push_back(x[channel_offset + n_vars * j + 2 + k]);
177 }
178 }
179 };
180
181 // Field schedule E(t): an explicit table if provided, else the constant.
182 PropertyTable Efield;
183 if (opts.cfg.Et_t.size() >= 2
184 && opts.cfg.Et_t.size() == opts.cfg.Et_v.size()) {
185 Efield = PropertyTable(opts.cfg.Et_t, opts.cfg.Et_v);
186 }
187 auto fieldAt = [&](double t) {
188 return Efield.empty() ? opts.cfg.electric_field : Efield.eval(t);
189 };
190
191 record(0.0);
192
193 // Fixed output times, uniform over the total interval
194 // @f$dt \cdot n_\text{steps}@f$.
195 const double t_total = // Total simulated time [s]
196 opts.dt * static_cast<double>(opts.n_steps);
197 const std::size_t n_intervals = // Number of recorded frames after t=0 [-]
198 std::max<std::size_t>(
199 1, opts.n_steps / std::max<std::size_t>(1, opts.record_every));
200
201 // ---------------- Stiff BDF backend (CVODE) ------------------------
202 if (opts.integrator == "bdf") {
203 ChannelRHS rhs(channel.get(), &sim, x, n_vars, channel_offset, n_points,
204 fieldAt);
205 std::unique_ptr<Cantera::Integrator> cvode(Cantera::newIntegrator("CVODE"));
206 cvode->setMethod(Cantera::BDF_Method);
207 const char* lin_solver_env = std::getenv("RIZER_LINSOL");
208 const std::string lin_solver_type =
209 lin_solver_env ? std::string(lin_solver_env) : std::string("BAND");
210 cvode->setLinearSolverType(lin_solver_type);
211 if (lin_solver_type == "BAND") {
212 // Each node couples to j +/- 1
213 const int bandwidth = static_cast<int>(2 * n_vars);
214 cvode->setBandwidth(bandwidth, bandwidth);
215 }
216 // Per-component absolute tolerances: K-scale for T, mass-fraction
217 // floor for Y_k. atol[Y] must sit ABOVE the truly-trace species
218 // level: a species at Y~1e-12 oscillating about zero cannot be
219 // tracked to 1e-15, and CVODE then drives its internal step chasing
220 // meaningless trace-species error (seen as a stall at the channel
221 // edge, where chemistry is stiff but species are vanishing). 1e-12
222 // ignores dynamically-irrelevant trace species; rtol 1e-7 governs the
223 // O(1) carriers and the temperatures (this is a transport+chemistry
224 // PDE solve, not a 0D point, so it does not need the 0D's rtol
225 // 1e-10).
226 std::vector<double> atol(x.size());
227 for (std::size_t j = 0; j < n_points; j++) {
228 atol[n_vars * j + 0] = 1.0e-3; // Tg [K]
229 atol[n_vars * j + 1] = 1.0e-3; // Te [K]
230 for (std::size_t k = 0; k < n_species; k++) {
231 atol[n_vars * j + 2 + k] = 1.0e-9; // Y_k [-]
232 }
233 }
234 cvode->setTolerances(1.0e-7,
235 Cantera::span<const double>(atol.data(), atol.size()));
236 // Cap the internal step at the output cadence. This is essential for the
237 // stiff plasma electron-energy equation (tiny electron heat capacity ->
238 // sub-ns Te dynamics): without it CVODE attempts an over-large first step
239 // and stalls. The 0D NRP reactor relies on the same max_step setting.
240 cvode->setMaxStepSize(opts.dt);
241 // A sane step budget: high enough for a long stiff run, low enough that a
242 // genuine stall throws a clean (decoded) error instead of spinning at a
243 // denormal step until SUNDIALS corrupts (the access-violation we saw).
244 cvode->setMaxSteps(500000);
245 cvode->initialize(0.0, rhs);
246
247 // CVODE reports failures as bare flat-state indices ("275: 25.0 ..."),
248 // which are meaningless to a caller. Translate each index into the node
249 // (with its radius) and physical component (Tg / Te / species name) it
250 // belongs to, so the message points at *where* and *what* went stiff.
251 auto decodeError = [&](const std::string& message) -> std::string {
252 // Failure time [s], if the message carries it
253 // ("At t = <x> and h = ...").
254 double t_fail = -1.0;
255 // Character offset into `message` [-]
256 std::size_t t_pos = message.find("At t = ");
257 if (t_pos != std::string::npos) {
258 try { t_fail = std::stod(message.substr(t_pos + 7)); } catch (...) {}
259 }
260 std::ostringstream out;
261 out << "PlasmaChannel1D BDF solve failed to converge";
262 if (t_fail >= 0.0) out << " near t=" << t_fail << " s";
263 out << ".\nThe implicit corrector could not resolve the local "
264 "dynamics and the step size hit its floor. The dominant "
265 "error contributions are:\n";
266 std::istringstream in(message);
267 std::string line;
268 bool in_error_block = false, found_any = false;
269 while (std::getline(in, line)) {
270 if (line.find("Components with largest") != std::string::npos) {
271 in_error_block = true;
272 continue;
273 }
274 if (!in_error_block) continue;
275 std::size_t colon = line.find(':');
276 if (colon == std::string::npos) continue;
277 long global_index; double error_value;
278 try {
279 global_index = std::stol(line.substr(0, colon));
280 error_value = std::stod(line.substr(colon + 1));
281 } catch (...) { continue; }
282 long local_index = global_index - static_cast<long>(channel_offset);
283 if (local_index < 0) continue;
284 std::size_t j = static_cast<std::size_t>(local_index) / n_vars;
285 std::size_t comp = static_cast<std::size_t>(local_index) % n_vars;
286 if (j >= n_points) continue;
287 out << " node " << j << " (r=" << channel->z(j) << " m) "
288 << channel->componentName(comp)
289 << " (weighted error " << error_value << ")\n";
290 found_any = true;
291 }
292 if (!found_any) { out << message << "\n"; }
293 out << "Remedies: reduce dt, loosen tolerances, soften the initial "
294 "profiles or the field ramp, or verify the initial state is "
295 "physically consistent at the flagged nodes.";
296 return out.str();
297 };
298
299 // Graded startup. The 2T electron-energy + plasma-chemistry layer is
300 // ferociously stiff at t=0 (tiny electron heat capacity ->
301 // @f$dT_e/dt \sim 10^{15}@f$ K/s once the field is on). The proven 0D
302 // reactor tames this with scipy vode's first_step=1e-15; Cantera's
303 // CVODES exposes no initial-step setter, but CVODE caps its first
304 // internal step at (tout - t0), so integrating to a sequence of tiny
305 // times forces a small, safe first step that then grows under error
306 // control -- the same effect, from the caller side.
307 // Warm-up integration times [s]
308 for (double t_warm : {1.0e-15, 1.0e-14, 1.0e-13, 1.0e-12, 1.0e-11, 1.0e-10}) {
309 if (t_warm >= t_total) break;
310 try {
311 cvode->integrate(t_warm);
312 } catch (const Cantera::CanteraError& err) {
313 throw Cantera::CanteraError("solveChannelTransient",
314 "{}", decodeError(err.what()));
315 }
316 }
317
318 for (std::size_t frame = 1; frame <= n_intervals; frame++) {
319 const double t_rec = t_total * static_cast<double>(frame)
320 / static_cast<double>(n_intervals);
321 std::string decoded;
322 bool failed = false;
323 try {
324 cvode->integrate(t_rec);
325 } catch (const Cantera::CanteraError& err) {
326 decoded = decodeError(err.what()); // Format-free std::string
327 failed = true;
328 }
329 if (failed) {
330 // Re-throw OUTSIDE the catch handler, and pass the decoded text as
331 // a format ARGUMENT ("{}") so CanteraError's fmt layer never parses
332 // any stray braces in the CVODE message as format fields.
333 throw Cantera::CanteraError("solveChannelTransient", "{}", decoded);
334 }
335 auto solution = cvode->solution();
336 std::copy(solution.begin(), solution.end(), x.begin());
337 record(t_rec);
338 if (opts.loglevel > 0) {
339 std::fprintf(stderr, "[bdf] frame %zu/%zu t=%.3e s\n",
340 frame, n_intervals, t_rec);
341 std::fflush(stderr);
342 }
343 }
344 history.nframes = history.t.size();
345 return history;
346 }
347
348 // ---------------- Adaptive Backward-Euler backend (MultiNewton) --------
349 // Shrink dt on a failed Newton solve, grow it back on success. (A fixed step
350 // -- m_tfactor=1 plus unbounded retries -- is what made stiff cases hang.
351 // B-E is L-stable, so the constraint is the Newton basin, not stability;
352 // letting dt adapt restores Cantera's own escape hatch.) Each sub-step
353 // mirrors one SteadyStateSystem::timeStep iteration (initTimeInteg applies
354 // the rdt transient term + factorizes, then a Newton solve) with explicit dt
355 // and time control so frames land on the requested times.
356 sim.newton().setOptions(/*maxJacAge=*/10);
357 sim.setMinTimeStep(1.0e-16);
358 sim.setMaxTimeStep(opts.dt);
359 sim.setTimeStepFactor(0.5);
360
361 const double dt_min = 1.0e-16; // Smallest allowed adaptive step [s]
362 // before giving up
363 const long max_substeps = 2000000; // Cumulative sub-step budget [-]
364 // across the whole run
365 auto& newton = sim.newton();
366
367 double t = 0.0; // Current simulated time [s]
368 double dt = opts.dt; // Current adaptive step size [s]
369 int fails = 0; // Consecutive Newton failures at the
370 // current dt [-]
371 long total_substeps = 0; // Cumulative sub-steps taken so far [-]
372
373 for (std::size_t frame = 1; frame <= n_intervals; frame++) {
374 const double t_rec = // This frame's target recording time [s]
375 t_total * static_cast<double>(frame) / static_cast<double>(n_intervals);
376 while (t < t_rec - 1.0e-300) {
377 // Clipped so frames land exactly on t_rec [s]
378 const double step_size = std::min(dt, t_rec - t);
379 channel->setElectricField(fieldAt(t)); // E(t) at the sub-step start
380 sim.initTimeInteg(step_size, x);
381 const int status = newton.solve(x, xnew, sim, opts.loglevel);
382 if (status >= 0) {
383 std::copy(xnew.begin(), xnew.end(), x.begin());
384 t += step_size;
385 fails = 0;
386 dt = std::min(dt * 1.5, opts.dt); // Ease the step size back up
387 } else {
388 if (++fails > 2) {
389 // Clamp @f$T>0@f$, @f$Y \in [0,1]@f$
390 sim.resetBadValues(x);
391 fails = 0;
392 }
393 dt *= 0.5; // Shrink and retry,
394 // even after a clamp
395 if (dt < dt_min) {
396 throw Cantera::CanteraError("solveChannelTransient",
397 "Backward-Euler Newton failed to converge; dt fell below "
398 "{:g} s near t={:g} s. The electron-impact chemistry is too "
399 "stiff for this step -- use integrator='bdf'.", dt_min, t);
400 }
401 }
402 if (++total_substeps > max_substeps) {
403 throw Cantera::CanteraError("solveChannelTransient",
404 "Exceeded {} sub-steps; integration is not progressing.",
405 max_substeps);
406 }
407 }
408 record(t);
409 }
410
411 // Reset every domain's rdt to 0 (Cantera's steady/transient mode flag),
412 // matching Sim1D's own convention of leaving the object in steady mode
413 // once time-stepping is done -- `sim` is local and destroyed right after,
414 // so this has no observable effect here, but keeps the pattern consistent
415 // with how Sim1D is used elsewhere (e.g. PlasmaColumnSolver).
416 sim.setSteadyMode();
417 history.nframes = history.t.size();
418 return history;
419}
420
421} // namespace rizer
const char * what() const override
void setOptions(int maxJacAge=5)
void initTimeInteg(double dt, span< const double > x) override
void setSteadyMode() override
void resetBadValues(span< double > x) override
void getInitialSoln()
void eval(size_t j, span< const double > x, span< double > r, double rdt=-1.0, int count=1)
void setMaxTimeStep(double tmax)
void setMinTimeStep(double tmin)
void setTimeStepFactor(double tfactor)
double eval(double T) const
Linearly interpolated value at T; clamped to the table endpoints.
bool empty() const
True for a default-constructed (empty) table; eval() returns 0.0.
Integrator * newIntegrator(const string &itype)
const size_t npos
ChannelHistory solveChannelTransient(const ChannelOptions &opts)
Build a PlasmaChannel1D from opts.cfg, advance it from t=0 to [s] with the requested backend (opts....
std::vector< double > ne
Electron number density [1/m^3], row-major [nframes, npts].
std::size_t npts
Number of radial grid points [-].
std::vector< double > t
Recorded times [s], length nframes.
std::vector< std::string > species
Species names, length nsp (order matches the Y axis).
std::size_t nsp
Number of species [-].
std::vector< double > Y
Species mass fractions [-], row-major [nframes, npts, nsp].
std::vector< double > r
Radial grid [m], length npts.
std::size_t nframes
Number of recorded time frames [-].
std::vector< double > Tg
Gas temperature [K], row-major [nframes, npts].
std::vector< double > Te
Electron temperature [K], row-major [nframes, npts].
int loglevel
Diagnostic verbosity [-] (0 = silent; >0 prints progress).
std::string integrator
Time-integration backend:
std::size_t n_steps
Number of output steps [-] (dt*n_steps = total time [s]).
PlasmaChannelConfig cfg
Domain physics + initial/boundary state.
double dt
Output cadence / initial step [s].
std::size_t record_every
Record a frame every N output steps [-].
std::vector< double > Et_v
std::vector< double > Et_t