• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Open-Sn / opensn / #5

02 Apr 2026 01:04PM UTC coverage: 74.858% (-1.2%) from 76.085%
#5

push

web-flow
Merge pull request #1009 from andrsd/unit-tests

Removing `opensn-test`

20988 of 28037 relevant lines covered (74.86%)

65922489.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

76.7
/python/lib/solver.cc
1
// SPDX-FileCopyrightText: 2025 The OpenSn Authors <https://open-sn.github.io/opensn/>
2
// SPDX-License-Identifier: MIT
3

4
#include "python/lib/py_wrappers.h"
5
#include <pybind11/functional.h>
6
#include "framework/runtime.h"
7
#include "framework/field_functions/field_function_grid_based.h"
8
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/discrete_ordinates_keigen_acceleration.h"
9
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/scdsa_acceleration.h"
10
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/smm_acceleration.h"
11
#include "modules/linear_boltzmann_solvers/discrete_ordinates_curvilinear_problem/discrete_ordinates_curvilinear_problem.h"
12
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/discrete_ordinates_problem.h"
13
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/io/discrete_ordinates_problem_io.h"
14
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/discrete_ordinates_compute.h"
15
#include "modules/linear_boltzmann_solvers/solvers/transient_solver.h"
16
#include "modules/linear_boltzmann_solvers/solvers/steady_state_solver.h"
17
#include "modules/linear_boltzmann_solvers/solvers/nl_keigen_solver.h"
18
#include "modules/linear_boltzmann_solvers/solvers/pi_keigen_solver.h"
19
#include "modules/linear_boltzmann_solvers/lbs_problem/io/lbs_problem_io.h"
20
#include "modules/linear_boltzmann_solvers/lbs_problem/lbs_problem.h"
21
#include "modules/linear_boltzmann_solvers/lbs_problem/lbs_compute.h"
22
#include "modules/solver.h"
23
#include <pybind11/numpy.h>
24
#include <algorithm>
25
#include <cstddef>
26
#include <cstdint>
27
#include <map>
28
#include <memory>
29
#include <string>
30
#include <vector>
31

32
namespace opensn
33
{
34

35
// Wrap problem
36
void
37
WrapProblem(py::module& slv)
661✔
38
{
39
  // clang-format off
40
  // problem base
41
  auto problem = py::class_<Problem, std::shared_ptr<Problem>>(
661✔
42
    slv,
43
    "Problem",
44
    R"(
45
    Base class for all problems.
46

47
    Wrapper of :cpp:class:`opensn::Problem`.
48
    )"
49
  );
661✔
50
  // clang-format on
51
}
661✔
52

53
// Wrap solver
54
void
55
WrapSolver(py::module& slv)
661✔
56
{
57
  // clang-format off
58
  // solver base
59
  auto solver = py::class_<Solver, std::shared_ptr<Solver> >(
661✔
60
    slv,
61
    "Solver",
62
    R"(
63
    Base class for all solvers.
64

65
    Wrapper of :cpp:class:`opensn::Solver`.
66
    )"
67
  );
661✔
68
  solver.def(
661✔
69
    "Initialize",
70
    &Solver::Initialize,
661✔
71
    "Initialize the solver."
72
  );
73
  solver.def(
661✔
74
    "Execute",
75
    &Solver::Execute,
661✔
76
    "Execute the solver."
77
  );
78
  solver.def(
661✔
79
    "Advance",
80
    &Solver::Advance,
661✔
81
    "Advance time values function."
82
  );
83
  // clang-format on
84
}
661✔
85

86
// Wrap LBS solver
87
void
88
WrapLBS(py::module& slv)
661✔
89
{
90
  // clang-format off
91
  // LBS problem
92
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
661✔
93
    slv,
94
    "LBSProblem",
95
    R"(
96
    Base class for all linear Boltzmann problems.
97

98
    Wrapper of :cpp:class:`opensn::LBSProblem`.
99
    )"
100
  );
661✔
101
  lbs_problem.def(
661✔
102
    "GetScalarFluxFieldFunction",
103
    [](LBSProblem& self, bool only_scalar_flux)
661✔
104
    {
105
      py::list field_function_list_per_group;
434✔
106
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
22,035✔
107
      {
108
        if (only_scalar_flux)
21,601✔
109
        {
110
          field_function_list_per_group.append(self.GetScalarFluxFieldFunction(group, 0));
25,240✔
111
        }
112
        else
113
        {
114
          py::list field_function_list_per_moment;
8,981✔
115
          for (unsigned int moment = 0; moment < self.GetNumMoments(); moment++)
37,168✔
116
          {
117
            field_function_list_per_moment.append(self.GetScalarFluxFieldFunction(group, moment));
56,374✔
118
          }
119
          field_function_list_per_group.append(field_function_list_per_moment);
8,981✔
120
        }
8,981✔
121
      }
122
      return field_function_list_per_group;
434✔
123
    },
×
124
    R"(
125
    Return scalar-flux or flux-moment field functions grouped by energy group.
126

127
    Parameters
128
    ----------
129
    only_scalar_flux : bool, default=True
130
        If True, returns only the zeroth moment (scalar flux) field function for each group.
131
        If False, returns all moment field functions for each group.
132

133
    Returns
134
    -------
135
    Union[List[pyopensn.fieldfunc.FieldFunctionGridBased], List[List[pyopensn.fieldfunc.FieldFunctionGridBased]]]
136
        If ``only_scalar_flux=True``:
137
        ``result[g]`` is the scalar-flux field function for group ``g``.
138

139
        If ``only_scalar_flux=False``:
140
        ``result[g][m]`` is the field function for group ``g`` and moment ``m``.
141

142
    Notes
143
    -----
144
    Flux-moment field functions are allocated lazily.
145

146
    The zeroth-moment field function for each group is created eagerly during solver
147
    initialization, so scalar-flux output is always available.
148

149
    Higher-moment field functions are created on demand. Calling
150
    ``GetScalarFluxFieldFunction(only_scalar_flux=False)`` allocates all higher moments
151
    and initializes them from the current solver state.
152

153
    This is a substantial memory saver for typical problems, because most workflows only
154
    need scalar-flux output and do not need to store field functions for every moment.
155

156
    In the nested form (``only_scalar_flux=False``), the moment index varies fastest
157
    within each group (inner index = moment, outer index = group).
158
    )",
159
    py::arg("only_scalar_flux") = true
661✔
160
  );
161
  lbs_problem.def(
661✔
162
    "GetPowerFieldFunction",
163
    &LBSProblem::GetPowerFieldFunction,
661✔
164
    R"(
165
    Returns the power generation field function, if enabled.
166
    )"
167
  );
168
  lbs_problem.def(
661✔
169
    "GetTime",
170
    &LBSProblem::GetTime,
661✔
171
    R"(
172
    Get the current simulation time in seconds.
173
    )"
174
  );
175
  lbs_problem.def(
661✔
176
    "GetTimeStep",
177
    &LBSProblem::GetTimeStep,
661✔
178
    R"(
179
    Get the current timestep size.
180
    )"
181
  );
182
  lbs_problem.def(
661✔
183
    "ComputeFissionRate",
184
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
661✔
185
    {
186
      const std::vector<double>* phi_ptr = nullptr;
×
187
      if (scalar_flux_iterate == "old")
×
188
      {
189
        phi_ptr = &self.GetPhiOldLocal();
×
190
      }
191
      else if (scalar_flux_iterate == "new")
×
192
      {
193
        phi_ptr = &self.GetPhiNewLocal();
×
194
      }
195
      else
196
      {
197
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
198
      }
199
      return ComputeFissionRate(self, *phi_ptr);
×
200
    },
201
    R"(
202
    Computes the total fission rate.
203

204
    Parameters
205
    ----------
206
    scalar_flux_iterate : {'old', 'new'}
207
        Specifies which scalar flux vector to use in the calculation.
208
            - 'old': Use the previous scalar flux iterate.
209
            - 'new': Use the current scalar flux iterate.
210

211
    Returns
212
    -------
213
    float
214
        The total fission rate.
215

216
    Raises
217
    ------
218
    ValueError
219
        If `scalar_flux_iterate` is not 'old' or 'new'.
220
    )",
221
    py::arg("scalar_flux_iterate")
661✔
222
  );
223
  lbs_problem.def(
661✔
224
    "ComputeFissionProduction",
225
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,457✔
226
    {
227
      const std::vector<double>* phi_ptr = nullptr;
796✔
228
      if (scalar_flux_iterate == "old")
796✔
229
      {
230
        phi_ptr = &self.GetPhiOldLocal();
44✔
231
      }
232
      else if (scalar_flux_iterate == "new")
752✔
233
      {
234
        phi_ptr = &self.GetPhiNewLocal();
752✔
235
      }
236
      else
237
      {
238
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
239
      }
240
      return ComputeFissionProduction(self, *phi_ptr);
796✔
241
    },
242
    R"(
243
    Computes the total fission production (nu*fission).
244

245
    Parameters
246
    ----------
247
    scalar_flux_iterate : {'old', 'new'}
248
        Specifies which scalar flux vector to use in the calculation.
249
            - 'old': Use the previous scalar flux iterate.
250
            - 'new': Use the current scalar flux iterate.
251

252
    Returns
253
    -------
254
    float
255
        The total fission production.
256

257
    Raises
258
    ------
259
    ValueError
260
        If `scalar_flux_iterate` is not 'old' or 'new'.
261
    )",
262
    py::arg("scalar_flux_iterate")
661✔
263
  );
264
  lbs_problem.def(
661✔
265
    "GetPhiOldLocal",
266
    [](LBSProblem& self)
709✔
267
    {
268
      return convert_vector(self.GetPhiOldLocal());
48✔
269
    },
270
    R"(
271
    Get the previous scalar flux iterate (local vector).
272

273
    Returns
274
    -------
275
    memoryview
276
        Memory view of the local old scalar flux vector.
277
    )"
278
  );
279
  lbs_problem.def(
661✔
280
    "GetPhiNewLocal",
281
    [](LBSProblem& self)
717✔
282
    {
283
      return convert_vector(self.GetPhiNewLocal());
56✔
284
    },
285
    R"(
286
    Get the current scalar flux iterate (local vector).
287

288
    Returns
289
    -------
290
    memoryview
291
        Memory view of the local new scalar flux vector.
292
    )"
293
  );
294
  lbs_problem.def(
661✔
295
    "WriteFluxMoments",
296
    [](LBSProblem& self, const std::string& file_base)
693✔
297
    {
298
      LBSSolverIO::WriteFluxMoments(self, file_base);
32✔
299
    },
300
    R"(
301
    Write flux moments to file.
302

303
    Parameters
304
    ----------
305
    file_base: str
306
        File basename.
307
    )",
308
    py::arg("file_base")
661✔
309
  );
310
  lbs_problem.def(
661✔
311
    "CreateAndWriteSourceMoments",
312
    [](LBSProblem& self, const std::string& file_base)
665✔
313
    {
314
      std::vector<double> source_moments = self.MakeSourceMomentsFromPhi();
4✔
315
      LBSSolverIO::WriteFluxMoments(self, file_base, source_moments);
4✔
316
    },
4✔
317
    R"(
318
    Write source moments from latest flux iterate to file.
319

320
    Parameters
321
    ----------
322
    file_base: str
323
        File basename.
324
    )",
325
    py::arg("file_base")
661✔
326
  );
327
  lbs_problem.def(
661✔
328
    "ReadFluxMomentsAndMakeSourceMoments",
329
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
661✔
330
    {
331
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
×
332
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
×
333
      self.SetExtSrcMomentsFrom(ext_src_moments);
×
334
      log.Log() << "Making source moments from flux file.";
×
335
      const auto temp_phi = self.GetPhiOldLocal();
×
336
      self.SetPhiOldFrom(self.GetExtSrcMomentsLocal());
×
337
      self.SetExtSrcMomentsFrom(self.MakeSourceMomentsFromPhi());
×
338
      self.SetPhiOldFrom(temp_phi);
×
339
    },
×
340
    R"(
341
    Read flux moments and compute corresponding source moments.
342

343
    Parameters
344
    ----------
345
    file_base: str
346
        File basename.
347
    single_file_flag: bool
348
        True if all flux moments are in a single file.
349
    )",
350
    py::arg("file_base"),
1,322✔
351
    py::arg("single_file_flag")
661✔
352
  );
353
  lbs_problem.def(
661✔
354
    "ReadSourceMoments",
355
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
665✔
356
    {
357
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
4✔
358
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
4✔
359
      self.SetExtSrcMomentsFrom(ext_src_moments);
4✔
360
    },
4✔
361
    R"(
362
    Read source moments from file.
363

364
    Parameters
365
    ----------
366
    file_base: str
367
        File basename.
368
    single_file_flag: bool
369
        True if all source moments are in a single file.
370
    )",
371
    py::arg("file_base"),
1,322✔
372
    py::arg("single_file_flag")
661✔
373
  );
374
  lbs_problem.def(
661✔
375
    "ReadFluxMoments",
376
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
661✔
377
    {
378
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag);
×
379
    },
380
    R"(
381
    Read flux moment data.
382

383
    Parameters
384
    ----------
385
    file_base: str
386
        File basename.
387
    single_file_flag: bool
388
        True if all flux moments are in a single file.
389
    )",
390
    py::arg("file_base"),
1,322✔
391
    py::arg("single_file_flag")
661✔
392
  );
393
  lbs_problem.def(
661✔
394
    "WriteAngularFluxes",
395
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
665✔
396
    {
397
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
4✔
398
    },
399
    R"(
400
    Write angular flux data to file.
401

402
    Parameters
403
    ----------
404
    file_base: str
405
        File basename.
406
    )",
407
    py::arg("file_base")
661✔
408
  );
409
  lbs_problem.def(
661✔
410
    "ReadAngularFluxes",
411
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
665✔
412
    {
413
      DiscreteOrdinatesProblemIO::ReadAngularFluxes(self, file_base);
4✔
414
    },
415
    R"(
416
    Read angular fluxes from file.
417

418
    Parameters
419
    ----------
420
    file_base: str
421
        File basename.
422
    )",
423
    py::arg("file_base")
661✔
424
  );
425
  lbs_problem.def(
661✔
426
    "SetPointSources",
427
    [](LBSProblem& self, py::kwargs& params)
661✔
428
    {
429
      for (auto [key, value] : params)
×
430
      {
431
        auto c_key = key.cast<std::string>();
×
432
        if (c_key == "clear_point_sources")
×
433
          self.ClearPointSources();
×
434
        else if (c_key == "point_sources")
×
435
        {
436
          auto sources = value.cast<py::list>();
×
437
          for (auto source : sources)
×
438
          {
439
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
440
          }
441
        }
×
442
        else
443
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
444
      }
×
445
    },
×
446
    R"(
447
    Set or clear point sources.
448

449
    Parameters
450
    ----------
451
    clear_point_sources: bool, default=False
452
        If true, all current the point sources of the problem are deleted.
453
    point_sources: List[pyopensn.source.PointSource]
454
        List of new point sources to be added to the problem.
455
    )"
456
  );
457
  lbs_problem.def(
661✔
458
    "SetVolumetricSources",
459
    [](LBSProblem& self, py::kwargs& params)
697✔
460
    {
461
      for (auto [key, value] : params)
108✔
462
      {
463
        auto c_key = key.cast<std::string>();
36✔
464
        if (c_key == "clear_volumetric_sources")
36✔
465
          self.ClearVolumetricSources();
12✔
466
        else if (c_key == "volumetric_sources")
24✔
467
        {
468
          auto sources = value.cast<py::list>();
24✔
469
          for (auto source : sources)
72✔
470
          {
471
            self.AddVolumetricSource(source.cast<std::shared_ptr<VolumetricSource>>());
48✔
472
          }
473
        }
24✔
474
        else
475
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
476
      }
36✔
477
    },
36✔
478
    R"(
479
    Set or clear volumetric sources.
480

481
    Parameters
482
    ----------
483
    clear_volumetric_sources: bool, default=False
484
        If true, all current the volumetric sources of the problem are deleted.
485
    volumetric_sources: List[pyopensn.source.VolumetricSource]
486
        List of new volumetric sources to be added to the problem.
487
    )"
488
  );
489
  lbs_problem.def(
661✔
490
    "SetXSMap",
491
    [](LBSProblem& self, py::kwargs& params)
833✔
492
    {
493
      BlockID2XSMap xs_map;
172✔
494
      for (auto [key, value] : params)
516✔
495
      {
496
        auto c_key = key.cast<std::string>();
172✔
497
        if (c_key == "xs_map")
172✔
498
        {
499
          auto xs_entries = value.cast<py::list>();
172✔
500
          for (auto entry : xs_entries)
516✔
501
          {
502
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
172✔
503
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
172✔
504
            const auto& block_ids =
172✔
505
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
172✔
506
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
172✔
507
            for (const auto& block_id : block_ids)
344✔
508
              xs_map[block_id] = xs;
172✔
509
          }
172✔
510
        }
172✔
511
        else
512
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
513
      }
172✔
514
      self.SetBlockID2XSMap(xs_map);
172✔
515
    },
172✔
516
    R"(
517
    Replace the block-id to cross-section map.
518

519
    Parameters
520
    ----------
521
    xs_map: List[Dict]
522
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
523
          - block_ids: List[int] (required)
524
              Mesh block ids to associate with the cross section.
525
          - xs: pyopensn.xs.MultiGroupXS (required)
526
              Cross section object.
527

528
    Notes
529
    -----
530
    Forward/adjoint mode toggles via :meth:`LBSProblem.SetAdjoint` do not change this map.
531
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer. If the same
532
    ``MultiGroupXS`` handle is shared across multiple problems, toggling adjoint mode in one
533
    problem also changes the transport mode seen by the others.
534
    )"
535
  );
536
  lbs_problem.def(
661✔
537
    "ZeroPhi",
538
    [](LBSProblem& self)
661✔
539
    {
540
      self.ZeroPhi();
×
541
    },
542
    R"(
543
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
544
    )"
545
  );
546
  lbs_problem.def(
661✔
547
    "ZeroPsi",
548
    [](LBSProblem& self)
661✔
549
    {
550
      self.ZeroPsi();
×
551
    },
552
    R"(
553
    Zero the angular-flux storage (if present for this problem type).
554
    )"
555
  );
556
  lbs_problem.def(
661✔
557
    "SetAdjoint",
558
    [](LBSProblem& self, bool adjoint)
661✔
559
    {
560
      self.SetAdjoint(adjoint);
24✔
561
    },
562
    py::arg("adjoint") = true,
661✔
563
    R"(
564
    Set forward/adjoint transport mode.
565

566
    Parameters
567
    ----------
568
    adjoint: bool, default=True
569
        ``True`` enables adjoint mode and ``False`` enables forward mode.
570

571
    Notes
572
    -----
573
    This is one of two supported mode-setting paths in Python:
574
      1. ``options={'adjoint': ...}`` in the problem constructor.
575
      2. ``SetAdjoint(...)`` (this method).
576

577
    If this changes mode, OpenSn performs a full mode-transition reset:
578
      - Materials are reinitialized in the selected mode.
579
      - Point and volumetric sources are cleared.
580
      - Boundary conditions are cleared.
581
      - Scalar and angular flux vectors are zeroed.
582

583
    If this is called with the same mode as the current setting, no reset is performed.
584

585
    The block-id to cross-section map is preserved across the transition.
586
    However, the mode change is applied to the mapped ``MultiGroupXS`` objects themselves.
587
    If those objects are shared with other problems, they observe the same mode toggle.
588

589
    After a mode change, reapply the desired driving terms before solving, typically:
590
      - :meth:`LBSProblem.SetPointSources`
591
      - :meth:`LBSProblem.SetVolumetricSources`
592
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
593

594
    This routine is intentionally destructive with respect to source/boundary/flux state
595
    to avoid hidden coupling between forward and adjoint setups.
596
    )"
597
  );
598
  lbs_problem.def(
661✔
599
    "SetForward",
600
    &LBSProblem::SetForward,
661✔
601
    R"(
602
    Set forward transport mode.
603

604
    Equivalent to ``SetAdjoint(False)``.
605
    )"
606
  );
607
  lbs_problem.def(
661✔
608
    "IsAdjoint",
609
    &LBSProblem::IsAdjoint,
661✔
610
    R"(
611
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
612
    )"
613
  );
614
  lbs_problem.def(
661✔
615
    "IsTimeDependent",
616
    &LBSProblem::IsTimeDependent,
661✔
617
    R"(
618
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
619
    )"
620
  );
621

622
  // discrete ordinate solver
623
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
661✔
624
                               LBSProblem>(
625
    slv,
626
    "DiscreteOrdinatesProblem",
627
    R"(
628
    Base class for discrete ordinates problems in Cartesian geometry.
629

630
    This class implements the algorithms necessary to solve a problem using the discrete ordinates method.
631
    When paired with a solver base, the result is a solver instance configured for a specific problem type
632
    (steady-state, transient, adjoint, k-eigenvalue, etc.).
633

634
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
635
    )"
636
  );
661✔
637
  do_problem.def(
1,322✔
638
    py::init(
661✔
639
      [](py::kwargs& params)
563✔
640
      {
641
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
563✔
642
      }
643
    ),
644
    R"(
645
    Construct a discrete ordinates problem with Cartesian geometry.
646

647
    Parameters
648
    ----------
649
    mesh : MeshContinuum
650
        The spatial mesh.
651
    num_groups : int
652
        The total number of energy groups.
653
    groupsets : List[Dict], default=[]
654
        A list of input parameter blocks, each block provides the iterative properties for a
655
        groupset. Each dictionary supports:
656
          - groups_from_to: List[int] (required)
657
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
658
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
659
              Handle to an angular quadrature.
660
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
661
              Angle aggregation method to use during sweeping.
662
          - angle_aggregation_num_subsets: int, default=1
663
              Number of angle subsets used for aggregation.
664
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
665
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
666
              Iterative method used for inner linear solves.
667
          - l_abs_tol: float, default=1.0e-6
668
              Inner linear solver absolute residual tolerance.
669
          - l_max_its: int, default=200
670
              Inner linear solver maximum iterations.
671
          - gmres_restart_interval: int, default=30
672
              GMRES restart interval, if GMRES is used.
673
          - allow_cycles: bool, default=True
674
              Whether cyclic dependencies are allowed in sweeps.
675
          - apply_wgdsa: bool, default=False
676
              Enable within-group DSA for this groupset.
677
          - wgdsa_l_abs_tol: float, default=1.0e-4
678
              WGDSA linear absolute tolerance.
679
          - wgdsa_l_max_its: int, default=30
680
              WGDSA maximum iterations.
681
          - wgdsa_verbose: bool, default=False
682
              Verbose WGDSA output.
683
          - wgdsa_petsc_options: str, default=''
684
              PETSc options string for the WGDSA solver.
685
          - apply_tgdsa: bool, default=False
686
              Enable two-grid DSA for this groupset.
687
          - tgdsa_l_abs_tol: float, default=1.0e-4
688
              TGDSA linear absolute tolerance.
689
          - tgdsa_l_max_its: int, default=30
690
              TGDSA maximum iterations.
691
          - tgdsa_verbose: bool, default=False
692
              Verbose TGDSA output.
693
          - tgdsa_petsc_options: str, default=''
694
              PETSc options string for the TGDSA solver.
695
    xs_map : List[Dict], default=[]
696
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
697
          - block_ids: List[int] (required)
698
              Mesh block IDs to associate with the cross section.
699
          - xs: pyopensn.xs.MultiGroupXS (required)
700
              Cross-section object to assign to the specified blocks.
701
    boundary_conditions: List[Dict], default=[]
702
        A list containing tables for each boundary specification. Each dictionary supports:
703
          - name: str (required)
704
              Boundary name that identifies the specific boundary.
705
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
706
              Boundary type specification.
707
          - group_strength: List[float], optional
708
              Required when ``type='isotropic'``. Isotropic strength per group.
709
          - function: AngularFluxFunction, optional
710
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
711
    point_sources: List[pyopensn.source.PointSource], default=[]
712
        A list of point sources.
713
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
714
        A list of volumetric sources.
715
    options : Dict, default={}
716
        A block of optional configuration parameters, including:
717
          - max_mpi_message_size: int, default=32768
718
          - restart_writes_enabled: bool, default=False
719
          - write_delayed_psi_to_restart: bool, default=True
720
          - read_restart_path: str, default=''
721
          - write_restart_path: str, default=''
722
          - write_restart_time_interval: int, default=0
723
            (must be 0 or >=30)
724
          - use_precursors: bool, default=False
725
          - use_source_moments: bool, default=False
726
          - save_angular_flux: bool, default=False
727
          - adjoint: bool, default=False
728
          - verbose_inner_iterations: bool, default=True
729
          - verbose_outer_iterations: bool, default=True
730
          - max_ags_iterations: int, default=100
731
          - ags_tolerance: float, default=1.0e-6
732
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
733
          - verbose_ags_iterations: bool, default=True
734
          - power_field_function_on: bool, default=False
735
          - power_default_kappa: float, default=3.20435e-11
736
          - power_normalization: float, default=-1.0
737
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
738
          - field_function_prefix: str, default=''
739
        These options are applied at problem creation.
740
    sweep_type : str, default="AAH"
741
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
742
    time_dependent : bool, default=False
743
        If true, the problem starts in time-dependent mode. Otherwise it starts in
744
        steady-state mode. Requires ``options.save_angular_flux=True``.
745
    use_gpus : bool, default=False
746
        A flag specifying whether GPU acceleration is used for the sweep. Currently, only ``AAH`` is
747
        supported.
748
    )"
749
  );
750
  do_problem.def(
661✔
751
    "SetTimeDependentMode",
752
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
661✔
753
    R"(
754
    Set the problem to time-dependent mode.
755

756
    Notes
757
    -----
758
    Switch problem from steady-state to time-dependent mode. This updates problem
759
    internals (sweep chunk mode and source-function) while preserving user boundary
760
    conditions and fixed sources.
761

762
    Requires ``options.save_angular_flux=True`` at problem creation.
763
    )"
764
  );
765
  do_problem.def(
661✔
766
    "SetSteadyStateMode",
767
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
661✔
768
    R"(
769
    Set the problem to steady-state mode.
770

771
    Notes
772
    -----
773
    Switch problem from time-dependent to steady-state mode. This updates problem
774
    internals (sweep chunk mode and source-function) while preserving user boundary
775
    conditions and fixed sources.
776
    )"
777
  );
778
  do_problem.def(
661✔
779
    "IsTimeDependent",
780
    &DiscreteOrdinatesProblem::IsTimeDependent,
661✔
781
    R"(
782
    Return ``True`` if the problem is currently in time-dependent mode.
783
    )"
784
  );
785
  do_problem.def(
661✔
786
    "SetBoundaryOptions",
787
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
665✔
788
    {
789
      for (auto [key, value] : params)
12✔
790
      {
791
        auto c_key = key.cast<std::string>();
4✔
792
        if (c_key == "clear_boundary_conditions")
4✔
793
          self.ClearBoundaries();
×
794
        else if (c_key == "boundary_conditions")
4✔
795
        {
796
          auto boundaries = value.cast<py::list>();
4✔
797
          for (auto boundary : boundaries)
24✔
798
          {
799
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
16✔
800
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
16✔
801
            self.SetBoundaryOptions(input);
16✔
802
          }
16✔
803
        }
4✔
804
        else
805
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
806
      }
4✔
807
    },
4✔
808
    R"(
809
    Set or clear boundary conditions.
810

811
    Parameters
812
    ----------
813
    clear_boundary_conditions: bool, default=False
814
        If true, all current boundary conditions are deleted.
815
    boundary_conditions: List[Dict]
816
        A list of boundary condition dictionaries. Each dictionary supports:
817
          - name: str (required)
818
              Boundary name that identifies the specific boundary.
819
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
820
              Boundary type specification.
821
          - group_strength: List[float], optional
822
              Required when ``type='isotropic'``. Isotropic strength per group.
823
          - function: AngularFluxFunction, optional
824
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
825

826
    Notes
827
    -----
828
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
829
    Reapply boundaries with this method before solving in the new mode.
830
    )"
831
  );
832
  do_problem.def(
661✔
833
    "GetPsi",
834
    [](DiscreteOrdinatesProblem& self)
669✔
835
    {
836
      const auto& psi = self.GetPsiNewLocal();
8✔
837
      py::list psi_list;
8✔
838
      for (const auto& vec : psi)
16✔
839
      {
840
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()),
8✔
841
                                         vec.data(),
842
                                         py::cast(self));
8✔
843
        psi_list.append(array);
8✔
844
      }
8✔
845
      return psi_list;
8✔
846
    },
×
847
    R"(
848
    Return psi as a list of NumPy arrays (float64), using zero-copy views into the
849
    underlying data.
850
    )"
851
  );
852
  do_problem.def(
661✔
853
    "GetAngularFieldFunctionList",
854
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
661✔
855
    {
856
      std::vector<unsigned int> group_ids;
×
857
      std::vector<size_t> angle_ids;
×
858
      group_ids.reserve(groups.size());
×
859
      angle_ids.reserve(angles.size());
×
860

861
      for (py::handle g : groups)
×
862
        group_ids.push_back(g.cast<unsigned int>());
×
863
      for (py::handle a : angles)
×
864
        angle_ids.push_back(a.cast<size_t>());
×
865

866
      auto ff_list = self.GetAngularFluxFieldFunctionList(group_ids, angle_ids);
×
867
      py::list out;
×
868
      for (const auto& ff : ff_list)
×
869
        out.append(ff);
×
870
      return out;
×
871
    },
×
872
    R"(
873
    Return field functions for selected angular flux components.
874

875
    Note: You must enable angular flux storage (``save_angular_flux=True``) in
876
    the problem options, otherwise the field functions will remain zero.
877

878
    Example
879
    -------
880
    ```python
881
    solver.Initialize()
882
    solver.Execute()
883
    ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
884
    ```
885

886
    For transient/time-dependent runs, call this after each timestep. Two common
887
    patterns are:
888

889
    1) Use `TransientSolver.Execute()` with a post-advance callback:
890
    ```python
891
    solver = TransientSolver(problem=phys)
892
    solver.Initialize()
893

894
    def post_advance():
895
        ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
896
        FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
897

898
    solver.SetPostAdvanceCallback(post_advance)
899
    solver.Execute()
900
    ```
901

902
    2) Use a custom Python loop with `TransientSolver.Advance()`:
903
    ```python
904
    solver = TransientSolver(problem=phys)
905
    solver.Initialize()
906
    for _ in range(num_steps):
907
        solver.Advance()
908
        ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
909
        FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
910
    ```
911

912
    Parameters
913
    ----------
914
    groups : List[int]
915
        Global group indices to export.
916
    angles : List[int]
917
        Angle indices within the groupset quadrature for each group.
918

919
    Returns
920
    -------
921
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
922
        Field functions for the requested (group, angle) pairs.
923
    )",
924
    py::arg("groups"),
1,322✔
925
    py::arg("angles")
661✔
926
  );
927
  do_problem.def(
661✔
928
    "ComputeLeakage",
929
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
700✔
930
    {
931
      auto grid = self.GetGrid();
39✔
932
      // get the supported boundaries
933
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
39✔
934
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
39✔
935
      const auto coord_sys = grid->GetCoordinateSystem();
39✔
936
      const auto mesh_type = grid->GetType();
39✔
937
      const auto dim = grid->GetDimension();
39✔
938
      // get the boundaries to parse, preserving user order
939
      std::vector<std::uint64_t> bndry_ids;
39✔
940
      if (bnd_names.size() > 1)
39✔
941
      {
942
        for (py::handle name : bnd_names)
184✔
943
        {
944
          auto sname = name.cast<std::string>();
112✔
945
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
112✔
946
          {
947
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24✔
948
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
949
                                       "' is invalid for cylindrical orthogonal meshes. "
950
                                       "Use rmin, rmax, zmin, zmax.");
×
951

952
            if (mesh_type == MeshType::ORTHOGONAL)
24✔
953
            {
954
              if (sname == "rmin") sname = "xmin";
24✔
955
              else if (sname == "rmax") sname = "xmax";
24✔
956
              else if (sname == "zmin") sname = "ymin";
16✔
957
              else if (sname == "zmax") sname = "ymax";
8✔
958
            }
959
          }
960
          bndry_ids.push_back(allowed_bd_names.at(sname));
112✔
961
        }
112✔
962
      }
963
      else
964
      {
965
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
9✔
966
      }
967
      // compute the leakage
968
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
39✔
969
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
78✔
970
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
39✔
971

972
      auto to_rz_name = [](const std::string& name)
63✔
973
      {
974
        if (name == "xmin") return std::string("rmin");
24✔
975
        if (name == "xmax") return std::string("rmax");
24✔
976
        if (name == "ymin") return std::string("zmin");
16✔
977
        if (name == "ymax") return std::string("zmax");
8✔
978
        return name;
×
979
      };
980

981
      // convert result to native Python
982
      py::dict result;
39✔
983
      for (const auto& bndry_id : bndry_ids)
157✔
984
      {
985
        const auto it = leakage.find(bndry_id);
118✔
986
        if (it == leakage.end())
118✔
987
          continue;
×
988
        // construct numpy array and copy contents
989
        const auto& grp_wise_leakage = it->second;
118✔
990
        py::array_t<double> np_vector(py::ssize_t(grp_wise_leakage.size()));
118✔
991
        auto buffer = np_vector.request();
118✔
992
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
118✔
993
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
118✔
994
        std::string name = allowed_bd_ids.at(bndry_id);
118✔
995
        if (rz_ortho)
118✔
996
          name = to_rz_name(name);
24✔
997
        result[py::str(name)] = std::move(np_vector);
236✔
998
      }
118✔
999

1000
      return result;
39✔
1001
    },
78✔
1002
    R"(
1003
    Compute leakage for the problem.
1004

1005
    Parameters
1006
    ----------
1007
    bnd_names : List[str]
1008
        A list of boundary names for which leakage should be computed.
1009

1010
    Returns
1011
    -------
1012
    Dict[str, numpy.ndarray]
1013
        A dictionary mapping boundary names to group-wise leakage vectors.
1014
        Each array contains the outgoing angular flux (per group) integrated over
1015
        the corresponding boundary surface.
1016

1017
    Raises
1018
    ------
1019
    RuntimeError
1020
        If `save_angular_flux` option was not enabled during problem setup.
1021

1022
    ValueError
1023
        If one or more boundary ids are not present on the current mesh.
1024
    )",
1025
    py::arg("bnd_names")
661✔
1026
  );
1027

1028
  // discrete ordinates curvilinear problem
1029
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
661✔
1030
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1031
                                           DiscreteOrdinatesProblem>(
1032
    slv,
1033
    "DiscreteOrdinatesCurvilinearProblem",
1034
    R"(
1035
    Base class for discrete ordinates problems in curvilinear geometry.
1036

1037
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1038
    )"
1039
  );
661✔
1040
  do_curvilinear_problem.def(
1,322✔
1041
    py::init(
661✔
1042
      [](py::kwargs& params)
80✔
1043
      {
1044
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
80✔
1045
      }
1046
    ),
1047
    R"(
1048
    Construct a discrete ordinates problem for curvilinear geometry.
1049

1050
    Warnings
1051
    --------
1052
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1053

1054
    Parameters
1055
    ----------
1056
    mesh : MeshContinuum
1057
        The spatial mesh.
1058
    coord_system : int
1059
        Coordinate system to use. Must be set to 2 (cylindrical coordinates).
1060
    num_groups : int
1061
        The total number of energy groups.
1062
    groupsets : list of dict
1063
        A list of input parameter blocks, each block provides the iterative properties for a
1064
        groupset. Each dictionary supports:
1065
          - groups_from_to: List[int] (required)
1066
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1067
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1068
              Handle to an angular quadrature.
1069
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
1070
              Angle aggregation method to use during sweeping.
1071
          - angle_aggregation_num_subsets: int, default=1
1072
              Number of angle subsets used for aggregation.
1073
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1074
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
1075
              Iterative method used for inner linear solves.
1076
          - l_abs_tol: float, default=1.0e-6
1077
              Inner linear solver absolute residual tolerance.
1078
          - l_max_its: int, default=200
1079
              Inner linear solver maximum iterations.
1080
          - gmres_restart_interval: int, default=30
1081
              GMRES restart interval, if GMRES is used.
1082
          - allow_cycles: bool, default=True
1083
              Whether cyclic dependencies are allowed in sweeps.
1084
          - apply_wgdsa: bool, default=False
1085
              Enable within-group DSA for this groupset.
1086
          - wgdsa_l_abs_tol: float, default=1.0e-4
1087
              WGDSA linear absolute tolerance.
1088
          - wgdsa_l_max_its: int, default=30
1089
              WGDSA maximum iterations.
1090
          - wgdsa_verbose: bool, default=False
1091
              Verbose WGDSA output.
1092
          - wgdsa_petsc_options: str, default=''
1093
              PETSc options string for the WGDSA solver.
1094
          - apply_tgdsa: bool, default=False
1095
              Enable two-grid DSA for this groupset.
1096
          - tgdsa_l_abs_tol: float, default=1.0e-4
1097
              TGDSA linear absolute tolerance.
1098
          - tgdsa_l_max_its: int, default=30
1099
              TGDSA maximum iterations.
1100
          - tgdsa_verbose: bool, default=False
1101
              Verbose TGDSA output.
1102
          - tgdsa_petsc_options: str, default=''
1103
    xs_map : list of dict
1104
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1105
          - block_ids: List[int] (required)
1106
              Mesh block IDs to associate with the cross section.
1107
          - xs: pyopensn.xs.MultiGroupXS (required)
1108
              Cross-section object to assign to the specified blocks.
1109
    boundary_conditions: List[Dict], default=[]
1110
        A list containing tables for each boundary specification. Each dictionary supports:
1111
          - name: str (required)
1112
              Boundary name that identifies the specific boundary.
1113
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1114
              Boundary type specification.
1115
          - group_strength: List[float], optional
1116
              Required when ``type='isotropic'``. Isotropic strength per group.
1117
          - function: AngularFluxFunction, optional
1118
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
1119
    point_sources: List[pyopensn.source.PointSource], default=[]
1120
        A list of point sources.
1121
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1122
        A list of volumetric sources.
1123
    options : dict, optional
1124
        A block of optional configuration parameters applied at problem creation, including:
1125
          - max_mpi_message_size: int, default=32768
1126
          - restart_writes_enabled: bool, default=False
1127
          - write_delayed_psi_to_restart: bool, default=True
1128
          - read_restart_path: str, default=''
1129
          - write_restart_path: str, default=''
1130
          - write_restart_time_interval: int, default=0
1131
          - use_precursors: bool, default=False
1132
          - use_source_moments: bool, default=False
1133
          - save_angular_flux: bool, default=False
1134
          - verbose_inner_iterations: bool, default=True
1135
          - verbose_outer_iterations: bool, default=True
1136
          - max_ags_iterations: int, default=100
1137
          - ags_tolerance: float, default=1.0e-6
1138
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1139
          - verbose_ags_iterations: bool, default=True
1140
          - power_field_function_on: bool, default=False
1141
          - power_default_kappa: float, default=3.20435e-11
1142
          - power_normalization: float, default=-1.0
1143
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1144
          - field_function_prefix: str, default=''
1145
    sweep_type : str, optional
1146
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1147
        If ``time_dependent=True``, ``options.save_angular_flux=True`` is required.
1148
    )"
1149
  );
1150
}
661✔
1151

1152
// Wrap steady-state solver
1153
void
1154
WrapSteadyState(py::module& slv)
661✔
1155
{
1156
  const auto balance_table_to_dict = [](const BalanceTable& table)
680✔
1157
  {
1158
    py::dict values;
19✔
1159
    values["absorption_rate"] = table.absorption_rate;
19✔
1160
    values["production_rate"] = table.production_rate;
19✔
1161
    values["inflow_rate"] = table.inflow_rate;
19✔
1162
    values["outflow_rate"] = table.outflow_rate;
19✔
1163
    values["balance"] = table.balance;
19✔
1164
    if (table.initial_inventory.has_value())
19✔
1165
      values["initial_inventory"] = table.initial_inventory.value();
×
1166
    if (table.final_inventory.has_value())
19✔
1167
      values["final_inventory"] = table.final_inventory.value();
×
1168
    if (table.predicted_inventory_change.has_value())
19✔
1169
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1170
    if (table.actual_inventory_change.has_value())
19✔
1171
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1172
    if (table.inventory_residual.has_value())
19✔
1173
      values["inventory_residual"] = table.inventory_residual.value();
×
1174
    return values;
19✔
1175
  };
×
1176

1177
  // clang-format off
1178
  // steady state solver
1179
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
661✔
1180
                                        Solver>(
1181
    slv,
1182
    "SteadyStateSourceSolver",
1183
    R"(
1184
    Steady state solver.
1185

1186
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1187
    )"
1188
  );
661✔
1189
  steady_state_solver.def(
1,322✔
1190
    py::init(
661✔
1191
      [](py::kwargs& params)
407✔
1192
      {
1193
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
407✔
1194
      }
1195
    ),
1196
    R"(
1197
    Construct a steady state solver.
1198

1199
    Parameters
1200
    ----------
1201
    pyopensn.solver.LBSProblem : LBSProblem
1202
        Existing LBSProblem instance.
1203
    )"
1204
  );
1205
  steady_state_solver.def(
661✔
1206
    "ComputeBalanceTable",
1207
    [balance_table_to_dict](const SteadyStateSourceSolver& self)
661✔
1208
    {
1209
      return balance_table_to_dict(self.ComputeBalanceTable());
19✔
1210
    },
1211
    R"(
1212
    Compute and return the global balance table using the solver's normalization.
1213
    This is a collective operation and must be called on all ranks.
1214

1215
    Returns
1216
    -------
1217
    dict
1218
        Dictionary with the following entries:
1219

1220
        - ``absorption_rate``:
1221
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1222
          groups and the full domain.
1223
        - ``production_rate``:
1224
          Global volumetric production/source rate used by the solver,
1225
          approximately ``integral Q dV`` summed over groups and the full domain.
1226
        - ``inflow_rate``:
1227
          Global incoming boundary contribution integrated over incoming
1228
          angular flux on boundaries.
1229
        - ``outflow_rate``:
1230
          Global outgoing boundary contribution accumulated from face outflow
1231
          tallies.
1232
        - ``balance``:
1233
          Rate balance,
1234
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1235

1236
    Notes
1237
    -----
1238
    This solver applies no extra normalization to the balance table.
1239
    )"
1240
  );
1241
  // clang-format on
1242
}
661✔
1243

1244
// Wrap transient solver
1245
void
1246
WrapTransient(py::module& slv)
661✔
1247
{
1248
  const auto balance_table_to_dict = [](const BalanceTable& table)
661✔
1249
  {
1250
    py::dict values;
×
1251
    values["absorption_rate"] = table.absorption_rate;
×
1252
    values["production_rate"] = table.production_rate;
×
1253
    values["inflow_rate"] = table.inflow_rate;
×
1254
    values["outflow_rate"] = table.outflow_rate;
×
1255
    values["balance"] = table.balance;
×
1256
    if (table.initial_inventory.has_value())
×
1257
      values["initial_inventory"] = table.initial_inventory.value();
×
1258
    if (table.final_inventory.has_value())
×
1259
      values["final_inventory"] = table.final_inventory.value();
×
1260
    if (table.predicted_inventory_change.has_value())
×
1261
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1262
    if (table.actual_inventory_change.has_value())
×
1263
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1264
    if (table.inventory_residual.has_value())
×
1265
      values["inventory_residual"] = table.inventory_residual.value();
×
1266
    return values;
×
1267
  };
×
1268
  // clang-format off
1269
  auto transient_solver =
661✔
1270
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1271
      slv,
1272
      "TransientSolver",
1273
      R"(
1274
      Transient solver.
1275

1276
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1277
      )"
1278
    );
661✔
1279
  transient_solver.def(
1,322✔
1280
    py::init(
661✔
1281
      [](py::kwargs& params)
160✔
1282
      {
1283
        return TransientSolver::Create(kwargs_to_param_block(params));
160✔
1284
      }
1285
    ),
1286
    R"(
1287
    Construct a transient solver.
1288

1289
    Parameters
1290
    ----------
1291
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1292
        Existing discrete ordinates problem instance.
1293
    dt : float, optional, default=2.0e-3
1294
        Time step size used during the simulation.
1295
    stop_time : float, optional, default=0.1
1296
        Simulation end time.
1297
    theta : float, optional, default=0.5
1298
        Time differencing scheme parameter.
1299
    initial_state : str, optional, default="existing"
1300
        Initial state for the transient solve. Allowed values: existing, zero.
1301
        In "zero" mode, scalar flux vectors are reset to zero.
1302
    verbose : bool, optional, default=True
1303
        Enable verbose logging.
1304
    )"
1305
  );
1306
  transient_solver.def(
661✔
1307
    "SetTimeStep",
1308
    &TransientSolver::SetTimeStep,
661✔
1309
    R"(
1310
    Set the timestep size used by :meth:`Advance`.
1311

1312
    Parameters
1313
    ----------
1314
    dt : float
1315
        New timestep size.
1316
    )");
1317
  transient_solver.def(
661✔
1318
    "SetTheta",
1319
    &TransientSolver::SetTheta,
661✔
1320
    R"(
1321
    Set the theta parameter used by :meth:`Advance`.
1322

1323
    Parameters
1324
    ----------
1325
    theta : float
1326
        Theta value between 1.0e-16 and 1.
1327
    )");
1328
  transient_solver.def(
661✔
1329
    "Advance",
1330
    &TransientSolver::Advance,
661✔
1331
    R"(
1332
    Advance the solver by a single timestep.
1333

1334
    Notes
1335
    -----
1336
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1337
    :meth:`Execute`.
1338
    )");
1339
  transient_solver.def(
661✔
1340
    "SetPreAdvanceCallback",
1341
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
661✔
1342
      &TransientSolver::SetPreAdvanceCallback),
1343
    R"(
1344
    Register a callback that runs before each advance within :meth:`Execute`.
1345

1346
    Parameters
1347
    ----------
1348
    callback : Optional[Callable[[], None]]
1349
        Function invoked before the solver advances a timestep. Pass None to clear.
1350
        If the callback modifies the timestep, the new value is used for the
1351
        upcoming step.
1352
    )");
1353
  transient_solver.def(
661✔
1354
    "SetPreAdvanceCallback",
1355
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
661✔
1356
      &TransientSolver::SetPreAdvanceCallback),
1357
    "Clear the PreAdvance callback by passing None.");
1358
  transient_solver.def(
661✔
1359
    "SetPostAdvanceCallback",
1360
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
661✔
1361
      &TransientSolver::SetPostAdvanceCallback),
1362
    R"(
1363
    Register a callback that runs after each advance within :meth:`Execute`.
1364

1365
    Parameters
1366
    ----------
1367
    callback : Optional[Callable[[], None]]
1368
        Function invoked after the solver advances a timestep. Pass None to clear.
1369
    )");
1370
  transient_solver.def(
661✔
1371
    "SetPostAdvanceCallback",
1372
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
661✔
1373
      &TransientSolver::SetPostAdvanceCallback),
1374
    "Clear the PostAdvance callback by passing None.");
1375
  transient_solver.def(
661✔
1376
    "ComputeBalanceTable",
1377
    [balance_table_to_dict](const TransientSolver& self)
661✔
1378
    {
1379
      return balance_table_to_dict(self.ComputeBalanceTable());
×
1380
    },
1381
    R"(
1382
    Compute and return the global balance table using the solver's normalization.
1383
    This is a collective operation and must be called on all ranks.
1384

1385
    Returns
1386
    -------
1387
    dict
1388
        Dictionary with the following entries:
1389

1390
        - ``absorption_rate``:
1391
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1392
          groups and the full domain.
1393
        - ``production_rate``:
1394
          Global volumetric production/source rate used by the solver,
1395
          approximately ``integral Q dV`` summed over groups and the full domain.
1396
        - ``inflow_rate``:
1397
          Global incoming boundary contribution integrated over incoming
1398
          angular flux on boundaries.
1399
        - ``outflow_rate``:
1400
          Global outgoing boundary contribution accumulated from face outflow
1401
          tallies.
1402
        - ``balance``:
1403
          Rate balance,
1404
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1405
        - ``initial_inventory``:
1406
          Total particle inventory at the start of the timestep, computed as
1407
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
1408
        - ``final_inventory``:
1409
          Total particle inventory at the end of the timestep, computed as
1410
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
1411
        - ``predicted_inventory_change``:
1412
          Inventory change predicted by the current timestep balance, computed as 
1413
          ``dt * balance``.
1414
        - ``actual_inventory_change``:
1415
          Measured change in total particle inventory over the timestep, computed as
1416
          ``final_inventory - initial_inventory``.
1417
        - ``inventory_residual``:
1418
          Mismatch between the measured and predicted timestep inventory
1419
          changes, computed as
1420
          ``actual_inventory_change - predicted_inventory_change``.
1421

1422
    Notes
1423
    -----
1424
    This solver applies no extra normalization to the balance table.
1425

1426
    The transient inventory terms currently use the end-of-step rate balance to
1427
    estimate the timestep inventory change.
1428
    )"
1429
  );
1430
  slv.attr("BackwardEuler") = 1.0;
661✔
1431
  slv.attr("CrankNicolson") = 0.5;
1,322✔
1432
  // clang-format on
1433
}
661✔
1434

1435
// Wrap non-linear k-eigen solver
1436
void
1437
WrapNLKEigen(py::module& slv)
661✔
1438
{
1439
  const auto balance_table_to_dict = [](const BalanceTable& table)
661✔
1440
  {
1441
    py::dict values;
×
1442
    values["absorption_rate"] = table.absorption_rate;
×
1443
    values["production_rate"] = table.production_rate;
×
1444
    values["inflow_rate"] = table.inflow_rate;
×
1445
    values["outflow_rate"] = table.outflow_rate;
×
1446
    values["balance"] = table.balance;
×
1447
    if (table.initial_inventory.has_value())
×
1448
      values["initial_inventory"] = table.initial_inventory.value();
×
1449
    if (table.final_inventory.has_value())
×
1450
      values["final_inventory"] = table.final_inventory.value();
×
1451
    if (table.predicted_inventory_change.has_value())
×
1452
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1453
    if (table.actual_inventory_change.has_value())
×
1454
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1455
    if (table.inventory_residual.has_value())
×
1456
      values["inventory_residual"] = table.inventory_residual.value();
×
1457
    return values;
×
1458
  };
×
1459
  // clang-format off
1460
  // non-linear k-eigen solver
1461
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
661✔
1462
                                              Solver>(
1463
    slv,
1464
    "NonLinearKEigenSolver",
1465
    R"(
1466
    Non-linear k-eigenvalue solver.
1467

1468
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1469
    )"
1470
  );
661✔
1471
  non_linear_k_eigen_solver.def(
1,322✔
1472
    py::init(
661✔
1473
      [](py::kwargs& params)
37✔
1474
      {
1475
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
37✔
1476
      }
1477
        ),
1478
    R"(
1479
    Construct a non-linear k-eigenvalue solver.
1480

1481
    Parameters
1482
    ----------
1483
    lbs_problem: pyopensn.solver.LBSProblem
1484
        Existing LBSProblem instance.
1485
    nl_abs_tol: float, default=1.0e-8
1486
        Non-linear absolute tolerance.
1487
    nl_rel_tol: float, default=1.0e-8
1488
        Non-linear relative tolerance.
1489
    nl_sol_tol: float, default=1.0e-50
1490
        Non-linear solution tolerance.
1491
    nl_max_its: int, default=50
1492
        Non-linear algorithm maximum iterations.
1493
    l_abs_tol: float, default=1.0e-8
1494
        Linear absolute tolerance.
1495
    l_rel_tol: float, default=1.0e-8
1496
        Linear relative tolerance.
1497
    l_div_tol: float, default=1.0e6
1498
        Linear divergence tolerance.
1499
    l_max_its: int, default=50
1500
        Linear algorithm maximum iterations.
1501
    l_gmres_restart_intvl: int, default=30
1502
        GMRES restart interval.
1503
    l_gmres_breakdown_tol: float, default=1.0e6
1504
        GMRES breakdown tolerance.
1505
    reset_phi0: bool, default=True
1506
        If true, reinitializes scalar fluxes to 1.0.
1507
    num_initial_power_iterations: int, default=0
1508
        Number of initial power iterations before the non-linear solve.
1509
    )"
1510
  );
1511
  non_linear_k_eigen_solver.def(
661✔
1512
    "GetEigenvalue",
1513
    &NonLinearKEigenSolver::GetEigenvalue,
661✔
1514
    R"(
1515
    Return the current k-eigenvalue.
1516
    )"
1517
  );
1518
  non_linear_k_eigen_solver.def(
661✔
1519
    "ComputeBalanceTable",
1520
    [balance_table_to_dict](const NonLinearKEigenSolver& self)
661✔
1521
    {
1522
      return balance_table_to_dict(self.ComputeBalanceTable());
×
1523
    },
1524
    R"(
1525
    Compute and return the global balance table using the solver's normalization.
1526
    This is a collective operation and must be called on all ranks.
1527

1528
    Returns
1529
    -------
1530
    dict
1531
        Dictionary with the following entries:
1532

1533
        - ``absorption_rate``:
1534
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1535
          groups and the full domain.
1536
        - ``production_rate``:
1537
          Global volumetric production/source rate used by the solver,
1538
          approximately ``integral Q dV`` summed over groups and the full domain.
1539
        - ``inflow_rate``:
1540
          Global incoming boundary contribution integrated over incoming
1541
          angular flux on boundaries.
1542
        - ``outflow_rate``:
1543
          Global outgoing boundary contribution accumulated from face outflow
1544
          tallies.
1545
        - ``balance``:
1546
          Rate balance,
1547
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1548

1549
    Notes
1550
    -----
1551
    For k-eigenvalue balance reporting, this solver scales the production term by
1552
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1553
    )"
1554
  );
1555
  // clang-format on
1556
}
661✔
1557

1558
// Wrap power iteration solvers
1559
void
1560
WrapPIteration(py::module& slv)
661✔
1561
{
1562
  const auto balance_table_to_dict = [](const BalanceTable& table)
665✔
1563
  {
1564
    py::dict values;
4✔
1565
    values["absorption_rate"] = table.absorption_rate;
4✔
1566
    values["production_rate"] = table.production_rate;
4✔
1567
    values["inflow_rate"] = table.inflow_rate;
4✔
1568
    values["outflow_rate"] = table.outflow_rate;
4✔
1569
    values["balance"] = table.balance;
4✔
1570
    if (table.initial_inventory.has_value())
4✔
1571
      values["initial_inventory"] = table.initial_inventory.value();
×
1572
    if (table.final_inventory.has_value())
4✔
1573
      values["final_inventory"] = table.final_inventory.value();
×
1574
    if (table.predicted_inventory_change.has_value())
4✔
1575
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1576
    if (table.actual_inventory_change.has_value())
4✔
1577
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1578
    if (table.inventory_residual.has_value())
4✔
1579
      values["inventory_residual"] = table.inventory_residual.value();
×
1580
    return values;
4✔
1581
  };
×
1582
  // clang-format off
1583
  // power iteration k-eigen solver
1584
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
661✔
1585
                                      Solver>(
1586
    slv,
1587
    "PowerIterationKEigenSolver",
1588
    R"(
1589
    Power iteration k-eigenvalue solver.
1590

1591
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
1592
    )"
1593
  );
661✔
1594
  pi_k_eigen_solver.def(
1,322✔
1595
    py::init(
661✔
1596
      [](py::kwargs& params)
153✔
1597
      {
1598
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
153✔
1599
      }
1600
    ),
1601
    R"(
1602
    Construct a power iteration k-eigen solver.
1603

1604
    Parameters
1605
    ----------
1606
    problem: pyopensn.solver.LBSProblem
1607
        Existing DiscreteOrdinatesProblem instance.
1608
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
1609
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
1610
    max_iters: int, default = 1000
1611
        Maximum power iterations allowed.
1612
    k_tol: float, default = 1.0e-10
1613
        Tolerance on the k-eigenvalue.
1614
    reset_solution: bool, default=True
1615
        If true, initialize flux moments to 1.0.
1616
    reset_phi0: bool, default=True
1617
        If true, reinitializes scalar fluxes to 1.0.
1618
    )"
1619
  );
1620
  pi_k_eigen_solver.def(
661✔
1621
    "GetEigenvalue",
1622
    &PowerIterationKEigenSolver::GetEigenvalue,
661✔
1623
    R"(
1624
    Return the current k-eigenvalue.
1625
    )"
1626
  );
1627
  pi_k_eigen_solver.def(
661✔
1628
    "ComputeBalanceTable",
1629
    [balance_table_to_dict](const PowerIterationKEigenSolver& self)
661✔
1630
    {
1631
      return balance_table_to_dict(self.ComputeBalanceTable());
4✔
1632
    },
1633
    R"(
1634
    Compute and return the global balance table using the solver's normalization.
1635
    This is a collective operation and must be called on all ranks.
1636

1637
    Returns
1638
    -------
1639
    dict
1640
        Dictionary with the following entries:
1641

1642
        - ``absorption_rate``:
1643
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1644
          groups and the full domain.
1645
        - ``production_rate``:
1646
          Global volumetric production/source rate used by the solver,
1647
          approximately ``integral Q dV`` summed over groups and the full domain.
1648
        - ``inflow_rate``:
1649
          Global incoming boundary contribution integrated over incoming
1650
          angular flux on boundaries.
1651
        - ``outflow_rate``:
1652
          Global outgoing boundary contribution accumulated from face outflow
1653
          tallies.
1654
        - ``balance``:
1655
          Rate balance,
1656
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1657

1658
    Notes
1659
    -----
1660
    For k-eigenvalue balance reporting, this solver scales the production term by
1661
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1662
    )"
1663
  );
1664
  // clang-format on
1665
}
661✔
1666

1667
// Wrap LBS solver
1668
void
1669
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
661✔
1670
{
1671
  // clang-format off
1672
  // discrete ordinates k-eigen acceleration base
1673
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
661✔
1674
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
1675
    slv,
1676
    "DiscreteOrdinatesKEigenAcceleration",
1677
    R"(
1678
    Base class for discrete ordinates k-eigenvalue acceleration methods.
1679

1680
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
1681
    )"
1682
  );
661✔
1683
  // SCDSA acceleration
1684
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
661✔
1685
                                       std::shared_ptr<SCDSAAcceleration>,
1686
                                       DiscreteOrdinatesKEigenAcceleration>(
1687
    slv,
1688
    "SCDSAAcceleration",
1689
    R"(
1690
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
1691

1692
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
1693
    )"
1694
  );
661✔
1695
  scdsa_acceleration.def(
1,322✔
1696
    py::init(
661✔
1697
      [](py::kwargs& params)
8✔
1698
      {
1699
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8✔
1700
      }
1701
    ),
1702
    R"(
1703
    SCDSA acceleration for the power iteration k-eigenvalue solver.
1704

1705
    Parameters
1706
    ----------
1707
    problem: pyopensn.solver.LBSProblem
1708
        Existing DiscreteOrdinatesProblem instance.
1709
    l_abs_tol: float, defauilt=1.0e-10
1710
        Absolute residual tolerance.
1711
    max_iters: int, default=100
1712
        Maximum allowable iterations.
1713
    verbose: bool, default=False
1714
        If true, enables verbose output.
1715
    petsc_options: str, default="ssss"
1716
        Additional PETSc options.
1717
    pi_max_its: int, default=50
1718
        Maximum allowable iterations for inner power iterations.
1719
    pi_k_tol: float, default=1.0e-10
1720
        k-eigenvalue tolerance for the inner power iterations.
1721
    sdm: str, default="pwld"
1722
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1723
            - 'pwld' : Piecewise Linear Discontinuous
1724
            - 'pwlc' : Piecewise Linear Continuous
1725
    )"
1726
  );
1727
  // SMM acceleration
1728
  auto smm_acceleration = py::class_<SMMAcceleration,
661✔
1729
                                     std::shared_ptr<SMMAcceleration>,
1730
                                     DiscreteOrdinatesKEigenAcceleration>(
1731
    slv,
1732
    "SMMAcceleration",
1733
    R"(
1734
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
1735

1736
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
1737
    )"
1738
  );
661✔
1739
  smm_acceleration.def(
1,322✔
1740
    py::init(
661✔
1741
      [](py::kwargs& params)
4✔
1742
      {
1743
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4✔
1744
      }
1745
    ),
1746
    R"(
1747
    SMM acceleration for the power iteration k-eigenvalue solver.
1748

1749
    Warnings
1750
    --------
1751
       SMM acceleration is **experimental** and should be used with caution!
1752
       SMM accleration only supports problems with isotropic scattering.
1753

1754
    Parameters
1755
    ----------
1756
    problem: pyopensn.solver.LBSProblem
1757
        Existing DiscreteOrdinatesProblem instance.
1758
    l_abs_tol: float, defauilt=1.0e-10
1759
        Absolute residual tolerance.
1760
    max_iters: int, default=100
1761
        Maximum allowable iterations.
1762
    verbose: bool, default=False
1763
        If true, enables verbose output.
1764
    petsc_options: str, default="ssss"
1765
        Additional PETSc options.
1766
    pi_max_its: int, default=50
1767
        Maximum allowable iterations for inner power iterations.
1768
    pi_k_tol: float, default=1.0e-10
1769
        k-eigenvalue tolerance for the inner power iterations.
1770
    sdm: str, default="pwld"
1771
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1772
            - 'pwld' : Piecewise Linear Discontinuous
1773
            - 'pwlc' : Piecewise Linear Continuous
1774
    )"
1775
  );
1776
  // clang-format on
1777
}
661✔
1778

1779
// Wrap the solver components of OpenSn
1780
void
1781
py_solver(py::module& pyopensn)
72✔
1782
{
1783
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
72✔
1784
  WrapProblem(slv);
72✔
1785
  WrapSolver(slv);
72✔
1786
  WrapLBS(slv);
72✔
1787
  WrapSteadyState(slv);
72✔
1788
  WrapTransient(slv);
72✔
1789
  WrapNLKEigen(slv);
72✔
1790
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
72✔
1791
  WrapPIteration(slv);
72✔
1792
}
72✔
1793

1794
} // namespace opensn
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc