rizer.cantera_ext
C++ Cantera 1-D plasma extension (custom Domain1D models and solvers)
Loading...
Searching...
No Matches
bindings.cpp
Go to the documentation of this file.
1// pybind11 bindings for the rizer Cantera plasma extension (_plasma1d).
2//
3// The extension holds rizer's C++ plasma models and this file is the only
4// place they meet Python. It covers three unrelated problems, which is why the
5// exposed API below reads as three groups rather than one:
6//
7// - a steady LTE arc column (Elenbaas-Heller), solved on Cantera's Sim1D
8// with its damped-Newton driver;
9// - a transient two-temperature NRP plasma channel, 0D and 1D radial, with
10// finite-rate chemistry, Joule heating and electron-heavy exchange,
11// integrated by CVODE/BDF;
12// - the explicit hydrodynamic half-step that wraps that channel under the
13// Strang split of NUMERICS.md section 4.2, plus the thermodynamic
14// accessors it needs.
15//
16// Design rule: "flat numerics only" across the Python boundary.
17// - No Cantera C++ type (Solution, Domain1D, Sim1D, ...) is ever exposed to
18// Python. This sidesteps all ABI-sharing concerns with the stock `cantera`
19// Python package; both can be loaded in the same process without conflicts.
20// - Properties enter as parallel (abscissa, value) numpy arrays and are
21// converted to PropertyTable objects inside the binding lambda.
22// - Results leave as plain Python dicts of numpy arrays and Python scalars.
23//
24// Exposed API:
25// Library
26// cantera_version() -> str version of the linked libcantera
27// register_plasma_rates() register rizer's native reaction rates
28// mean_cross_section(...) -> array Maxwellian-averaged Qbar(Te)
29// Steady LTE arc column
30// solve_column(...) -> dict converged radial column profile
31// Transient NRP channel
32// Reactor0D persistent 0D two-temperature reactor
33// solve_1D_unsteady_discharge(...) -> dict 1D radial channel history
34// Hydrodynamic sub-step and its thermodynamics
35// solve_hydro_step_only(...) -> dict one explicit Strang half-step
36// hydro_thermo_probe(...) -> dict ThermoAccessor at one state
37
38#ifndef NOMINMAX
39#define NOMINMAX
40#endif
41#include <pybind11/pybind11.h>
42#include <pybind11/stl.h>
43#include <pybind11/numpy.h>
44
45#include "cantera/base/global.h"
46
61
62#include <cmath>
63#include <filesystem>
64#include <map>
65#include <memory>
66#include <optional>
67#include <sstream>
68#include <stdexcept>
69#include <string>
70#include <vector>
71
72namespace py = pybind11;
73
74namespace
75{
76
77 // ------------------------------------------------------------------------------ //
78 // File-local helpers //
79 // ------------------------------------------------------------------------------ //
80
84 std::vector<double> toVec(const py::array_t<double> &a)
85 {
86 // Re-wrapping as c_style|forcecast forces a contiguous double array (copying
87 // only if `a` isn't already one), so the raw-pointer range below is always
88 // valid -- unlike raw pointer arithmetic, this does not assume `a` itself is
89 // contiguous (e.g. a strided view).
90 const py::array_t<double, py::array::c_style | py::array::forcecast> c(a);
91 return std::vector<double>(c.data(), c.data() + c.size());
92 }
93
102 std::vector<double> optVec(const py::object &obj)
103 {
104 if (obj.is_none())
105 return {};
106 return toVec(obj.cast<py::array_t<double>>());
107 }
108
118 void requireLength(std::size_t size, std::size_t expected, const char *name)
119 {
120 if (size != expected)
121 {
122 throw std::invalid_argument(
123 std::string(name) + " has length " + std::to_string(size) +
124 ", expected " + std::to_string(expected) + ".");
125 }
126 }
127
134 void requireTeGrid(int Te_n)
135 {
136 if (Te_n < 2)
137 {
138 throw std::invalid_argument(
139 "Te_n must be at least 2 to span [Te_min, Te_max], got " +
140 std::to_string(Te_n) + ".");
141 }
142 }
143
154 void requireMatrixShape(const py::array &a, std::size_t rows,
155 std::optional<std::size_t> cols, const char *name)
156 {
157 const bool shape_ok =
158 a.ndim() == 2 && static_cast<std::size_t>(a.shape(0)) == rows &&
159 (!cols || static_cast<std::size_t>(a.shape(1)) == *cols);
160 if (!shape_ok)
161 {
162 std::string got = "(";
163 for (py::ssize_t d = 0; d < a.ndim(); d++)
164 {
165 got += (d ? ", " : "") + std::to_string(a.shape(d));
166 }
167 got += a.ndim() == 1 ? ",)" : ")";
168 throw std::invalid_argument(
169 std::string(name) + " has shape " + got + ", expected (" +
170 std::to_string(rows) + ", " +
171 (cols ? std::to_string(*cols) : std::string("n_species")) + ").");
172 }
173 }
174
182 std::vector<int> toChargeNumbers(const py::array_t<double> &a)
183 {
184 const std::vector<double> values = toVec(a);
185 std::vector<int> charges(values.size());
186 for (std::size_t k = 0; k < values.size(); k++)
187 {
188 if (!std::isfinite(values[k]) || values[k] != std::floor(values[k]))
189 {
190 throw std::invalid_argument(
191 "ion_Z[" + std::to_string(k) + "] is " + std::to_string(values[k]) +
192 "; a charge number must be a whole number.");
193 }
194 charges[k] = static_cast<int>(values[k]);
195 }
196 return charges;
197 }
198
213 std::vector<rizer::CollisionModel::SpeciesSpec>
214 collisionSpecs(const py::list &xsec_energy_J, const py::list &xsec_sigma_m2,
215 const py::array_t<double> &radius_m, const py::array_t<double> &ion_Z)
216 {
217 const std::size_t n_species = static_cast<std::size_t>(xsec_energy_J.size());
218 requireLength(static_cast<std::size_t>(xsec_sigma_m2.size()), n_species,
219 "xsec_sigma_m2");
220 requireLength(static_cast<std::size_t>(radius_m.size()), n_species, "radius_m");
221 requireLength(static_cast<std::size_t>(ion_Z.size()), n_species, "ion_Z");
222 const std::vector<double> radius = toVec(radius_m);
223 const std::vector<int> charges = toChargeNumbers(ion_Z);
224
225 std::vector<rizer::CollisionModel::SpeciesSpec> specs(n_species);
226 for (std::size_t k = 0; k < n_species; k++)
227 {
228 specs[k].radius = radius[k];
229 specs[k].Z = charges[k];
230 const py::object energy = xsec_energy_J[k];
231 const py::object sigma = xsec_sigma_m2[k];
232 if (energy.is_none() != sigma.is_none())
233 {
234 throw std::invalid_argument(
235 "species " + std::to_string(k) + " has " +
236 (energy.is_none() ? "xsec_sigma_m2 but no xsec_energy_J"
237 : "xsec_energy_J but no xsec_sigma_m2") +
238 "; a tabulated cross section needs both, or neither for a "
239 "hard sphere or no model.");
240 }
241 if (!energy.is_none())
242 {
243 specs[k].energy_J = toVec(energy.cast<py::array_t<double>>());
244 specs[k].sigma_m2 = toVec(sigma.cast<py::array_t<double>>());
245 }
246 }
247 return specs;
248 }
249
268 rizer::PropertyTable makeTable(const py::array_t<double> &T,
269 const py::array_t<double> &values,
270 const char *name = "property table")
271 {
272 std::vector<double> abscissa = toVec(T);
273 std::vector<double> ordinate = toVec(values);
274 if (abscissa.size() != ordinate.size())
275 {
276 throw std::invalid_argument(
277 std::string(name) +
278 ": the abscissa has " +
279 std::to_string(abscissa.size()) +
280 " point(s) against " +
281 std::to_string(ordinate.size()) +
282 " value(s); they tabulate one curve and must be the same length.");
283 }
284 for (std::size_t i = 1; i < abscissa.size(); i++)
285 {
286 if (!(abscissa[i] > abscissa[i - 1]))
287 {
288 throw std::invalid_argument(
289 std::string(name) +
290 ": the abscissa must be strictly increasing, but entry " +
291 std::to_string(i) +
292 " (" + std::to_string(abscissa[i]) +
293 ") does not exceed entry " +
294 std::to_string(i - 1) +
295 " (" + std::to_string(abscissa[i - 1]) + ").");
296 }
297 }
298 return rizer::PropertyTable(std::move(abscissa), std::move(ordinate));
299 }
300
301} // namespace
302
303namespace
304{
305
306 // ------------------------------------------------------------------------------ //
307 // Cached Cantera thermo accessor //
308 // ------------------------------------------------------------------------------ //
309
332 rizer::CanteraThermo &cachedCanteraThermo(const std::string &mech,
333 const std::string &phase)
334 {
335 struct Entry
336 {
337 std::filesystem::file_time_type modified;
338 std::unique_ptr<rizer::CanteraThermo> thermo;
339 };
340 static std::map<std::pair<std::string, std::string>, Entry> cache;
341 const std::string path = Cantera::findInputFile(mech);
342 const auto modified = std::filesystem::last_write_time(path);
343 Entry &entry = cache[std::make_pair(path, phase)];
344 if (!entry.thermo || entry.modified != modified)
345 {
346 entry.thermo = std::make_unique<rizer::CanteraThermo>(path, phase);
347 entry.modified = modified;
348 }
349 return *entry.thermo;
350 }
351
352} // namespace
353
354// ---------------------------------------------------------------------------------- //
355// Module definition //
356// ---------------------------------------------------------------------------------- //
357
358PYBIND11_MODULE(_plasma1d, m)
359{
360 m.doc() =
361 "rizer Cantera plasma extension: a steady LTE arc column, a "
362 "transient two-temperature NRP plasma channel (0D and 1D radial) "
363 "with its explicit hydrodynamic Strang half-step, and the "
364 "thermodynamic accessors they share. Flat numerics only across "
365 "the boundary: no Cantera C++ object is ever exposed, so this "
366 "module and the stock `cantera` package coexist in one process.";
367
368 m.def("cantera_version", []()
369 { return Cantera::version(); }, "Version string of the linked libcantera.");
370
371 // Whether this build compiled the per-node reaction loop with OpenMP.
372 // Exported because its ABSENCE is otherwise invisible: `n_threads` is
373 // still accepted and still returns the right answer, one thread at a
374 // time, so a CMake change that stops attaching OpenMP to plasma1d_core
375 // costs the parallel speed-up with nothing failing. This flag is read
376 // from bindings.cpp's own translation unit, which inherits the compile
377 // option through plasma1d_core, so it tracks the core's setting.
378#ifdef _OPENMP
379 m.attr("has_openmp") = true;
380#else
381 m.attr("has_openmp") = false;
382#endif
383
384 m.def("register_plasma_rates", &rizer::registerPlasmaRates,
385 "(Re-)register the native C++ plasma reaction rates "
386 "(janev-dissociative-recombination-C2Hy, Druyvesteyn, "
387 "reverse-two-temperature-plasma) with Cantera's ReactionRateFactory. "
388 "Idempotent; replaces the Python ExtensibleRate registrations of the "
389 "same names, so mechanisms load and evaluate natively afterwards.");
390
391 m.def("mean_cross_section",
392 // Maxwellian momentum-transfer kernel Qbar(Te) (Mitchner II-6.30).
393 //
394 // Exposed for validation against the Python
395 // TabulatedSpeciesCrossSection integral. Vectorized over Te.
396 // energy_J/sigma_m2 are the tabulated cross section, held at the
397 // nearest edge value outside the tabulated range rather than zeroed.
398 [](
399 // Parameters
400 // ----------
401 py::array_t<double> energy_J,
402 // Tabulated collision energy grid [J], strictly increasing.
403 py::array_t<double> sigma_m2,
404 // Tabulated cross section Q(energy_J) [m^2], same length as energy_J.
405 py::array_t<double> Te,
406 // Electron temperatures to evaluate Qbar(Te) at [K].
407 double x_max,
408 // Upper limit of the reduced-energy (E/kTe) integration [-].
409 // [Optional] By default, 20.0
410 int N
411 // Number of quadrature points for the integration [-].
412 // [Optional] By default, 1000
413 //
414 // Returns
415 // -------
416 // NumPy array
417 // Maxwellian momentum-transfer kernel Qbar(Te) [m^2], one entry per Te.
418 )
419 {
420 std::vector<double> e = toVec(energy_J);
421 std::vector<double> s = toVec(sigma_m2);
422 std::vector<double> t = toVec(Te);
423 // Checked here rather than left to the table: without it a
424 // mismatched pair would interpolate to 0.0 at every energy and
425 // return a correctly shaped array of zeros instead of raising.
426 if (e.size() != s.size()) {
427 throw std::invalid_argument(
428 "mean_cross_section: energy_J has "
429 + std::to_string(e.size()) + " entries but sigma_m2 has "
430 + std::to_string(s.size()) + "; they tabulate the same "
431 "cross section and must be the same length.");
432 }
433 if (e.size() < 2) {
434 throw std::invalid_argument(
435 "mean_cross_section: energy_J/sigma_m2 need at least 2 "
436 "points to interpolate, got " + std::to_string(e.size())
437 + ".");
438 }
439 std::vector<double> q =
441 return py::array_t<double>(q.size(), q.data()); },
442 // --- required
443 py::arg("energy_J"), py::arg("sigma_m2"), py::arg("Te"),
444 // --- optional
445 py::arg("x_max") = 20.0, py::arg("N") = 1000);
446
447 // Native C++ two-temperature 0D plasma reactor: a persistent evaluator that the
448 // Python driver (scipy + NRP circuit) calls per RHS. Flat numerics only; the
449 // per-species cross-section model is passed once at construction as ragged lists.
450 py::class_<rizer::PlasmaReactor0D>(m, "Reactor0D")
451 .def(py::init(
452 // Construct a Reactor0D from a mechanism and a collision model.
453 //
454 // The per-species cross-section model is passed once here as ragged
455 // lists and baked into a Qbar(Te) table, rather than integrated live
456 // on every right-hand-side evaluation.
457 [](
458 // Parameters
459 // ----------
460 //
461 // Mechanism
462 const std::string &mech,
463 // Cantera mechanism (YAML) path
464 const std::string &phase,
465 // Phase name within the mechanism
466 bool reacting,
467 // Include finite-rate chemistry source terms [-]; false freezes
468 // the composition
469 //
470 // Collision model, per species
471 py::list xsec_energy_J,
472 // Tabulated cross-section energy grids [J] (list of 1-D arrays,
473 // or None per species), length nsp
474 py::list xsec_sigma_m2,
475 // Tabulated cross sections at xsec_energy_J [m^2] (list of 1-D
476 // arrays, or None per species), length nsp
477 py::array_t<double> radius_m,
478 // Hard-sphere effective radii [m], NaN where unused, length nsp
479 py::array_t<double> ion_Z,
480 // Ion charge numbers [-], 0 for neutrals, length nsp
481 double Te_min,
482 // Lower bound of the log-spaced Qbar(Te) grid [K]
483 double Te_max,
484 // Upper bound of the log-spaced Qbar(Te) grid [K]
485 int Te_n,
486 // Points in the Qbar(Te) grid [-]
487 //
488 // Geometry and thermochemistry
489 double gap,
490 // Inter-electrode gap [m], used by the polytropic volume equation
491 // [Optional] By default, 0.0
492 py::array_t<double> species_h0k
493 // Standard enthalpies of formation at 0 K [J/kmol], length nsp;
494 // required when `reacting` and the mechanism carries an
495 // electron-impact reaction (see rizer.kinetics.electron_reactions)
496 // [Optional] By default, empty
497 )
498 {
500 cfg.mech = mech;
501 cfg.phase = phase;
502 cfg.reacting = reacting;
503 cfg.Te_min = Te_min;
504 cfg.Te_max = Te_max;
505 requireTeGrid(Te_n);
506 cfg.Te_n = static_cast<std::size_t>(Te_n);
507 cfg.gap = gap;
508 cfg.species_h0k = toVec(species_h0k);
509 cfg.specs = collisionSpecs(xsec_energy_J, xsec_sigma_m2, radius_m,
510 ion_Z);
511 return std::make_unique<rizer::PlasmaReactor0D>(cfg);
512 }),
513 // --- required: mechanism
514 py::arg("mech"),
515 py::arg("phase"),
516 // --- optional: mechanism
517 py::arg("reacting") = true,
518 // --- required: collision model
519 py::arg("xsec_energy_J"),
520 py::arg("xsec_sigma_m2"),
521 py::arg("radius_m"),
522 py::arg("ion_Z"),
523 // --- optional: collision model
524 py::arg("Te_min") = 200.0,
525 py::arg("Te_max") = 1.0e5,
526 py::arg("Te_n") = 400,
527 // --- optional: geometry and thermochemistry
528 py::arg("gap") = 0.0,
529 py::arg("species_h0k") = py::array_t<double>(0))
530
531 .def("electrical_conductivity",
532 // Electrical conductivity at one state.
533 [](
534 // Parameters
535 // ----------
537 // The bound Reactor0D instance
538 double Tg,
539 // Heavy-species temperature [K]
540 double Te,
541 // Electron temperature [K]
542 py::array_t<double> Y,
543 // Species mass fractions [-], length nsp
544 double rho
545 // Mass density [kg/m^3]
546 //
547 // Returns
548 // -------
549 // float
550 // Electrical conductivity [S/m]
551 )
552 {
553 auto y = toVec(Y);
554 requireLength(y.size(), self.nSpecies(), "Y");
555 return self.electricalConductivity(Tg, Te, y.data(), rho); },
556 // --- required
557 py::arg("Tg"), py::arg("Te"), py::arg("Y"), py::arg("rho"))
558
559 .def("electron_density",
560 // Electron number density [1/m^3]
561 [](
562 // Parameters
563 // ----------
565 // The bound Reactor0D instance
566 double Tg,
567 // Gas (heavy-species) temperature [K]
568 double Te,
569 // Electron temperature [K]
570 py::array_t<double> Y,
571 // Species mass fractions [-], length nsp
572 double rho
573 // Mass density [kg/m^3]
574 //
575 // Returns
576 // -------
577 // float
578 // Electron number density [1/m^3]
579 )
580 {
581 auto y = toVec(Y);
582 requireLength(y.size(), self.nSpecies(), "Y");
583 return self.electronDensity(Tg, Te, y.data(), rho); },
584 // --- required
585 py::arg("Tg"), py::arg("Te"), py::arg("Y"), py::arg("rho"))
586
587 .def("rhs",
588 // 0D RHS dydt = [dTg, dTe, dV, dY...] for state [Tg, Te, V, Y...];
589 [](
590 // Parameters
591 // ----------
593 // The bound Reactor0D instance
594 double Tg,
595 // Gas (heavy-species) temperature [K]
596 double Te,
597 // Electron temperature [K]
598 double V,
599 // Volume [m^3]
600 py::array_t<double> Y,
601 // Species mass fractions [-], length nsp
602 double E,
603 // Applied electric field [V/m]
604 double mass,
605 // Fixed total mass [kg] (rho = mass/V)
606 double p_ext,
607 // External pressure [Pa] (drives the polytropic volume equation)
608 double polytropic_index
609 // Polytropic index [-] (inf = constant volume)
610 //
611 // Returns
612 // -------
613 // NumPy array
614 // dydt = [dTg, dTe, dV, dY...] (length 3 + nsp)
615
616 )
617 {
618 auto y = toVec(Y);
619 requireLength(y.size(), self.nSpecies(), "Y");
620 std::vector<double> dydt(3 + self.nSpecies(), 0.0);
621 self.rhs(Tg, Te, V, y.data(), E, mass, p_ext, polytropic_index,
622 dydt.data());
623 return py::array_t<double>(dydt.size(), dydt.data()); },
624 // --- required
625 py::arg("Tg"), py::arg("Te"), py::arg("V"), py::arg("Y"), py::arg("E"), py::arg("mass"), py::arg("p_ext"), py::arg("polytropic_index"))
626
627 .def_property_readonly("species", [](rizer::PlasmaReactor0D &self)
628 { return self.speciesNames(); })
629 // Power / Maxwellian-condition breakdown from the most recent rhs()
630 // call (rizer.models.nrp.isomass_2T_volume_reactor_cpp.Isomass2TVolumeReactor's
631 // stored P_elastic/P_inelastic/P_chemical_H/P_chemical_e/P_Joule/
632 // nu_eH/nu_ee/nu_eI/nu_eH_mass_weighted attributes, natively).
633 .def_property_readonly("P_elastic", [](rizer::PlasmaReactor0D &self)
634 { return self.diagnostics().P_elastic; })
635 .def_property_readonly("P_inelastic", [](rizer::PlasmaReactor0D &self)
636 { return self.diagnostics().P_inelastic; })
637 .def_property_readonly("P_chemical_H", [](rizer::PlasmaReactor0D &self)
638 { return self.diagnostics().P_chemical_H; })
639 .def_property_readonly("P_chemical_e", [](rizer::PlasmaReactor0D &self)
640 { return self.diagnostics().P_chemical_e; })
641 .def_property_readonly("P_Joule", [](rizer::PlasmaReactor0D &self)
642 { return self.diagnostics().P_Joule; })
643 .def_property_readonly("nu_eH", [](rizer::PlasmaReactor0D &self)
644 { return self.diagnostics().nu_eH; })
645 .def_property_readonly("nu_ee", [](rizer::PlasmaReactor0D &self)
646 { return self.diagnostics().nu_ee; })
647 .def_property_readonly("nu_eI", [](rizer::PlasmaReactor0D &self)
648 { return self.diagnostics().nu_eI; })
649 .def_property_readonly("nu_eH_mass_weighted", [](rizer::PlasmaReactor0D &self)
650 { return self.diagnostics().nu_eH_mass_weighted; });
651
652 m.def("solve_column",
653 // Solve the steady radial Elenbaas-Heller LTE arc column.
654 //
655 // Assembles [Empty1D | ThermalPlasmaColumn1D | Empty1D] in a Cantera
656 // Sim1D, solves with its damped-Newton driver plus a pseudo-transient
657 // fallback, and optionally refines the radial grid adaptively.
658 [](
659 // Parameters
660 // ----------
661 //
662 // Geometry and operating point
663 double R,
664 // Outer wall radius [m]
665 double electric_field,
666 // Axial electric field [V/m]
667 double T_wall,
668 // Wall temperature [K]
669 //
670 // Material properties
671 py::array_t<double> sigma_T,
672 // Temperatures for sigma_v [K], strictly increasing
673 py::array_t<double> sigma_v,
674 // Electrical conductivity at sigma_T [S/m]
675 py::array_t<double> kappa_T,
676 // Temperatures for kappa_v [K], strictly increasing
677 py::array_t<double> kappa_v,
678 // Thermal conductivity at kappa_T [W/m/K]
679 py::object prad_T,
680 // Temperatures for prad_v [K], strictly increasing
681 // [Optional] By default, None (radiation off)
682 py::object prad_v,
683 // Radiated power density at prad_T [W/m^3]
684 // [Optional] By default, None (radiation off)
685 //
686 // Initial guess
687 std::size_t n_points,
688 // Initial uniform grid count [-]
689 // [Optional] By default, 41
690 double T_center_guess,
691 // Parabolic-seed centerline temperature [K]
692 // [Optional] By default, 10000.0
693 py::object init_r,
694 // Seed radii [m], strictly increasing over 0..R, overriding the
695 // parabolic seed
696 // [Optional] By default, None
697 py::object init_T,
698 // Seed temperatures at init_r [K]
699 // [Optional] By default, None
700 //
701 // Solver controls
702 double rho_cp,
703 // rho*cp for the pseudo-transient scaling [J/m^3/K]
704 // [Optional] By default, 1.0e3
705 int loglevel,
706 // Sim1D verbosity [-]; 0 is silent
707 // [Optional] By default, 0
708 //
709 // Grid refinement
710 bool refine_grid,
711 // Enable Cantera's adaptive grid refinement [-]
712 // [Optional] By default, true
713 double refine_ratio,
714 // Refiner: max ratio of adjacent cell widths [-]
715 // [Optional] By default, 4.0
716 double refine_slope,
717 // Refiner: max fractional slope change per cell [-]
718 // [Optional] By default, 0.05
719 double refine_curve,
720 // Refiner: max fractional curvature per cell [-]
721 // [Optional] By default, 0.10
722 double refine_prune
723 // Refiner: remove points below this threshold [-]
724 // [Optional] By default, 0.0
725 //
726 // Returns
727 // -------
728 // dict
729 // The converged profile: r [m], T [K], sigma [S/m], kappa
730 // [W/m/K], the total current [A], the electric field [V/m] and
731 // the final n_points.
732 )
733 {
734 // Convert the input numpy arrays into PropertyTable objects.
736 makeTable(sigma_T, sigma_v, "solve_column: sigma_T/sigma_v");
738 makeTable(kappa_T, kappa_v, "solve_column: kappa_T/kappa_v");
739 // For the optional radiation property, check if the inputs are None.
740 // If either is None, create a constant PropertyTable with value 0.0.
741 // Otherwise, create a PropertyTable from the provided arrays.
743 (prad_T.is_none() || prad_v.is_none())
745 : makeTable(prad_T.cast<py::array_t<double>>(),
746 prad_v.cast<py::array_t<double>>(),
747 "solve_column: prad_T/prad_v");
748
749 // Set up the solver options from the provided parameters.
751 opts.n_points = n_points;
752 opts.T_center_guess = T_center_guess;
753 opts.rho_cp = rho_cp;
754 opts.loglevel = loglevel;
755 opts.refine_grid = refine_grid;
756 opts.refine_ratio = refine_ratio;
757 opts.refine_slope = refine_slope;
758 opts.refine_curve = refine_curve;
759 opts.refine_prune = refine_prune;
760
761 // If the user provided an initial seed profile,
762 // convert the numpy arrays into std::vectors.
763 // solveColumn refuses a pair it cannot use, so both go through even
764 // when only one is given.
765 opts.init_r = optVec(init_r);
766 opts.init_T = optVec(init_T);
767
768 // Call the C++ solver function, releasing the GIL since it is pure C++.
769 // The result is a ColumnResult struct containing the solution.
771 {
772 py::gil_scoped_release release; // solve is pure C++
773 res = rizer::solveColumn(R, electric_field, T_wall,
774 sigma, kappa, prad, opts);
775 }
776
777 // Convert the result into a Python dict of numpy arrays and scalars.
778 py::dict out;
779 out["r"] = py::array_t<double>(res.r.size(), res.r.data());
780 out["T"] = py::array_t<double>(res.T.size(), res.T.data());
781 out["sigma"] = py::array_t<double>(res.sigma.size(), res.sigma.data());
782 out["kappa"] = py::array_t<double>(res.kappa.size(), res.kappa.data());
783 out["current"] = res.current;
784 out["electric_field"] = res.electric_field;
785 out["n_points"] = res.n_points;
786 return out; },
787 // --- required: geometry and operating point (outer radius, field, wall temperature)
788 py::arg("R"), py::arg("electric_field"), py::arg("T_wall"),
789 // --- required: plasma properties (electrical and thermal conductivity)
790 py::arg("sigma_T"), py::arg("sigma_v"), py::arg("kappa_T"), py::arg("kappa_v"),
791 // --- optional: plasma properties (radiation)
792 py::arg("prad_T") = py::none(), py::arg("prad_v") = py::none(),
793 // --- optional: initial guess (parabolic seed or explicit profile)
794 py::arg("n_points") = 41, py::arg("T_center_guess") = 10000.0, py::arg("init_r") = py::none(), py::arg("init_T") = py::none(),
795 // --- optional: solver controls (pseudo-transient scaling and verbosity)
796 py::arg("rho_cp") = 1.0e3, py::arg("loglevel") = 0,
797 // --- optional: grid refinement (Cantera's adaptive grid refinement)
798 py::arg("refine_grid") = true, py::arg("refine_ratio") = 4.0, py::arg("refine_slope") = 0.05, py::arg("refine_curve") = 0.10, py::arg("refine_prune") = 0.0);
799
800 m.def("solve_1D_unsteady_discharge",
801 // Integrate the transient two-temperature NRP plasma channel.
802 //
803 // One radial domain of `n_points` nodes carrying Tg, Te and the K
804 // species mass fractions, advanced by CVODE/BDF over `n_steps` steps
805 // of `dt`. With `hydrodynamics` on, each step is the Strang sequence
806 // of NUMERICS.md section 4.2 -- half an explicit hydro step, the
807 // reaction step, half an explicit hydro step -- and rho and u_r
808 // become solved fields rather than fixed inputs.
809 //
810 // Setting n_points = 1 gives the 0D reactor as the single-node limit.
811 [](
812 // Parameters
813 // ----------
814 //
815 // Mechanism
816 const std::string &mech,
817 // Cantera mechanism (YAML) path
818 const std::string &phase,
819 // Phase name within the mechanism
820 //
821 // Grid
822 std::size_t n_points,
823 // Number of radial nodes [-]; 1 is the 0D limit
824 double R_max,
825 // Outer radius of the domain [m]
826 py::object grid,
827 // Explicit node radii [m], overriding the uniform grid built from
828 // n_points/R_max
829 // [Optional] By default, None (uniform)
830 //
831 // Initial state, uniform
832 double rho,
833 // Mass density [kg/m^3], held fixed unless `hydrodynamics`
834 double Tg0,
835 // Initial heavy-pool temperature [K]
836 double Te0,
837 // Initial electron temperature [K]
838 py::array_t<double> Y0,
839 // Initial mass fractions [-], length K, summing to 1
840 //
841 // Initial state, radial profiles (each a radius/value pair)
842 py::object initTg_r,
843 // Radii for initTg_v [m]
844 // [Optional] By default, None (uniform Tg0)
845 py::object initTg_v,
846 // Heavy-pool temperature at initTg_r [K]
847 // [Optional] By default, None
848 py::object initTe_r,
849 // Radii for initTe_v [m]
850 // [Optional] By default, None (uniform Te0)
851 py::object initTe_v,
852 // Electron temperature at initTe_r [K]
853 // [Optional] By default, None
854 py::object initY_r,
855 // Radii for initY_v [m]
856 // [Optional] By default, None (uniform Y0)
857 py::object initY_v,
858 // Mass fractions at initY_r [-], shape [n_radii, K]
859 // [Optional] By default, None
860 py::object init_rho_r,
861 // Radii for init_rho_v [m]
862 // [Optional] By default, None (uniform rho)
863 py::object init_rho_v,
864 // Mass density at init_rho_r [kg/m^3]
865 // [Optional] By default, None
866 py::object init_ur_r,
867 // Radii for init_ur_v [m]
868 // [Optional] By default, None (gas at rest)
869 py::object init_ur_v,
870 // Radial velocity at init_ur_r [m/s]; needs `hydrodynamics`
871 // [Optional] By default, None
872 //
873 // Chemistry
874 bool reacting,
875 // Include finite-rate chemistry source terms [-]
876 // [Optional] By default, true
877 py::object species_h0k,
878 // Per-species 0 K formation enthalpies [J/kmol], length K, for the
879 // inelastic threshold energies
880 // [Optional] By default, None (taken from the mechanism)
881 //
882 // Discharge
883 double electric_field,
884 // Axial electric field [V/m], constant in time
885 // [Optional] By default, 0.0
886 py::object Efield_t,
887 // Times for Efield_v [s], overriding the constant field
888 // [Optional] By default, None
889 py::object Efield_v,
890 // Axial electric field at Efield_t [V/m]
891 // [Optional] By default, None
892 double gap,
893 // Electrode gap [m], needed to turn `plasma_voltage` into a field
894 // [Optional] By default, 0.0
895 py::object plasma_voltage,
896 // Callable V_p(t [s], R_p [Ohm]) -> volts across the gap,
897 // evaluated at both ends of every global step with R_p frozen
898 // over it; incompatible with n_threads > 1
899 // [Optional] By default, None
900 //
901 // Transport and collisions, bulk closures
902 py::object sigma_T,
903 // Temperatures for sigma_v [K]
904 // [Optional] By default, None
905 py::object sigma_v,
906 // Electrical conductivity at sigma_T [S/m]
907 // [Optional] By default, None
908 double nu_E,
909 // Fixed electron-heavy energy-exchange frequency [1/s]
910 // [Optional] By default, 0.0
911 double nu_m,
912 // Fixed electron momentum-transfer frequency [1/s], from which
913 // sigma follows by the Drude relation
914 // [Optional] By default, 0.0
915 py::object nu_m_T,
916 // Temperatures for nu_m_v [K]
917 // [Optional] By default, None
918 py::object nu_m_v,
919 // Momentum-transfer frequency at nu_m_T [1/s]
920 // [Optional] By default, None
921 py::object kappa_H_T,
922 // Temperatures for kappa_H_v [K]
923 // [Optional] By default, None
924 py::object kappa_H_v,
925 // Heavy-pool thermal conductivity at kappa_H_T [W/m/K]
926 // [Optional] By default, None (conduction off)
927 py::object kappa_e_T,
928 // Temperatures for kappa_e_v [K]
929 // [Optional] By default, None
930 py::object kappa_e_v,
931 // Electron thermal conductivity at kappa_e_T [W/m/K]
932 // [Optional] By default, None (conduction off)
933 double D_species,
934 // Species diffusivity [m^2/s]; 0 disables species diffusion
935 // [Optional] By default, 0.0
936 //
937 // Transport and collisions, composition-resolved model
938 py::object xsec_energy_J,
939 // Per-species cross-section energy grids [J] (list of 1-D arrays
940 // or None per species), length K
941 // [Optional] By default, None
942 py::object xsec_sigma_m2,
943 // Per-species cross sections at xsec_energy_J [m^2], length K
944 // [Optional] By default, None
945 py::object radius_m,
946 // Per-species hard-sphere effective radii [m], length K
947 // [Optional] By default, None
948 py::object ion_Z,
949 // Per-species ion charge numbers [-], 0 for neutrals, length K
950 // [Optional] By default, None
951 double Te_min,
952 // Lower edge of the precomputed Qbar(Te) table [K]
953 // [Optional] By default, 300.0
954 double Te_max,
955 // Upper edge of the precomputed Qbar(Te) table [K]
956 // [Optional] By default, 1.0e5
957 int Te_n,
958 // Points in the precomputed Qbar(Te) table [-]
959 // [Optional] By default, 400
960 //
961 // Ambient and boundary
962 double T_amb,
963 // Ambient temperature at the wall [K]
964 // [Optional] By default, 300.0
965 double p_amb,
966 // Ambient pressure [Pa]; required when `hydrodynamics`
967 // [Optional] By default, -1.0 (unset)
968 py::object Y_amb,
969 // Ambient mass fractions [-], length K, summing to 1; required by
970 // outer_bc_mode="ambient_ghost"
971 // [Optional] By default, None
972 const std::string &outer_bc_mode,
973 // Outer boundary treatment, "ambient_ghost" or "pressure_outlet"
974 // [Optional] By default, "ambient_ghost"
975 //
976 // Hydrodynamics
977 bool hydrodynamics,
978 // Wrap each step in the explicit hydro Strang half-steps [-]
979 // [Optional] By default, false
980 double cfl,
981 // Courant number for the hydro sub-cycling [-], in (0, 1]
982 // [Optional] By default, 0.9
983 double hydro_substep,
984 // Pinned hydro sub-step [s], dividing dt/2 exactly; a test control
985 // for convergence studies, with no CFL check
986 // [Optional] By default, 0.0 (the CFL bound chooses)
987 //
988 // Time stepping
989 double dt,
990 // Global (Strang) step [s]
991 // [Optional] By default, 1.0e-9
992 std::size_t n_steps,
993 // Number of global steps [-]
994 // [Optional] By default, 1000
995 std::size_t record_every,
996 // Record one frame every this many steps [-]
997 // [Optional] By default, 1
998 //
999 // Integrator controls
1000 double rtol,
1001 // CVODE relative tolerance, shared by every component [-]
1002 // [Optional] By default, 1.0e-7
1003 double atol_T,
1004 // CVODE absolute tolerance on both temperatures [K]
1005 // [Optional] By default, 1.0e-3
1006 double atol_Y,
1007 // CVODE absolute tolerance on each mass fraction [-]
1008 // [Optional] By default, 1.0e-9
1009 bool independent_nodes,
1010 // Give each node its own CVODE instead of one banded system;
1011 // legal only with every transport term off
1012 // [Optional] By default, false
1013 std::size_t n_threads,
1014 // OpenMP threads over the per-node loop [-]; needs
1015 // `independent_nodes`
1016 // [Optional] By default, 1
1017 int loglevel
1018 // Solver verbosity [-]
1019 // [Optional] By default, 0
1020 //
1021 // Returns
1022 // -------
1023 // dict
1024 // Recorded history: t [s], r [m], the Tg/Te/ne/Y/rho/u_r/p fields
1025 // shaped [nframes, npts] (Y as [nframes, npts, nsp]), the
1026 // per-frame solver counters, and the species names.
1027 )
1028 {
1029 // Every optional property/profile table below (sigma, kappa,
1030 // kappa_e, nu_m, grid, Et) arrives as `None` when disabled;
1031 // ChannelOptions/PlasmaChannelConfig use empty vectors as that
1032 // sentinel instead, which optVec() converts.
1033 rizer::ChannelOptions options;
1034 auto& cfg = options.cfg;
1035 cfg.mech = mech;
1036 cfg.phase = phase;
1037 cfg.n_points = n_points;
1038 cfg.R_max = R_max;
1039 cfg.rho = rho;
1040 cfg.electric_field = electric_field;
1041 cfg.nu_E = nu_E;
1042 cfg.D_species = D_species;
1043 cfg.reacting = reacting;
1044 cfg.T_amb = T_amb;
1045 cfg.species_h0k = optVec(species_h0k);
1046 cfg.sigma_v = optVec(sigma_v);
1047 cfg.sigma_T = optVec(sigma_T);
1048 cfg.kappa_H_v = optVec(kappa_H_v);
1049 cfg.kappa_H_T = optVec(kappa_H_T);
1050 cfg.kappa_e_v = optVec(kappa_e_v);
1051 cfg.kappa_e_T = optVec(kappa_e_T);
1052 // Explicit radial mesh (empty -> uniform).
1053 cfg.grid = optVec(grid);
1054 // Initial profiles: the explicit radial table when given, else a
1055 // two-radius table carrying the uniform scalar. The domain takes
1056 // only tables, so the uniform case is built here rather than
1057 // doubling every field in PlasmaChannelConfig. The uniform table
1058 // spans the grid the domain will use: an explicit grid's own extent,
1059 // which need not be R_max.
1060 const double outer_radius = cfg.grid.size() >= 2 ? cfg.grid.back() : R_max;
1061 cfg.initTg_v = initTg_v.is_none() ? std::vector<double>{Tg0, Tg0}
1062 : optVec(initTg_v);
1063 cfg.initTg_r = initTg_r.is_none() ? std::vector<double>{0.0, outer_radius}
1064 : optVec(initTg_r);
1065 cfg.initTe_v = initTe_v.is_none() ? std::vector<double>{Te0, Te0}
1066 : optVec(initTe_v);
1067 cfg.initTe_r = initTe_r.is_none() ? std::vector<double>{0.0, outer_radius}
1068 : optVec(initTe_r);
1069 if (initY_v.is_none()) {
1070 // Row-major [2 * K]: the same composition at both radii.
1071 const std::vector<double> Y0_v = toVec(Y0);
1072 cfg.initY_r = {0.0, outer_radius};
1073 cfg.initY_v = Y0_v;
1074 cfg.initY_v.insert(cfg.initY_v.end(), Y0_v.begin(), Y0_v.end());
1075 } else {
1076 // initY_v is [nr, nsp]; toVec's C-order copy is the row-major
1077 // layout PlasmaChannelConfig::initY_v expects.
1078 cfg.initY_r = optVec(initY_r);
1079 const auto initY_array = initY_v.cast<py::array_t<double>>();
1080 requireMatrixShape(initY_array, cfg.initY_r.size(),
1081 static_cast<std::size_t>(Y0.size()), "initY_v");
1082 cfg.initY_v = toVec(initY_array);
1083 }
1084 cfg.nu_m = nu_m;
1085 // Te-dependent nu_m(Te) table (empty -> scalar nu_m).
1086 cfg.nu_m_T = optVec(nu_m_T);
1087 cfg.nu_m_v = optVec(nu_m_v);
1088 // Field-vs-time table (empty -> constant).
1089 options.Et_t = optVec(Efield_t);
1090 options.Et_v = optVec(Efield_v);
1091 cfg.init_rho_r = optVec(init_rho_r);
1092 cfg.init_rho_v = optVec(init_rho_v);
1093 cfg.hydrodynamics = hydrodynamics;
1094 cfg.init_ur_r = optVec(init_ur_r);
1095 cfg.init_ur_v = optVec(init_ur_v);
1096 cfg.outer_bc_mode = outer_bc_mode;
1097 cfg.p_amb = p_amb;
1098 cfg.cfl = cfl;
1099 cfg.hydro_substep = hydro_substep;
1100 // Composition-resolved collision model (empty -> legacy sigma/nu_m/nu_E
1101 // closure above). xsec_energy_J/xsec_sigma_m2/radius_m/ion_Z mirror the
1102 // Reactor0D binding exactly, one entry per species: all four must be
1103 // given together (or all omitted) since a per-species spec needs its
1104 // radius/Z slot even when it carries no tabulated cross-section.
1105 const bool any_collision_arg = !xsec_energy_J.is_none()
1106 || !xsec_sigma_m2.is_none() || !radius_m.is_none() || !ion_Z.is_none();
1107 if (any_collision_arg) {
1108 if (xsec_energy_J.is_none() || xsec_sigma_m2.is_none()
1109 || radius_m.is_none() || ion_Z.is_none()) {
1110 throw std::invalid_argument(
1111 "solve_1D_unsteady_discharge: xsec_energy_J, xsec_sigma_m2, "
1112 "radius_m, and ion_Z must all be given together (or all "
1113 "omitted) to enable the composition-resolved collision "
1114 "model.");
1115 }
1116 cfg.specs = collisionSpecs(xsec_energy_J.cast<py::list>(),
1117 xsec_sigma_m2.cast<py::list>(),
1118 radius_m.cast<py::array_t<double>>(),
1119 ion_Z.cast<py::array_t<double>>());
1120 cfg.Te_min = Te_min;
1121 cfg.Te_max = Te_max;
1122 requireTeGrid(Te_n);
1123 cfg.Te_n = static_cast<std::size_t>(Te_n);
1124 }
1125
1126 options.dt = dt;
1127 options.n_steps = n_steps;
1128 options.record_every = record_every;
1129 options.loglevel = loglevel;
1130 options.rtol = rtol;
1131 options.atol_T = atol_T;
1132 options.atol_Y = atol_Y;
1133 options.independent_nodes = independent_nodes;
1134 options.n_threads = n_threads;
1135 if (Y_amb.is_none()) {
1136 throw std::invalid_argument(
1137 "solve_1D_unsteady_discharge: Y_amb is required. It is the "
1138 "composition OUTSIDE the domain -- the wall ghost and the "
1139 "hydro inflow read it -- and it is deliberately not derived "
1140 "from Y0, which is the composition INSIDE at t=0. Pass "
1141 "Y_amb=Y0 only when the boundary genuinely holds the "
1142 "initial composition.");
1143 }
1144 cfg.Y_amb = toVec(Y_amb.cast<py::array_t<double>>());
1145 options.gap = gap;
1146 if (!plasma_voltage.is_none()) {
1147 // The solve below releases the GIL, so every call back into
1148 // Python has to take it again. Captured by value: the solve
1149 // outlives this lambda's enclosing scope only through
1150 // ChannelOptions, which owns the std::function.
1151 py::function callback = plasma_voltage;
1152 options.plasma_voltage =
1153 [callback](double t, double R_p) -> double {
1154 py::gil_scoped_acquire gil;
1155 return callback(t, R_p).cast<double>();
1156 };
1157 }
1158
1159 // A transient BDF/Newton solve over many steps can run for seconds
1160 // to minutes; release the GIL for its duration so other Python
1161 // threads (and, e.g., a Ctrl-C from the interpreter) aren't blocked.
1162 rizer::ChannelHistory history;
1163 {
1164 py::gil_scoped_release release;
1165 history = rizer::solveChannelTransient(options);
1166 }
1167
1168 py::dict out;
1169 out["t"] = py::array_t<double>(history.t.size(), history.t.data());
1170 out["r"] = py::array_t<double>(history.r.size(), history.r.data());
1171 // Shaped [nframes, npts] and [nframes, npts, nsp].
1172 out["Tg"] = py::array_t<double>(
1173 {history.nframes, history.npts}, history.Tg.data());
1174 out["Te"] = py::array_t<double>(
1175 {history.nframes, history.npts}, history.Te.data());
1176 out["ne"] = py::array_t<double>(
1177 {history.nframes, history.npts}, history.ne.data());
1178 out["Y"] = py::array_t<double>(
1179 {history.nframes, history.npts, history.nsp}, history.Y.data());
1180 out["rho"] = py::array_t<double>(
1181 {history.nframes, history.npts}, history.rho.data());
1182 out["u_r"] = py::array_t<double>(
1183 {history.nframes, history.npts}, history.u_r.data());
1184 out["p"] = py::array_t<double>(
1185 {history.nframes, history.npts}, history.p.data());
1186 out["boundary_mass_out"] = history.boundary_mass_out;
1187 out["boundary_species_mass_out"] = py::array_t<double>(
1188 history.boundary_species_mass_out.size(),
1189 history.boundary_species_mass_out.data());
1190 out["boundary_total_energy_out"] = history.boundary_total_energy_out;
1191 out["boundary_electron_energy_out"] =
1193 out["pressure_work_electron"] = history.pressure_work_electron;
1194 out["boundary_conduction_energy_out"] =
1196 out["boundary_diffusion_energy_out"] =
1198 // Per-frame CVODE counters.
1199 for (const auto& [key, values] : {
1200 std::pair{"solver_steps", &history.solver_steps},
1201 std::pair{"solver_rhs_evals", &history.solver_rhs_evals},
1202 std::pair{"solver_jac_evals", &history.solver_jac_evals},
1203 std::pair{"solver_err_test_fails",
1204 &history.solver_err_test_fails},
1205 std::pair{"solver_nonlin_conv_fails",
1206 &history.solver_nonlin_conv_fails},
1207 std::pair{"solver_last_order", &history.solver_last_order},
1208 std::pair{"solver_lin_rhs_evals",
1209 &history.solver_lin_rhs_evals},
1210 std::pair{"solver_lin_setups", &history.solver_lin_setups},
1211 }) {
1212 out[key] = py::array_t<double>(values->size(), values->data());
1213 }
1214 // Circuit history ("plasma_voltage" only; empty arrays without it).
1215 for (const auto& [key, values] : {
1216 std::pair{"Rp", &history.Rp},
1217 std::pair{"Rp_state", &history.Rp_state},
1218 std::pair{"Vp", &history.Vp},
1219 std::pair{"I", &history.I},
1220 std::pair{"E", &history.E},
1221 }) {
1222 out[key] = py::array_t<double>(values->size(), values->data());
1223 }
1224 out["species"] = history.species;
1225 out["nframes"] = history.nframes;
1226 out["npts"] = history.npts;
1227 out["nsp"] = history.nsp;
1228 return out; },
1229 // --- required: mechanism
1230 py::arg("mech"), py::arg("phase"),
1231 // --- required: grid
1232 py::arg("n_points"), py::arg("R_max"),
1233 // --- optional: grid
1234 py::arg("grid") = py::none(),
1235 // --- required: initial state
1236 py::arg("rho"), py::arg("Tg0"), py::arg("Te0"), py::arg("Y0"),
1237 // --- optional: initial radial profiles
1238 py::arg("initTg_r") = py::none(), py::arg("initTg_v") = py::none(), py::arg("initTe_r") = py::none(), py::arg("initTe_v") = py::none(), py::arg("initY_r") = py::none(), py::arg("initY_v") = py::none(), py::arg("init_rho_r") = py::none(), py::arg("init_rho_v") = py::none(), py::arg("init_ur_r") = py::none(), py::arg("init_ur_v") = py::none(),
1239 // --- optional: chemistry
1240 py::arg("reacting") = true, py::arg("species_h0k") = py::none(),
1241 // --- optional: discharge
1242 py::arg("electric_field") = 0.0, py::arg("Efield_t") = py::none(), py::arg("Efield_v") = py::none(), py::arg("gap") = 0.0, py::arg("plasma_voltage") = py::none(),
1243 // --- optional: transport and collisions, bulk closures
1244 py::arg("sigma_T") = py::none(), py::arg("sigma_v") = py::none(), py::arg("nu_E") = 0.0, py::arg("nu_m") = 0.0, py::arg("nu_m_T") = py::none(), py::arg("nu_m_v") = py::none(), py::arg("kappa_H_T") = py::none(), py::arg("kappa_H_v") = py::none(), py::arg("kappa_e_T") = py::none(), py::arg("kappa_e_v") = py::none(), py::arg("D_species") = 0.0,
1245 // --- optional: transport and collisions, composition-resolved model
1246 py::arg("xsec_energy_J") = py::none(), py::arg("xsec_sigma_m2") = py::none(), py::arg("radius_m") = py::none(), py::arg("ion_Z") = py::none(), py::arg("Te_min") = 200.0, py::arg("Te_max") = 1.0e5, py::arg("Te_n") = 400,
1247 // --- optional: ambient and boundary
1248 py::arg("T_amb") = 300.0, py::arg("p_amb") = -1.0, py::arg("Y_amb") = py::none(), py::arg("outer_bc_mode") = "ambient_ghost",
1249 // --- optional: hydrodynamics
1250 py::arg("hydrodynamics") = false, py::arg("cfl") = 0.9, py::arg("hydro_substep") = 0.0,
1251 // --- optional: time stepping
1252 py::arg("dt") = 1.0e-9, py::arg("n_steps") = 1000, py::arg("record_every") = 1,
1253 // --- optional: integrator controls
1254 py::arg("rtol") = 1.0e-7, py::arg("atol_T") = 1.0e-3, py::arg("atol_Y") = 1.0e-9, py::arg("independent_nodes") = false, py::arg("n_threads") = 1, py::arg("loglevel") = 0);
1255
1256 // ---- The Cantera-backed thermo accessor, one state at a time -------
1257 m.def("hydro_thermo_probe",
1258 // Evaluate every ThermoAccessor method at one state.
1259 //
1260 // No time stepping and no fluxes, so the equation of state can be
1261 // asserted as the state function it is -- which is what NUMERICS.md
1262 // sections "Two-temperature sound speed" and "Energy-inversion round trip", cases 8 and 9 ask for.
1263 [](
1264 // Parameters
1265 // ----------
1266 const std::string &mech,
1267 // Cantera mechanism (YAML) path
1268 const std::string &phase,
1269 // PlasmaPhase name within the mechanism
1270 double rho,
1271 // Mass density [kg/m^3]
1272 double Tg,
1273 // Heavy-pool temperature [K]
1274 double Te,
1275 // Electron temperature [K]
1276 py::array_t<double> Y,
1277 // Mass fractions [-], length nsp, summing to 1
1278 py::object Tg_seed
1279 // Seed for the heavy-temperature Newton solve [K]
1280 // [Optional] By default, None (seeded at Tg)
1281 //
1282 // Returns
1283 // -------
1284 // dict
1285 // p_heavy, p_electron [Pa]; sound_speed [m/s]; heavy_energy
1286 // [J/kg of mixture]; electron_cv [J/kg of electrons/K];
1287 // density_from_pressure [kg/m^3], which round-trips rho; and
1288 // heavy_temperature [K], which round-trips Tg from Tg_seed.
1289 )
1290 {
1291 const std::vector<double> Yv = toVec(Y);
1292 rizer::CanteraThermo& thermo = cachedCanteraThermo(mech, phase);
1293 if (Yv.size() != thermo.nSpecies()) {
1294 throw std::invalid_argument(
1295 "hydro_thermo_probe: Y has length "
1296 + std::to_string(Yv.size()) + ", expected "
1297 + std::to_string(thermo.nSpecies()) + ".");
1298 }
1299 double p_heavy = 0.0;
1300 double p_electron = 0.0;
1301 thermo.pressures(0.0, 0.0, rho, Tg, Te, Yv.data(), p_heavy, p_electron);
1302 const double heavy_energy = thermo.heavyEnergy(Tg, Yv.data());
1303 const double seed =
1304 Tg_seed.is_none() ? Tg : Tg_seed.cast<double>();
1305
1306 py::dict out;
1307 out["p_heavy"] = p_heavy;
1308 out["p_electron"] = p_electron;
1309 out["sound_speed"] =
1310 thermo.soundSpeed(0.0, 0.0, rho, Tg, Te, Yv.data());
1311 out["heavy_energy"] = heavy_energy;
1312 out["electron_cv"] = thermo.electronCv();
1313 out["density_from_pressure"] = thermo.densityFromPressure(
1314 p_heavy + p_electron, Tg, Te, Yv.data());
1315 out["heavy_temperature"] =
1316 thermo.heavyTemperature(rho, Yv.data(), heavy_energy, seed);
1317 return out; },
1318 // --- required
1319 py::arg("mech"), py::arg("phase"), py::arg("rho"), py::arg("Tg"), py::arg("Te"), py::arg("Y"),
1320 // --- optional
1321 py::arg("Tg_seed") = py::none());
1322
1323 // ---- One explicit hydrodynamic Strang half-step, plain arrays -------
1324 m.def("radial_diffusion_probe",
1325 // One node-by-node evaluation of the parabolic operator, nothing else.
1326 //
1327 // RadialDiffusion is not a sub-step: it runs inside CVODE's residual
1328 // and adds into rates the caller already holds, so there is no
1329 // "solve" to expose. This evaluates it on a state handed in, with
1330 // both capacitances set to 1, which makes every returned rate the
1331 // raw divergence -- W/m^3 for the two pools, kg/m^3/s per species.
1332 // That is what a manufactured solution can be compared against.
1333 //
1334 // A mechanism is still loaded, because the operator reads the
1335 // domain's grid, species count and per-node density. It runs no
1336 // chemistry: the probe never calls eval().
1337 [](
1338 // Parameters
1339 // ----------
1340 const std::string &mech,
1341 // Cantera mechanism (YAML) path
1342 const std::string &phase,
1343 // Phase name within the mechanism
1344 py::array_t<double> grid,
1345 // Node radii [m], length N, ascending, grid[0] = 0
1346 py::array_t<double> rho,
1347 // Mass density per node [kg/m^3], length N
1348 py::array_t<double> Tg,
1349 // Heavy-pool temperature per node [K], length N
1350 py::array_t<double> Te,
1351 // Electron temperature per node [K], length N
1352 py::array_t<double> Y,
1353 // Mass fractions [-], shape [N, K], each row summing to 1
1354 double T_amb,
1355 // Wall ghost temperature [K], the Dirichlet value both
1356 // conduction calls sit against
1357 py::array_t<double> Y_amb,
1358 // Wall ghost mass fractions [-], length K
1359 double wall_ghost_density,
1360 // Mass density of the wall ghost half-cell [kg/m^3]
1361 py::object kappa_H_T,
1362 // Heavy-pool conductivity abscissa [K], or None for no
1363 // heavy conduction
1364 // [Optional] By default, None
1365 py::object kappa_H_v,
1366 // Heavy-pool conductivity [W/m/K] at kappa_H_T
1367 // [Optional] By default, None
1368 py::object kappa_e_T,
1369 // Electron conductivity abscissa [K], or None
1370 // [Optional] By default, None
1371 py::object kappa_e_v,
1372 // Electron conductivity [W/m/K] at kappa_e_T
1373 // [Optional] By default, None
1374 double D_species
1375 // Scalar species mass diffusivity [m^2/s]; 0 switches the
1376 // species block and both energy terms it drives off
1377 // [Optional] By default, 0.0
1378 //
1379 // Returns
1380 // -------
1381 // dict
1382 // dTg, dTe [W/m^3] and dY [kg/m^3/s, shape (N, K)]: the
1383 // divergences this node-by-node operator contributes.
1384 )
1385 {
1386 const auto g = toVec(grid);
1387 const auto rho_v = toVec(rho);
1388 const auto Tg_v = toVec(Tg);
1389 const auto Te_v = toVec(Te);
1390 const auto Y_v = toVec(Y);
1391 const auto Y_amb_v = toVec(Y_amb);
1392 const std::size_t n_points = g.size();
1393 if (n_points < 2) {
1394 throw std::invalid_argument(
1395 "radial_diffusion_probe: the grid needs at least 2 nodes.");
1396 }
1397 requireLength(rho_v.size(), n_points, "rho");
1398 requireLength(Tg_v.size(), n_points, "Tg");
1399 requireLength(Te_v.size(), n_points, "Te");
1400 requireMatrixShape(Y, n_points, Y_amb_v.size(), "Y");
1401
1403 cfg.mech = mech;
1404 cfg.phase = phase;
1405 cfg.grid = g;
1406 cfg.n_points = n_points;
1407 cfg.R_max = g.back();
1408 cfg.rho = rho_v.front();
1409 // The initial profiles only have to make the constructor's state
1410 // valid; the probe overwrites x with the caller's field below. The
1411 // composition comes from that field's end rows, not from Y_amb: the
1412 // ambient may hold no electrons, and the constructor refuses a node
1413 // without them.
1414 cfg.initTg_r = {g.front(), g.back()};
1415 cfg.initTg_v = {Tg_v.front(), Tg_v.front()};
1416 cfg.initTe_r = {g.front(), g.back()};
1417 cfg.initTe_v = {Te_v.front(), Te_v.front()};
1418 const std::size_t n_species_in = Y_amb_v.size();
1419 if (Y_v.size() != n_points * n_species_in) {
1420 throw std::invalid_argument(
1421 "radial_diffusion_probe: Y has " + std::to_string(Y_v.size()) +
1422 " entries, expected " + std::to_string(n_points * n_species_in) +
1423 " (one row of Y_amb's length per grid node).");
1424 }
1425 cfg.initY_r = {g.front(), g.back()};
1426 cfg.initY_v.assign(Y_v.begin(), Y_v.begin() + n_species_in);
1427 cfg.initY_v.insert(cfg.initY_v.end(), Y_v.end() - n_species_in, Y_v.end());
1428 cfg.Y_amb = Y_amb_v;
1429 cfg.T_amb = T_amb;
1430 cfg.reacting = false;
1431 cfg.D_species = D_species;
1432 cfg.kappa_H_T = optVec(kappa_H_T);
1433 cfg.kappa_H_v = optVec(kappa_H_v);
1434 cfg.kappa_e_T = optVec(kappa_e_T);
1435 cfg.kappa_e_v = optVec(kappa_e_v);
1436
1437 rizer::PlasmaChannel1D channel(cfg);
1438 const std::size_t n_species = channel.nSpecies();
1439 if (Y_v.size() != n_points * n_species) {
1440 throw std::invalid_argument(
1441 "radial_diffusion_probe: Y has " + std::to_string(Y_v.size()) +
1442 " entries, expected " + std::to_string(n_points * n_species) + ".");
1443 }
1444 channel.setHydroState(rho_v, std::vector<double>(n_points, 0.0));
1445
1446 // The domain's own component layout, filled by hand: this probe
1447 // never goes through eval(), so nothing else populates it.
1448 const std::size_t n_vars = 2 + n_species;
1449 std::vector<double> x(n_vars * n_points, 0.0);
1450 for (std::size_t j = 0; j < n_points; j++) {
1451 x[channel.index(0, j)] = Tg_v[j];
1452 x[channel.index(1, j)] = Te_v[j];
1453 for (std::size_t k = 0; k < n_species; k++) {
1454 x[channel.index(2 + k, j)] = Y_v[j * n_species + k];
1455 }
1456 }
1457
1459 coefficients.kappa = &channel.heavyConductivity();
1460 coefficients.kappa_e = &channel.electronConductivity();
1461 coefficients.species_diffusivity = D_species;
1462 coefficients.ambient_temperature = T_amb;
1463 coefficients.wall_ghost_density = wall_ghost_density;
1464 coefficients.Y_amb = &Y_amb_v;
1465 coefficients.molecular_weights = &channel.molecularWeights();
1466 coefficients.species_cp = &channel.speciesCp();
1467 coefficients.electron_index = channel.electronIndex();
1468 coefficients.n_species = n_species;
1469
1470 std::vector<double> dTg(n_points, 0.0), dTe(n_points, 0.0);
1471 std::vector<double> dY(n_points * n_species, 0.0);
1472 std::vector<double> scratch(n_species, 0.0);
1473 for (std::size_t j = 0; j < n_points; j++) {
1474 // The enthalpy-diffusion term reads speciesCp(), a per-call
1475 // cache that eval() refreshes through sourceTerms(). Nothing
1476 // here calls sourceTerms, so this probe refreshes it itself;
1477 // without it the term would silently contribute zero.
1478 channel.updateSpeciesCp(Tg_v[j], Te_v[j],
1479 &Y_v[j * n_species], rho_v[j]);
1481 channel, Cantera::span<const double>(x.data(), x.size()),
1482 coefficients, j, /*lhsTg=*/1.0, /*lhsTe=*/1.0, dTg[j], dTe[j],
1483 &dY[j * n_species], scratch.data());
1484 }
1485
1486 py::dict out;
1487 out["dTg"] = py::array_t<double>(dTg.size(), dTg.data());
1488 out["dTe"] = py::array_t<double>(dTe.size(), dTe.data());
1489 out["dY"] = py::array_t<double>(
1490 {static_cast<py::ssize_t>(n_points),
1491 static_cast<py::ssize_t>(n_species)},
1492 dY.data());
1493 return out; },
1494 // --- required
1495 py::arg("mech"),
1496 py::arg("phase"),
1497 py::arg("grid"),
1498 py::arg("rho"),
1499 py::arg("Tg"),
1500 py::arg("Te"),
1501 py::arg("Y"),
1502 py::arg("T_amb"),
1503 py::arg("Y_amb"),
1504 py::arg("wall_ghost_density"),
1505 // --- optional
1506 py::arg("kappa_H_T") = py::none(),
1507 py::arg("kappa_H_v") = py::none(),
1508 py::arg("kappa_e_T") = py::none(),
1509 py::arg("kappa_e_v") = py::none(),
1510 py::arg("D_species") = 0.0);
1511
1512 m.def("solve_hydro_step_only",
1513 // Advance one explicit hydrodynamic Strang half-step on plain arrays.
1514 //
1515 // Solves the reduced hyperbolic system of PHYSICS.md section 5.4 --
1516 // continuity, radial momentum, species advection and the two pool
1517 // energies -- with the Rusanov flux and forward Euler of NUMERICS.md
1518 // section 5, sub-cycled to the acoustic CFL limit. No chemistry, no
1519 // Joule heating, no exchange, no conduction, no species diffusion.
1520 //
1521 // Deliberately separate from solve_1D_unsteady_discharge: it takes no
1522 // Cantera::Solution unless asked for one, so the pure-array unit tests
1523 // can cover the flux scheme, the boundary treatment and the axis
1524 // factor with nothing but numpy arrays and a gamma-law equation of
1525 // state.
1526 [](
1527 // Parameters
1528 // ----------
1529 //
1530 // Grid, from NUMERICS.md section 2
1531 py::array_t<double> r_node,
1532 // Node radii [m], length N, ascending
1533 py::array_t<double> r_face,
1534 // Face radii [m], length N+1, non-decreasing. r_face[0] = 0 makes
1535 // the axis face vanish, which IS the symmetry condition;
1536 // r_face = 1 everywhere is the planar entry point
1537 py::array_t<double> V_cell,
1538 // Cell "volumes", cross-section per unit axial length [m^2],
1539 // length N
1540 bool cylindrical_source,
1541 // Apply the cylindrical geometric momentum source p*dr/V [-].
1542 // MUST be false in planar mode: the face radii alone do not
1543 // disable it
1544 //
1545 // State in, advanced in place
1546 py::array_t<double> rho,
1547 // Mass density per node [kg/m^3], length N
1548 py::array_t<double> u_r,
1549 // Radial velocity per node [m/s], length N
1550 py::array_t<double> Tg,
1551 // Heavy-pool temperature per node [K], length N
1552 py::array_t<double> Te,
1553 // Electron temperature per node [K], length N
1554 py::array_t<double> Y,
1555 // Mass fractions [-], shape [N, K], each row summing to 1
1556 //
1557 // Equation of state
1558 const std::string &eos,
1559 // Which ThermoAccessor closes the system. Three values:
1560 //
1561 // "gamma" -- a calorically perfect gas-law accessor built from
1562 // `gamma` and `molecular_weights`. Needs no mechanism and no
1563 // Cantera object, so it is what the pure-array unit tests run
1564 // on: the pressures, the sound speed and the energy inversion
1565 // are all closed form, which makes the reference exact rather
1566 // than iterative.
1567 //
1568 // "cantera" -- the real two-temperature equation of state, read
1569 // from `mech`/`phase` through Cantera's PlasmaPhase. Species
1570 // thermo comes from the mechanism's NASA polynomials, so the
1571 // heavy-pool inversion is a Newton solve rather than a
1572 // division, and `electron_index` must be a real species. This
1573 // is the production path, and the one the split solver uses.
1574 //
1575 // "prescribed_p" -- TEST-ONLY. Pressure comes from the
1576 // `prescribed_p` callable p(r, t) while every other property
1577 // falls back to the gamma-law accessor, so a validation case
1578 // can impose a closed-form pressure field and isolate the
1579 // momentum row against it (NUMERICS.md section "Momentum alone", cases
1580 // 1-2). It needs `ghost_state`, because pressures() and
1581 // densityFromPressure() are then no longer mutually inverse
1582 // and neither outer mode could close a consistent ghost.
1583 // [Optional] By default, "gamma"
1584 double gamma,
1585 // Ratio of the HEAVY pool's specific heats,
1586 // gamma_H = c_p,H / c_v,H, for eos="gamma" [-], > 1. The
1587 // electron pool carries its own, fixed at 5/3
1588 // [Optional] By default, 1.4
1589 py::array_t<double> molecular_weights,
1590 // Per-species molecular weights for eos="gamma" [kg/kmol],
1591 // length K
1592 // [Optional] By default, empty
1593 int electron_index,
1594 // Index of the electron species in Y [-], or -1 for none.
1595 // Required by eos="cantera"
1596 // [Optional] By default, -1
1597 py::object prescribed_p,
1598 // Callable p(r, t) -> Pa for eos="prescribed_p"
1599 // [Optional] By default, None
1600 const std::string &mech,
1601 // Cantera mechanism (YAML) path for eos="cantera"
1602 // [Optional] By default, "" (unset)
1603 const std::string &phase,
1604 // Phase name within the mechanism for eos="cantera"
1605 // [Optional] By default, "" (unset)
1606 //
1607 // Outer boundary, from NUMERICS.md section 7
1608 const std::string &outer_bc_mode,
1609 // "ambient_ghost" writes the far-field state into the ghost and
1610 // anchors the domain; "pressure_outlet" imposes p = p_amb and
1611 // extrapolates the rest, and reflects
1612 // [Optional] By default, "ambient_ghost"
1613 double p_amb,
1614 // Ambient pressure closing the ghost density [Pa]
1615 // [Optional] By default, 0.0
1616 double T_amb,
1617 // Ambient temperature [K]
1618 // [Optional] By default, 0.0
1619 py::array_t<double> Y_amb,
1620 // Ambient mass fractions [-], length K, summing to 1; needed by
1621 // "ambient_ghost"
1622 // [Optional] By default, empty
1623 py::object ghost_state,
1624 // TEST-ONLY callable
1625 // (r_ghost, t, is_inner_face, rho, u_r, Tg, Te, Y) -> ghost state,
1626 // overriding outer_bc_mode. `r_ghost` is the ghost CELL's node
1627 // radius, one node spacing beyond the end node, so a closed-form
1628 // field evaluated there fills the ghost where the ghost actually
1629 // is. Deliberately absent from every
1630 // production config: it needs an answer the solver is supposed to
1631 // be computing (NUMERICS.md section 7.6)
1632 // [Optional] By default, None
1633 //
1634 // Stepping
1635 double dt_total,
1636 // Interval to advance over [s]; 0 probes the state and returns
1637 // dt_cfl without advancing it
1638 std::size_t n_subcycles,
1639 // Force this many equal sub-steps instead of CFL sub-cycling
1640 // [-]; TEST-ONLY, for cases needing a known step size
1641 // [Optional] By default, 0 (CFL)
1642 double cfl,
1643 // Courant number for the sub-cycling [-], in (0, 1]
1644 // [Optional] By default, 0.9
1645 double t0
1646 // Time the interval starts at [s], passed to the accessors and
1647 // the ghost callback
1648 // [Optional] By default, 0.0
1649 //
1650 // Returns
1651 // -------
1652 // dict
1653 // The advanced state (rho, u_r, Tg, Te, Y), the sub-cycle
1654 // diagnostics (n_subcycles_used, dt_cfl, rho_min, p_min) and the
1655 // accumulated boundary fluxes that
1656 // close NUMERICS.md section 10's identities.
1657 )
1658 {
1659
1661 cfg.r_node = toVec(r_node);
1662 cfg.r_face = toVec(r_face);
1663 cfg.V_cell = toVec(V_cell);
1664 cfg.cylindrical_source = cylindrical_source;
1665 cfg.p_amb = p_amb;
1666 cfg.T_amb = T_amb;
1667 cfg.Y_amb = toVec(Y_amb);
1668 cfg.dt_total = dt_total;
1669 cfg.n_subcycles = n_subcycles;
1670 cfg.cfl = cfl;
1671 cfg.t0 = t0;
1672 cfg.electron_index = electron_index;
1673
1674 cfg.outer_bc_mode = rizer::parseOuterBc(outer_bc_mode);
1675
1677 state.rho = toVec(rho);
1678 state.u_r = toVec(u_r);
1679 state.Tg = toVec(Tg);
1680 state.Te = toVec(Te);
1681 state.Y = toVec(Y);
1682 const std::size_t n_points = state.rho.size();
1683 if (n_points == 0) {
1684 throw std::invalid_argument(
1685 "solve_hydro_step_only: rho is empty.");
1686 }
1687 requireMatrixShape(Y, n_points, std::nullopt, "Y");
1688 state.n_species = static_cast<std::size_t>(Y.shape(1));
1689
1690 // Every accessor indexes Y by its OWN species list -- the
1691 // mechanism's for the Cantera one, molecular_weights for the
1692 // gamma-law one -- so a Y row shorter than that list is read past
1693 // its end, and for the last node past the array.
1694 const std::size_t n_weights =
1695 static_cast<std::size_t>(molecular_weights.size());
1696 if (eos != "cantera" && n_weights != state.n_species) {
1697 throw std::invalid_argument(
1698 "solve_hydro_step_only: molecular_weights has "
1699 + std::to_string(n_weights) + " entries but Y carries "
1700 + std::to_string(state.n_species)
1701 + " species per node.");
1702 }
1703
1704 // Only the accessors this call constructs are owned here; the
1705 // Cantera one outlives the call in the cache above.
1706 std::unique_ptr<rizer::ThermoAccessor> owned_thermo;
1707 const rizer::ThermoAccessor* thermo = nullptr;
1708 if (eos == "cantera") {
1709 if (mech.empty() || phase.empty()) {
1710 throw std::invalid_argument(
1711 "solve_hydro_step_only: eos=\"cantera\" needs mech and "
1712 "phase; the equation of state comes from the mechanism, "
1713 "not from gamma and molecular_weights.");
1714 }
1715 rizer::CanteraThermo& cantera = cachedCanteraThermo(mech, phase);
1716 if (cantera.nSpecies() != state.n_species) {
1717 throw std::invalid_argument(
1718 "solve_hydro_step_only: Y carries "
1719 + std::to_string(state.n_species)
1720 + " species per node but phase \"" + phase
1721 + "\" has " + std::to_string(cantera.nSpecies())
1722 + ".");
1723 }
1724 if (electron_index < 0) {
1725 // The two settings disagree about whether an electron pool
1726 // exists: CanteraThermo keeps e- out of heavyEnergy() while
1727 // electron_index = -1 skips the electron energy row, so the
1728 // pool's internal energy would sit in neither and Te would
1729 // be carried forward untouched. -1 is this binding's
1730 // default, so the combination is reachable by omission.
1731 throw std::invalid_argument(
1732 "solve_hydro_step_only: eos=\"cantera\" needs a real "
1733 "electron_index; -1 skips the electron energy row while "
1734 "the Cantera accessor keeps the electron out of the "
1735 "heavy pool, which drops its energy from both rows.");
1736 }
1737 if (static_cast<std::size_t>(electron_index) != cantera.electronIndex()) {
1738 // The energy rows have to agree on which species is the
1739 // electron: heavyEnergy() leaves out the phase's own
1740 // electron, so any other index would run the electron row on
1741 // one species while the real electron's energy sits in
1742 // neither row.
1743 throw std::invalid_argument(
1744 "solve_hydro_step_only: electron_index = "
1745 + std::to_string(electron_index)
1746 + " but phase \"" + phase + "\" has its electron at index "
1747 + std::to_string(cantera.electronIndex()) + ".");
1748 }
1749 thermo = &cantera;
1750 } else if (eos == "gamma") {
1751 owned_thermo = std::make_unique<rizer::GammaLawThermo>(
1752 gamma, toVec(molecular_weights), electron_index);
1753 thermo = owned_thermo.get();
1754 } else if (eos == "prescribed_p") {
1755 if (prescribed_p.is_none()) {
1756 throw std::invalid_argument(
1757 "solve_hydro_step_only: eos=\"prescribed_p\" needs a "
1758 "prescribed_p callable p(r, t) -> Pa.");
1759 }
1760 if (ghost_state.is_none()) {
1761 // The prescribed accessor answers pressures() from p(r, t)
1762 // but densityFromPressure() from its gamma-law backing, so
1763 // the two are not mutually inverse. Either outer mode would
1764 // then close the ghost density from p_amb while its
1765 // pressure came back as p(r_face, t), discarding p_amb and
1766 // leaving the ghost internally inconsistent.
1767 throw std::invalid_argument(
1768 "solve_hydro_step_only: eos=\"prescribed_p\" needs a "
1769 "ghost_state callback. outer_bc_mode closes the ghost "
1770 "density from p_amb through the gamma-law backing, "
1771 "which does not invert the prescribed p(r, t).");
1772 }
1773 auto fn = prescribed_p.cast<py::function>();
1774 owned_thermo = std::make_unique<rizer::PrescribedPressureThermo>(
1775 [fn](double r, double t) {
1776 return fn(r, t).cast<double>();
1777 },
1778 rizer::GammaLawThermo(gamma, toVec(molecular_weights),
1779 electron_index));
1780 thermo = owned_thermo.get();
1781 } else {
1782 throw std::invalid_argument(
1783 "solve_hydro_step_only: eos must be \"gamma\", \"cantera\" "
1784 "or \"prescribed_p\", got \"" + eos + "\".");
1785 }
1786 cfg.thermo = thermo;
1787
1788 if (!ghost_state.is_none()) {
1789 auto fn = ghost_state.cast<py::function>();
1790 const std::size_t nsp = state.n_species;
1791 cfg.ghost_state = [fn, nsp](double r_ghost, double t,
1792 bool is_inner_face,
1793 const rizer::NodeState& interior) {
1794 py::object out = fn(
1795 r_ghost, t, is_inner_face, interior.rho,
1796 interior.u_r, interior.Tg, interior.Te,
1797 py::array_t<double>(interior.Y.size(),
1798 interior.Y.data()));
1799 auto tup = out.cast<py::tuple>();
1800 if (tup.size() != 5) {
1801 throw std::invalid_argument(
1802 "solve_hydro_step_only: ghost_state must return "
1803 "(rho, u_r, Tg, Te, Y).");
1804 }
1805 rizer::NodeState ghost;
1806 ghost.rho = tup[0].cast<double>();
1807 ghost.u_r = tup[1].cast<double>();
1808 ghost.Tg = tup[2].cast<double>();
1809 ghost.Te = tup[3].cast<double>();
1810 ghost.Y = toVec(tup[4].cast<py::array_t<double>>());
1811 if (ghost.Y.size() != nsp) {
1812 throw std::invalid_argument(
1813 "solve_hydro_step_only: ghost_state returned a Y "
1814 "of the wrong length.");
1815 }
1816 return ghost;
1817 };
1818 }
1819
1820 const rizer::HydroStepResult result = rizer::hydroStep(cfg, state);
1821
1822 py::dict out;
1823 out["rho"] = py::array_t<double>(state.rho.size(), state.rho.data());
1824 out["u_r"] = py::array_t<double>(state.u_r.size(), state.u_r.data());
1825 out["Tg"] = py::array_t<double>(state.Tg.size(), state.Tg.data());
1826 out["Te"] = py::array_t<double>(state.Te.size(), state.Te.data());
1827 out["Y"] = py::array_t<double>(
1828 std::vector<py::ssize_t>{static_cast<py::ssize_t>(n_points),
1829 static_cast<py::ssize_t>(state.n_species)},
1830 state.Y.data());
1831 out["n_subcycles_used"] = result.n_subcycles_used;
1832 out["dt_cfl"] = result.dt_cfl;
1833 out["rho_min"] = result.rho_min;
1834 out["p_min"] = result.p_min;
1835 out["boundary_mass_out"] = result.boundary_mass_out;
1836 out["boundary_species_mass_out"] = py::array_t<double>(
1837 result.boundary_species_mass_out.size(),
1838 result.boundary_species_mass_out.data());
1839 out["boundary_total_energy_out"] = result.boundary_total_energy_out;
1840 out["boundary_electron_energy_out"] =
1842 out["pressure_work_electron"] = result.pressure_work_electron;
1843 return out; },
1844 // --- required: grid
1845 py::arg("r_node"), py::arg("r_face"), py::arg("V_cell"),
1846 // --- optional: grid
1847 py::arg("cylindrical_source") = true,
1848 // --- required: state in, advanced in place
1849 py::arg("rho"), py::arg("u_r"), py::arg("Tg"), py::arg("Te"), py::arg("Y"),
1850 // --- optional: equation of state
1851 py::arg("eos") = "gamma", py::arg("gamma") = 1.4, py::arg("molecular_weights") = py::array_t<double>(0), py::arg("electron_index") = -1, py::arg("prescribed_p") = py::none(), py::arg("mech") = "", py::arg("phase") = "",
1852 // --- optional: outer boundary
1853 py::arg("outer_bc_mode") = "ambient_ghost", py::arg("p_amb") = 0.0, py::arg("T_amb") = 0.0, py::arg("Y_amb") = py::array_t<double>(0), py::arg("ghost_state") = py::none(),
1854 // --- required: stepping
1855 py::arg("dt_total"),
1856 // --- optional: stepping
1857 py::arg("n_subcycles") = 0, py::arg("cfl") = 0.9, py::arg("t0") = 0.0);
1858}
Outer-boundary vocabulary for the 1D radial channel: which treatment is applied, the primitive state ...
The production ThermoAccessor for the hydro sub-step: Cantera's two-temperature PlasmaPhase behind Hy...
Calorically perfect ThermoAccessor: one constant gamma for the heavy pool, ElectronGamma for the elec...
Standalone explicit hydrodynamic sub-stepper for the 1D radial two-temperature plasma channel: the "A...
The transient two-temperature radial channel as a Cantera Domain1D: per-node source terms from Plasma...
Drives PlasmaChannel1D in time and records what it did.
The standalone 0-D two-temperature reactor: PlasmaCell plus the constant-mass volume equation,...
TEST-ONLY ThermoAccessor holding the pressure to a prescribed field p(r, t) and delegating every othe...
The parabolic half of PlasmaChannel1D::eval(): conduction in both pools, Fickian species diffusion,...
The equation-of-state seam of the hydrodynamic sub-step: the abstract accessor hydroStep() closes its...
PYBIND11_MODULE(_plasma1d, m)
Definition bindings.cpp:358
size_t index(size_t n, size_t j) const
ThermoAccessor backed by a PlasmaPhase.
std::size_t electronIndex() const
Index of the electron species, the one heavyEnergy() leaves out.
double electronCv() const override
exactly: no thermo lookup and no state, so it is the same number at every node and every step.
double heavyEnergy(double Tg, const double *Y) const override
Cantera's mixture internal energy less the electron's contribution, on the per kg of MIXTURE basis th...
double soundSpeed(double r, double t, double rho, double Tg, double Te, const double *Y) const override
summed over the heavy species alone, from the fixed ElectronGamma, over the same partial-pressure sp...
double densityFromPressure(double pressure, double Tg, double Te, const double *Y) const override
PlasmaPhase::setPressure() read back through density().
double heavyTemperature(double rho, const double *Y, double heavy_internal_energy_target, double Tg_seed) const override
Damped Newton on = target, seeded with Tg_seed and confined to [200 K, 1e5 K].
std::size_t nSpecies() const
Number of species in the loaded phase.
void pressures(double r, double t, double rho, double Tg, double Te, const double *Y, double &p_heavy, double &p_electron) const override
PlasmaPhase::electronPressure(), and the total less that.
static std::vector< double > meanCrossSectionTable(const std::vector< double > &energy_J, const std::vector< double > &sigma_m2, const std::vector< double > &Te, double x_max=20.0, int N=1000)
Tabulated variant of meanCrossSection, evaluated at each Te in the input vector.
Calorically perfect ideal-gas accessor: one constant for the heavy pool, ElectronGamma for the elect...
Transient, two-temperature (Te != Tg), finite-rate reacting 1-D radial plasma channel (custom Cantera...
const PropertyTable & electronConductivity() const
std::size_t nSpecies() const
Number of species K [-] in this domain's mechanism.
void setHydroState(const std::vector< double > &rho, const std::vector< double > &u_r)
Replace the fields the hydro sub-step owns.
std::size_t electronIndex() const
Species index of the electron [-]; the constructor rejects a mechanism without one,...
const std::vector< double > & molecularWeights() const
const PropertyTable & heavyConductivity() const
The transport tables and per-species properties the diffusion operator reads.
void updateSpeciesCp(double Tg, double Te, const double *Y, double rho) const
Refresh speciesCp() at one node's state.
const std::vector< double > & speciesCp() const
Per-species [J/kg/K], length nSpecies(), at the state the cell was last evaluated at.
Native C++ two-temperature 0D plasma reactor (stateless per-eval evaluator).
double electronDensity(double Tg, double Te, const double *Y, double rho) const
Electron number density [1/m^3].
std::size_t nSpecies() const
void rhs(double Tg, double Te, double V, const double *Y, double E, double mass, double p_ext, double polytropic_index, double *dydt) const
Full 0D RHS.
std::vector< std::string > speciesNames() const
Species names in mechanism index order, so a caller passing plain numbers across the Python boundary ...
const PlasmaCell::Diagnostics & diagnostics() const
Power / Maxwellian-condition breakdown from the most recent rhs() call (see PlasmaCell::Diagnostics) ...
double electricalConductivity(double Tg, double Te, const double *Y, double rho) const
Electrical conductivity [S/m] at state .
static PropertyTable constant(double value)
A two-point constant table returning value for any T.
Equation-of-state and caloric accessor injected into hydroStep().
string version()
string findInputFile(const string &name)
OuterBc parseOuterBc(const std::string &mode)
Map an outer_bc_mode config string onto the enum.
ChannelHistory solveChannelTransient(const ChannelOptions &opts)
Build a PlasmaChannel1D from opts.cfg, advance it from t=0 to [s] with the stiff BDF backend,...
void addRadialDiffusion(const PlasmaChannel1D &domain, Cantera::span< const double > x, const RadialDiffusionCoefficients &c, std::size_t j, double lhsTg, double lhsTe, double &dTg, double &dTe, double *dYdt, double *scratch)
Add one node's diffusive contributions to the rates the caller holds.
HydroStepResult hydroStep(const HydroStepConfig &cfg, HydroStepState &state)
Advance state over one Strang half-step of cfg.dt_total.
void registerPlasmaRates()
Register the custom plasma reaction rates with Cantera's ReactionRateFactory.
ColumnResult solveColumn(double R, double electric_field, double T_wall, const PropertyTable &sigma, const PropertyTable &kappa, const PropertyTable &p_rad, const ColumnOptions &opts)
Build, solve, and extract the radial Elenbaas-Heller column.
std::vector< double > I
Total current V_p/R_p [A].
std::vector< double > Vp
Plasma terminal voltage [V].
std::vector< double > ne
Species mass fractions [-], row-major [nframes, npts, nsp].
std::vector< double > E
Field V_p/gap [V/m].
std::vector< double > Rp_state
Resistance of the recorded state [Ohm].
std::size_t npts
Number of radial grid points [-].
std::vector< double > boundary_species_mass_out
The same, per species [kg/m/rad], length nsp, with what species diffusion carries through the wall gh...
std::vector< double > u_r
Species names, length nsp (order matches the Y axis).
std::vector< double > t
< Recorded times [s], length nframes
std::vector< double > solver_err_test_fails
std::vector< double > solver_last_order
double boundary_conduction_energy_out
What conduction (both pools) and species diffusion moved out through the wall ghost face over the run...
std::vector< double > p
Radial velocity [m/s], row-major [nframes, npts].
std::vector< std::string > species
std::size_t nsp
Number of species [-].
std::vector< double > solver_jac_evals
std::vector< double > Y
Mass density [kg/m^3], row-major [nframes, npts].
std::vector< double > solver_rhs_evals
std::vector< double > r
Gas temperature [K], row-major [nframes, npts].
std::vector< double > solver_lin_setups
std::size_t nframes
Number of recorded time frames [-].
std::vector< double > solver_steps
Per-frame CVODE counters, length nframes; the t=0 entry is zero, since nothing has been integrated ye...
std::vector< double > Rp
Circuit history, length nframes when ChannelOptions::plasma_voltage is set and empty otherwise.
std::vector< double > Tg
Electron temperature [K], row-major [nframes, npts].
std::vector< double > Te
Electron number density [1/m^3], row-major [nframes, npts].
std::vector< double > rho
Total pressure [Pa], row-major [nframes, npts].
double boundary_mass_out
What the hydrodynamic sub-step moved across the domain boundary, accumulated over every half-step of ...
std::vector< double > solver_nonlin_conv_fails
std::vector< double > solver_lin_rhs_evals
RHS calls made by the linear solver itself, forming the banded Jacobian by difference quotients.
double rtol
Error tolerances for the stiff BDF backend, which sub-steps internally until it meets them,...
std::function< double(double, double)> plasma_voltage
Closes the electrical circuit on the channel's own resistance, given and returning the plasma termin...
int loglevel
Diagnostic verbosity [-] (0 = silent; >0 prints progress).
std::size_t n_threads
Threads advancing the per-node systems of independent_nodes.
double atol_T
Absolute tolerance on Tg and Te [K].
double gap
Inter-electrode gap [m].
std::size_t n_steps
Number of output steps [-] (dt*n_steps = total time [s]).
PlasmaChannelConfig cfg
Domain physics + initial/boundary state.
std::vector< double > Et_t
Field-vs-time schedule : Et_t [s] strictly increasing, Et_v [V/m] the field at those times.
bool independent_nodes
How the reaction sub-step is partitioned.
std::vector< double > Et_v
double atol_Y
Absolute tolerance on each Y_k [-].
double dt
Output cadence / initial step [s].
std::size_t record_every
Record a frame every N output steps [-].
Solver configuration. All fields have defaults matching a typical H2 arc.
std::vector< double > init_r
Optional seed profile for the initial guess.
int loglevel
Sim1D solver verbosity [-] (0 = silent, 1 = progress, higher = more detail).
double refine_ratio
Refiner: max allowed ratio of adjacent cell widths [-].
double refine_curve
Refiner: max allowed fractional curvature of T per cell [-].
double rho_cp
Volumetric heat capacity [J/m^3/K] — scales the pseudo-transient term used as a Newton fallback.
double refine_slope
Refiner: max allowed fractional change in T slope between adjacent cells [-].
double T_center_guess
Parabolic-seed centerline temperature [K], used to build the default initial guess.
double refine_prune
Refiner: remove grid points whose contribution falls below this threshold [-].
std::vector< double > init_T
Optional seed temperatures [K] at each init_r location.
std::size_t n_points
Initial uniform grid point count [-] (refined during solve if refine_grid).
bool refine_grid
Enable Cantera adaptive grid refinement [-] (true/false) during the solve.
Output of a completed column solve.
double electric_field
Axial E field [V/m] used for this solve (echo of the input).
std::size_t n_points
Number of grid points after adaptive refinement.
std::vector< double > T
Converged temperature [K] at each grid point.
double current
Total arc current [A], trapezoidal quadrature.
std::vector< double > sigma
Electrical conductivity [S/m] evaluated at T.
std::vector< double > kappa
Thermal conductivity [W/m/K] evaluated at T.
std::vector< double > r
Radial grid [m] after refinement, r[0]=0, r[N-1]=R.
Geometry, boundary treatment and stepping controls for one call.
Definition HydroStep.h:94
const ThermoAccessor * thermo
Injected equation of state; must outlive the call.
Definition HydroStep.h:159
std::vector< double > r_node
Node radii [m], length N, ascending.
Definition HydroStep.h:110
int electron_index
Index of the electron species in Y, or -1 for none.
Definition HydroStep.h:156
std::vector< double > r_face
Face radii [m], length N+1, ascending.
Definition HydroStep.h:115
double t0
Time at the start of the call [s], passed to the thermo accessor and the ghost-state callback.
Definition HydroStep.h:152
double cfl
Courant number applied to the CFL bound [-], in (0, 1].
Definition HydroStep.h:149
double dt_total
Total interval this call covers [s] – one Strang half-step.
Definition HydroStep.h:136
double p_amb
Ambient pressure [Pa] and temperature [K].
Definition HydroStep.h:131
GhostStateFn ghost_state
Optional test-only ghost fill; overrides outer_bc_mode when set.
Definition HydroStep.h:161
std::vector< double > V_cell
Cell "volumes", cross-sectional area per unit axial length [m^2], length N.
Definition HydroStep.h:118
std::vector< double > Y_amb
Ambient composition [-], length K.
Definition HydroStep.h:133
std::size_t n_subcycles
Fixed number of equal sub-cycles of dt_total / n_subcycles; 0 means CFL-limited instead.
Definition HydroStep.h:143
bool cylindrical_source
Apply the cylindrical geometric source p_i*dr_i/V_i of equation (13).
Definition HydroStep.h:124
Diagnostics from one call, enough to assert NUMERICS.md section "Positivity under a strong rarefactio...
Definition HydroStep.h:179
std::size_t n_subcycles_used
M actually taken [-].
Definition HydroStep.h:180
double rho_min
Min rho over every node and sub-cycle [kg/m^3].
Definition HydroStep.h:184
double boundary_mass_out
Mass that left the domain across both boundary faces over the whole call, per unit axial length and p...
Definition HydroStep.h:203
double boundary_total_energy_out
TOTAL energy that left the domain across both boundary faces over the whole call, per unit axial leng...
Definition HydroStep.h:218
std::vector< double > boundary_species_mass_out
The same, per species [kg/m/rad], length K.
Definition HydroStep.h:205
double boundary_electron_energy_out
Electron-pool INTERNAL energy that left the domain across both boundary faces over the whole call,...
Definition HydroStep.h:233
double dt_cfl
CFL bound at the initial state [s].
Definition HydroStep.h:181
double p_min
Min total p over every node and sub-cycle [Pa].
Definition HydroStep.h:185
double pressure_work_electron
Compression work done BY the electron pool over the whole call, per unit axial length and per radian ...
Definition HydroStep.h:244
Per-node primitive state, advanced in place.
Definition HydroStep.h:166
std::vector< double > rho
Mass density [kg/m^3], length N.
Definition HydroStep.h:167
std::vector< double > u_r
Radial velocity [m/s], length N.
Definition HydroStep.h:168
std::size_t n_species
K [-].
Definition HydroStep.h:172
std::vector< double > Te
Electron temperature [K], length N.
Definition HydroStep.h:170
std::vector< double > Y
Mass fractions [-], row-major [N, K].
Definition HydroStep.h:171
std::vector< double > Tg
Heavy-pool temperature [K], length N.
Definition HydroStep.h:169
Primitive state at one radial node, as the ghost-state callback sees it and returns it.
double Te
Electron temperature [K].
std::vector< double > Y
Species mass fractions [-], length K.
double Tg
Heavy-pool temperature [K].
double u_r
Radial velocity [m/s].
double rho
Mass density [kg/m^3].
double nu_eH
Electron-electron collision frequency [1/s] (Mitchner II-8.11e).
Definition PlasmaCell.h:110
double P_elastic
< Elastic electron-heavy exchange power [W/m^3]
Definition PlasmaCell.h:100
double P_chemical_e
Joule heating power [W/m^3].
Definition PlasmaCell.h:106
double nu_ee
Electron-ion momentum-transfer collision frequency [1/s].
Definition PlasmaCell.h:112
double P_Joule
Electron-heavy momentum-transfer collision frequency [1/s] (Mitchner II-13.3).
Definition PlasmaCell.h:108
double P_inelastic
Heavy-species chemical power [W/m^3].
Definition PlasmaCell.h:102
double nu_eI
sum_h nu_eh/m_h [1/kg/s] (Mitchner VIII-3.8, term 2)
Definition PlasmaCell.h:114
double P_chemical_H
Electron chemical power [W/m^3].
Definition PlasmaCell.h:104
Everything PlasmaChannel1D needs to build itself.
std::vector< double > initTe_v
std::vector< double > kappa_e_T
Electron thermal conductivity table kappa_e(Te) [K], [W/m/K].
std::vector< double > initY_v
std::string mech
Cantera mechanism (YAML) file path.
double T_amb
Wall/ambient temperature T_amb [K], and the outer-wall Dirichlet ghost value for Tg.
std::vector< double > initTg_r
Tg(r) at t=0: _r is the radius axis [m], _v the temperature at that radius [K].
std::vector< double > kappa_e_v
std::vector< double > kappa_H_v
std::vector< double > Y_amb
Ambient mass fractions [-], length K, summing to 1: the composition of the gas OUTSIDE the domain.
std::size_t n_points
Number of radial grid points [-]. Ignored when grid is given.
std::vector< double > kappa_H_T
Heavy-pool thermal conductivity table kappa_H(Tg) [K], [W/m/K].
std::vector< double > initTg_v
double R_max
Outer (wall) radius R [m]. Ignored when grid is given.
double D_species
Constant scalar species mass diffusivity D [m^2/s].
bool reacting
Include finite-rate chemistry source terms [-].
std::vector< double > initTe_r
Te(r) at t=0 [m], [K]. Python: Te0 (uniform) or Te_profile.
std::vector< double > initY_r
Y(r) at t=0.
double rho
Mass density rho [kg/m^3] held at every node, unless init_rho_r/init_rho_v give a radial profile.
std::vector< double > grid
Explicit radial grid [m], increasing from 0.
std::string phase
Phase name within mech; must be a PlasmaPhase carrying "e-".
double Te_max
Upper bound of the tabulated electron-temperature grid [K].
double Te_min
Lower bound of the tabulated electron-temperature grid [K].
std::string phase
Mechanism file/URI, and phase name within it.
double gap
Inter-electrode gap [m], for the polytropic volume equation.
std::vector< CollisionModel::SpeciesSpec > specs
Per-species cross-section model (cross-section data / radius / ion Z), exactly one entry per mechanis...
std::size_t Te_n
Number of points in the tabulated Te grid [-].
std::vector< double > species_h0k
Per-species standard enthalpy of formation at 0 K [J/kmol]; forwarded to PlasmaCellConfig::species_h0...
bool reacting
If false, disables chemistry source terms in the RHS.
Everything the diffusion block needs that the grid and the state do not carry: the transport coeffici...
std::size_t electron_index
Index of the electron species [-].
const PropertyTable * kappa_e
Electron conductivity [W/m/K]; an empty table switches electron conduction off.
const std::vector< double > * Y_amb
Ambient mass fractions [-], length K: the species ghost.
double ambient_temperature
Wall/ambient temperature [K], the Dirichlet ghost both conduction calls sit against.
double species_diffusivity
Scalar species mass diffusivity [m^2/s]; zero switches the species block, and with it both energy te...
std::size_t n_species
Number of species K [-].
const PropertyTable * kappa
Heavy-pool conductivity [W/m/K]; an empty table switches heavy conduction off.
const std::vector< double > * species_cp
Per-species [J/kg/K], length K.
const std::vector< double > * molecular_weights
Per-species molecular weight [kg/kmol], length K.
double wall_ghost_density
Mass density of the wall ghost half-cell [kg/m^3], which closes the face coefficient of the outermost...