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

Open-Sn / opensn / 24549169077

15 Apr 2026 06:32PM UTC coverage: 74.822%. Remained the same
24549169077

push

github

web-flow
Merge pull request #1023 from wdhawkins/lbs_do_split

Moving discrete-ordinates code from LBSProblem to DiscreteOrdinatesProblem

58 of 74 new or added lines in 8 files covered. (78.38%)

296 existing lines in 8 files now uncovered.

21206 of 28342 relevant lines covered (74.82%)

66139191.83 hits per line

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

76.86
/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/solvers/transient_solver.h"
15
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/steady_state_solver.h"
16
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/nl_keigen_solver.h"
17
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/pi_keigen_solver.h"
18
#include "modules/linear_boltzmann_solvers/lbs_problem/io/lbs_problem_io.h"
19
#include "modules/linear_boltzmann_solvers/lbs_problem/lbs_problem.h"
20
#include "modules/linear_boltzmann_solvers/lbs_problem/compute/lbs_compute.h"
21
#include "modules/solver.h"
22
#include <pybind11/numpy.h>
23
#include <algorithm>
24
#include <cstddef>
25
#include <cstdint>
26
#include <map>
27
#include <memory>
28
#include <string>
29
#include <vector>
30

31
namespace opensn
32
{
33

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

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

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

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

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

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

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

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

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

141
    Notes
142
    -----
143
    Field functions are created on demand from the current solver state.
144

145
    The returned field functions are snapshots of the solver state at creation time; they are not
146
    refreshed automatically if the solver state changes.
147

148
    They support ``CanUpdate()`` and ``Update()`` while their owning problem is still alive.
149
    Calling ``Update()`` explicitly refreshes the same field-function object from the solver's
150
    latest state.
151

152
    Calling ``GetScalarFluxFieldFunction(only_scalar_flux=False)`` creates all requested
153
    moments from the current ``phi`` iterate at the time of the call.
154

155
    In the nested form (``only_scalar_flux=False``), the moment index varies fastest
156
    within each group (inner index = moment, outer index = group).
157
    )",
158
    py::arg("only_scalar_flux") = true
665✔
159
  );
160
  lbs_problem.def(
1,330✔
161
    "CreateFieldFunction",
162
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
1,330✔
163
      const std::string&, const std::string&, double)>(&LBSProblem::CreateFieldFunction),
164
    R"(
165
    Create a named scalar field function derived from a 1D XS or ``power``.
166

167
    Parameters
168
    ----------
169
    name : str
170
        Name to assign to the returned field function.
171
    xs_name : str
172
        Built-in 1D XS name, custom XS name, or the special value ``power``.
173
    power_normalization_target : float, default=-1.0
174
        If positive, scale the derived field function so that the raw power field would
175
        integrate to this total power.
176

177
    Notes
178
    -----
179
    The returned field function is created on demand from the current scalar-flux iterate.
180
    For ordinary XS names this computes ``sum_g xs[g] * phi_g`` at each node.
181

182
    The returned field function is a snapshot of the solver state at creation time; it is not
183
    refreshed automatically if the solver state changes. It supports ``CanUpdate()`` and
184
    ``Update()`` while its owning problem is still alive. Calling ``Update()`` explicitly
185
    recomputes the same field function from the solver's latest state.
186

187
    If ``xs_name == "power"``, the same power-generation formula used elsewhere by the solver
188
    is applied on demand.
189

190
    If ``power_normalization_target > 0``, the returned field function is scaled using the power
191
    implied by the current scalar flux. This scaling affects only the returned field function;
192
    it does not mutate the solver's internal ``phi`` vectors.
193

194
    The returned field function is a fresh object created for this call. It is not
195
    automatically updated by later solves or timesteps.
196
    )",
197
    py::arg("name"),
1,330✔
198
    py::arg("xs_name"),
665✔
199
    py::arg("power_normalization_target") = -1.0
665✔
200
  );
201
  lbs_problem.def(
665✔
202
    "GetTime",
203
    &LBSProblem::GetTime,
665✔
204
    R"(
205
    Get the current simulation time in seconds.
206
    )"
207
  );
208
  lbs_problem.def(
665✔
209
    "GetTimeStep",
210
    &LBSProblem::GetTimeStep,
665✔
211
    R"(
212
    Get the current timestep size.
213
    )"
214
  );
215
  lbs_problem.def(
665✔
216
    "ComputeFissionRate",
217
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
665✔
218
    {
219
      const std::vector<double>* phi_ptr = nullptr;
×
UNCOV
220
      if (scalar_flux_iterate == "old")
×
221
      {
UNCOV
222
        phi_ptr = &self.GetPhiOldLocal();
×
223
      }
UNCOV
224
      else if (scalar_flux_iterate == "new")
×
225
      {
UNCOV
226
        phi_ptr = &self.GetPhiNewLocal();
×
227
      }
228
      else
229
      {
UNCOV
230
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
231
      }
UNCOV
232
      return ComputeFissionRate(self, *phi_ptr);
×
233
    },
234
    R"(
235
    Computes the total fission rate.
236

237
    Parameters
238
    ----------
239
    scalar_flux_iterate : {'old', 'new'}
240
        Specifies which scalar flux vector to use in the calculation.
241
            - 'old': Use the previous scalar flux iterate.
242
            - 'new': Use the current scalar flux iterate.
243

244
    Returns
245
    -------
246
    float
247
        The total fission rate.
248

249
    Raises
250
    ------
251
    ValueError
252
        If `scalar_flux_iterate` is not 'old' or 'new'.
253
    )",
254
    py::arg("scalar_flux_iterate")
665✔
255
  );
256
  lbs_problem.def(
665✔
257
    "ComputeFissionProduction",
258
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,461✔
259
    {
260
      const std::vector<double>* phi_ptr = nullptr;
796✔
261
      if (scalar_flux_iterate == "old")
796✔
262
      {
263
        phi_ptr = &self.GetPhiOldLocal();
44✔
264
      }
265
      else if (scalar_flux_iterate == "new")
752✔
266
      {
267
        phi_ptr = &self.GetPhiNewLocal();
752✔
268
      }
269
      else
270
      {
UNCOV
271
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
272
      }
273
      return ComputeFissionProduction(self, *phi_ptr);
796✔
274
    },
275
    R"(
276
    Computes the total fission production (nu*fission).
277

278
    Parameters
279
    ----------
280
    scalar_flux_iterate : {'old', 'new'}
281
        Specifies which scalar flux vector to use in the calculation.
282
            - 'old': Use the previous scalar flux iterate.
283
            - 'new': Use the current scalar flux iterate.
284

285
    Returns
286
    -------
287
    float
288
        The total fission production.
289

290
    Raises
291
    ------
292
    ValueError
293
        If `scalar_flux_iterate` is not 'old' or 'new'.
294
    )",
295
    py::arg("scalar_flux_iterate")
665✔
296
  );
297
  lbs_problem.def(
665✔
298
    "GetPhiOldLocal",
299
    [](LBSProblem& self)
713✔
300
    {
301
      return convert_vector(self.GetPhiOldLocal());
48✔
302
    },
303
    R"(
304
    Get the previous scalar flux iterate (local vector).
305

306
    Returns
307
    -------
308
    memoryview
309
        Memory view of the local old scalar flux vector.
310
    )"
311
  );
312
  lbs_problem.def(
665✔
313
    "GetPhiNewLocal",
314
    [](LBSProblem& self)
729✔
315
    {
316
      return convert_vector(self.GetPhiNewLocal());
64✔
317
    },
318
    R"(
319
    Get the current scalar flux iterate (local vector).
320

321
    Returns
322
    -------
323
    memoryview
324
        Memory view of the local new scalar flux vector.
325
    )"
326
  );
327
  lbs_problem.def(
665✔
328
    "WriteFluxMoments",
329
    [](LBSProblem& self, const std::string& file_base)
697✔
330
    {
331
      LBSSolverIO::WriteFluxMoments(self, file_base);
32✔
332
    },
333
    R"(
334
    Write flux moments to file.
335

336
    Parameters
337
    ----------
338
    file_base: str
339
        File basename.
340
    )",
341
    py::arg("file_base")
665✔
342
  );
343
  lbs_problem.def(
665✔
344
    "CreateAndWriteSourceMoments",
345
    [](LBSProblem& self, const std::string& file_base)
669✔
346
    {
347
      std::vector<double> source_moments = self.MakeSourceMomentsFromPhi();
4✔
348
      LBSSolverIO::WriteFluxMoments(self, file_base, source_moments);
4✔
349
    },
4✔
350
    R"(
351
    Write source moments from latest flux iterate to file.
352

353
    Parameters
354
    ----------
355
    file_base: str
356
        File basename.
357
    )",
358
    py::arg("file_base")
665✔
359
  );
360
  lbs_problem.def(
665✔
361
    "ReadFluxMomentsAndMakeSourceMoments",
362
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
665✔
363
    {
364
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
×
365
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
×
366
      self.SetExtSrcMomentsFrom(ext_src_moments);
×
367
      log.Log() << "Making source moments from flux file.";
×
368
      const auto temp_phi = self.GetPhiOldLocal();
×
369
      self.SetPhiOldFrom(self.GetExtSrcMomentsLocal());
×
370
      self.SetExtSrcMomentsFrom(self.MakeSourceMomentsFromPhi());
×
371
      self.SetPhiOldFrom(temp_phi);
×
UNCOV
372
    },
×
373
    R"(
374
    Read flux moments and compute corresponding source moments.
375

376
    Parameters
377
    ----------
378
    file_base: str
379
        File basename.
380
    single_file_flag: bool
381
        True if all flux moments are in a single file.
382
    )",
383
    py::arg("file_base"),
1,330✔
384
    py::arg("single_file_flag")
665✔
385
  );
386
  lbs_problem.def(
665✔
387
    "ReadSourceMoments",
388
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
669✔
389
    {
390
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
4✔
391
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
4✔
392
      self.SetExtSrcMomentsFrom(ext_src_moments);
4✔
393
    },
4✔
394
    R"(
395
    Read source moments from file.
396

397
    Parameters
398
    ----------
399
    file_base: str
400
        File basename.
401
    single_file_flag: bool
402
        True if all source moments are in a single file.
403
    )",
404
    py::arg("file_base"),
1,330✔
405
    py::arg("single_file_flag")
665✔
406
  );
407
  lbs_problem.def(
665✔
408
    "ReadFluxMoments",
409
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
665✔
410
    {
UNCOV
411
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag);
×
412
    },
413
    R"(
414
    Read flux moment data.
415

416
    Parameters
417
    ----------
418
    file_base: str
419
        File basename.
420
    single_file_flag: bool
421
        True if all flux moments are in a single file.
422
    )",
423
    py::arg("file_base"),
1,330✔
424
    py::arg("single_file_flag")
665✔
425
  );
426
  lbs_problem.def(
665✔
427
    "WriteAngularFluxes",
428
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
669✔
429
    {
430
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
4✔
431
    },
432
    R"(
433
    Write angular flux data to file.
434

435
    Parameters
436
    ----------
437
    file_base: str
438
        File basename.
439
    )",
440
    py::arg("file_base")
665✔
441
  );
442
  lbs_problem.def(
665✔
443
    "ReadAngularFluxes",
444
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
669✔
445
    {
446
      DiscreteOrdinatesProblemIO::ReadAngularFluxes(self, file_base);
4✔
447
    },
448
    R"(
449
    Read angular fluxes from file.
450

451
    Parameters
452
    ----------
453
    file_base: str
454
        File basename.
455
    )",
456
    py::arg("file_base")
665✔
457
  );
458
  lbs_problem.def(
665✔
459
    "SetPointSources",
460
    [](LBSProblem& self, py::kwargs& params)
665✔
461
    {
UNCOV
462
      for (auto [key, value] : params)
×
463
      {
464
        auto c_key = key.cast<std::string>();
×
465
        if (c_key == "clear_point_sources")
×
466
          self.ClearPointSources();
×
UNCOV
467
        else if (c_key == "point_sources")
×
468
        {
469
          auto sources = value.cast<py::list>();
×
UNCOV
470
          for (auto source : sources)
×
471
          {
UNCOV
472
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
473
          }
UNCOV
474
        }
×
475
        else
476
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
477
      }
×
UNCOV
478
    },
×
479
    R"(
480
    Set or clear point sources.
481

482
    Parameters
483
    ----------
484
    clear_point_sources: bool, default=False
485
        If true, all current the point sources of the problem are deleted.
486
    point_sources: List[pyopensn.source.PointSource]
487
        List of new point sources to be added to the problem.
488
    )"
489
  );
490
  lbs_problem.def(
665✔
491
    "SetVolumetricSources",
492
    [](LBSProblem& self, py::kwargs& params)
701✔
493
    {
494
      for (auto [key, value] : params)
108✔
495
      {
496
        auto c_key = key.cast<std::string>();
36✔
497
        if (c_key == "clear_volumetric_sources")
36✔
498
          self.ClearVolumetricSources();
12✔
499
        else if (c_key == "volumetric_sources")
24✔
500
        {
501
          auto sources = value.cast<py::list>();
24✔
502
          for (auto source : sources)
72✔
503
          {
504
            self.AddVolumetricSource(source.cast<std::shared_ptr<VolumetricSource>>());
48✔
505
          }
506
        }
24✔
507
        else
UNCOV
508
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
509
      }
36✔
510
    },
36✔
511
    R"(
512
    Set or clear volumetric sources.
513

514
    Parameters
515
    ----------
516
    clear_volumetric_sources: bool, default=False
517
        If true, all current the volumetric sources of the problem are deleted.
518
    volumetric_sources: List[pyopensn.source.VolumetricSource]
519
        List of new volumetric sources to be added to the problem.
520
    )"
521
  );
522
  lbs_problem.def(
665✔
523
    "SetXSMap",
524
    [](LBSProblem& self, py::kwargs& params)
849✔
525
    {
526
      BlockID2XSMap xs_map;
184✔
527
      for (auto [key, value] : params)
552✔
528
      {
529
        auto c_key = key.cast<std::string>();
184✔
530
        if (c_key == "xs_map")
184✔
531
        {
532
          auto xs_entries = value.cast<py::list>();
184✔
533
          for (auto entry : xs_entries)
552✔
534
          {
535
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
184✔
536
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
184✔
537
            const auto& block_ids =
184✔
538
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
184✔
539
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
184✔
540
            for (const auto& block_id : block_ids)
368✔
541
              xs_map[block_id] = xs;
184✔
542
          }
184✔
543
        }
184✔
544
        else
UNCOV
545
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
546
      }
184✔
547
      self.SetBlockID2XSMap(xs_map);
184✔
548
    },
184✔
549
    R"(
550
    Replace the block-id to cross-section map.
551

552
    Parameters
553
    ----------
554
    xs_map: List[Dict]
555
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
556
          - block_ids: List[int] (required)
557
              Mesh block ids to associate with the cross section.
558
          - xs: pyopensn.xs.MultiGroupXS (required)
559
              Cross section object.
560

561
    Notes
562
    -----
563
    The problem is refreshed immediately after replacing the map. Material metadata,
564
    precursor storage, GPU carriers, and derived solver state owned by the concrete
565
    problem are rebuilt for the new cross sections.
566

567
    If ``options.use_precursors=True``, this flag remains active across XS-map
568
    changes even when the current map has no precursor-bearing material. In that
569
    case delayed-neutron source terms are simply inactive until a later map provides
570
    precursor data again.
571

572
    Existing precursor concentrations are remapped by local cell and precursor-family
573
    index. When the new material for a cell has fewer precursor families, extra old
574
    families are dropped. When it has more families, newly introduced families are
575
    initialized to zero. If a cell changes through a material with zero precursors,
576
    the precursor history for that cell is discarded and any later reintroduced
577
    precursor families start from zero.
578

579
    If any fissionable material in the new map contains delayed-neutron precursor
580
    data and ``options.use_precursors=True``, all fissionable materials in the map
581
    must contain precursor data. Non-fissionable materials may have zero precursors.
582

583
    Forward/adjoint mode toggles via :meth:`LBSProblem.SetAdjoint` do not change this map.
584
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer. If the same
585
    ``MultiGroupXS`` handle is shared across multiple problems, toggling adjoint mode in one
586
    problem also changes the transport mode seen by the others.
587
    )"
588
  );
589
  lbs_problem.def(
665✔
590
    "ZeroPhi",
591
    [](LBSProblem& self)
665✔
592
    {
UNCOV
593
      self.ZeroPhi();
×
594
    },
595
    R"(
596
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
597
    )"
598
  );
599
  lbs_problem.def(
665✔
600
    "SetAdjoint",
601
    [](LBSProblem& self, bool adjoint)
665✔
602
    {
603
      self.SetAdjoint(adjoint);
24✔
604
    },
605
    py::arg("adjoint") = true,
665✔
606
    R"(
607
    Set forward/adjoint transport mode.
608

609
    Parameters
610
    ----------
611
    adjoint: bool, default=True
612
        ``True`` enables adjoint mode and ``False`` enables forward mode.
613

614
    Notes
615
    -----
616
    This is one of two supported mode-setting paths in Python:
617
      1. ``options={'adjoint': ...}`` in the problem constructor.
618
      2. ``SetAdjoint(...)`` (this method).
619

620
    If this changes mode, OpenSn performs a full mode-transition reset:
621
      - Materials are reinitialized in the selected mode.
622
      - Point and volumetric sources are cleared.
623
      - Boundary conditions are cleared.
624
      - Scalar and angular flux vectors are zeroed.
625

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

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

632
    After a mode change, reapply the desired driving terms before solving, typically:
633
      - :meth:`LBSProblem.SetPointSources`
634
      - :meth:`LBSProblem.SetVolumetricSources`
635
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
636

637
    This routine is intentionally destructive with respect to source/boundary/flux state
638
    to avoid hidden coupling between forward and adjoint setups.
639
    )"
640
  );
641
  lbs_problem.def(
665✔
642
    "SetForward",
643
    &LBSProblem::SetForward,
665✔
644
    R"(
645
    Set forward transport mode.
646

647
    Equivalent to ``SetAdjoint(False)``.
648
    )"
649
  );
650
  lbs_problem.def(
665✔
651
    "IsAdjoint",
652
    &LBSProblem::IsAdjoint,
665✔
653
    R"(
654
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
655
    )"
656
  );
657
  lbs_problem.def(
665✔
658
    "IsTimeDependent",
659
    &LBSProblem::IsTimeDependent,
665✔
660
    R"(
661
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
662
    )"
663
  );
664

665
  // discrete ordinate solver
666
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
665✔
667
                               LBSProblem>(
668
    slv,
669
    "DiscreteOrdinatesProblem",
670
    R"(
671
    Base class for discrete ordinates problems in Cartesian geometry.
672

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

677
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
678
    )"
679
  );
665✔
680
  do_problem.def(
1,330✔
681
    py::init(
665✔
682
      [](py::kwargs& params)
583✔
683
      {
684
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
583✔
685
      }
686
    ),
687
    R"(
688
    Construct a discrete ordinates problem with Cartesian geometry.
689

690
    Parameters
691
    ----------
692
    mesh : MeshContinuum
693
        The spatial mesh.
694
    num_groups : int
695
        The total number of energy groups.
696
    groupsets : List[Dict], default=[]
697
        A list of input parameter blocks, each block provides the iterative properties for a
698
        groupset. Each dictionary supports:
699
          - groups_from_to: List[int] (required)
700
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
701
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
702
              Handle to an angular quadrature.
703
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
704
              Angle aggregation method to use during sweeping.
705
          - angle_aggregation_num_subsets: int, default=1
706
              Number of angle subsets used for aggregation.
707
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
708
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
709
              Iterative method used for inner linear solves.
710
          - l_abs_tol: float, default=1.0e-6
711
              Inner linear solver absolute residual tolerance.
712
          - l_max_its: int, default=200
713
              Inner linear solver maximum iterations.
714
          - gmres_restart_interval: int, default=30
715
              GMRES restart interval, if GMRES is used.
716
          - allow_cycles: bool, default=True
717
              Whether cyclic dependencies are allowed in sweeps.
718
          - apply_wgdsa: bool, default=False
719
              Enable within-group DSA for this groupset.
720
          - wgdsa_l_abs_tol: float, default=1.0e-4
721
              WGDSA linear absolute tolerance.
722
          - wgdsa_l_max_its: int, default=30
723
              WGDSA maximum iterations.
724
          - wgdsa_verbose: bool, default=False
725
              Verbose WGDSA output.
726
          - wgdsa_petsc_options: str, default=''
727
              PETSc options string for the WGDSA solver.
728
          - apply_tgdsa: bool, default=False
729
              Enable two-grid DSA for this groupset.
730
          - tgdsa_l_abs_tol: float, default=1.0e-4
731
              TGDSA linear absolute tolerance.
732
          - tgdsa_l_max_its: int, default=30
733
              TGDSA maximum iterations.
734
          - tgdsa_verbose: bool, default=False
735
              Verbose TGDSA output.
736
          - tgdsa_petsc_options: str, default=''
737
              PETSc options string for the TGDSA solver.
738
    xs_map : List[Dict], default=[]
739
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
740
          - block_ids: List[int] (required)
741
              Mesh block IDs to associate with the cross section.
742
          - xs: pyopensn.xs.MultiGroupXS (required)
743
              Cross-section object to assign to the specified blocks.
744
    boundary_conditions: List[Dict], default=[]
745
        A list containing tables for each boundary specification. Each dictionary supports:
746
          - name: str (required)
747
              Boundary name that identifies the specific boundary.
748
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
749
              Boundary type specification.
750
          - group_strength: List[float], optional
751
              Required when ``type='isotropic'``. Isotropic strength per group.
752
          - function: AngularFluxFunction, optional
753
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
754
    point_sources: List[pyopensn.source.PointSource], default=[]
755
        A list of point sources.
756
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
757
        A list of volumetric sources.
758
    options : Dict, default={}
759
        A block of optional configuration parameters, including:
760
          - max_mpi_message_size: int, default=32768
761
          - restart_writes_enabled: bool, default=False
762
          - write_delayed_psi_to_restart: bool, default=True
763
          - read_restart_path: str, default=''
764
          - write_restart_path: str, default=''
765
          - write_restart_time_interval: int, default=0
766
            (must be 0 or >=30)
767
          - use_precursors: bool, default=True
768
            Enable delayed-neutron precursor treatment. This is treated as user intent and remains
769
            active across later ``SetXSMap`` calls, even if the current XS map temporarily contains
770
            no precursor-bearing material. When XS maps are swapped, existing precursor
771
            concentrations are remapped by cell and precursor-family index; new families start at
772
            zero and removed families are discarded. If any fissionable material in the active map
773
            has precursor data and ``use_precursors=True``, all fissionable materials in that map
774
            must have precursor data. Non-fissionable materials may have zero precursors.
775
          - use_source_moments: bool, default=False
776
          - save_angular_flux: bool, default=False
777
            Store angular flux state (`psi`) for transient mode, angular-flux
778
            field functions, and angular-flux I/O.
779
          - adjoint: bool, default=False
780
          - verbose_inner_iterations: bool, default=True
781
          - verbose_outer_iterations: bool, default=True
782
          - max_ags_iterations: int, default=100
783
          - ags_tolerance: float, default=1.0e-6
784
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
785
          - verbose_ags_iterations: bool, default=True
786
          - power_default_kappa: float, default=3.20435e-11
787
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
788
          - field_function_prefix: str, default=''
789
        These options are applied at problem creation.
790
    sweep_type : str, default="AAH"
791
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
792
    time_dependent : bool, default=False
793
        If true, the problem starts in time-dependent mode. Otherwise it starts in
794
        steady-state mode. Requires ``options.save_angular_flux=True``.
795
    use_gpus : bool, default=False
796
        A flag specifying whether GPU acceleration is used for the sweep. Currently, only ``AAH`` is
797
        supported.
798
    )"
799
  );
800
  do_problem.def(
665✔
801
    "SetTimeDependentMode",
802
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
665✔
803
    R"(
804
    Set the problem to time-dependent mode.
805

806
    Notes
807
    -----
808
    Switch problem from steady-state to time-dependent mode. This updates problem
809
    internals (sweep chunk mode and source-function) while preserving user boundary
810
    conditions and fixed sources.
811

812
    Requires ``options.save_angular_flux=True`` at problem creation.
813
    )"
814
  );
815
  do_problem.def(
665✔
816
    "SetSteadyStateMode",
817
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
665✔
818
    R"(
819
    Set the problem to steady-state mode.
820

821
    Notes
822
    -----
823
    Switch problem from time-dependent to steady-state mode. This updates problem
824
    internals (sweep chunk mode and source-function) while preserving user boundary
825
    conditions and fixed sources.
826
    )"
827
  );
828
  do_problem.def(
665✔
829
    "IsTimeDependent",
830
    &DiscreteOrdinatesProblem::IsTimeDependent,
665✔
831
    R"(
832
    Return ``True`` if the problem is currently in time-dependent mode.
833
    )"
834
  );
835
  do_problem.def(
665✔
836
    "SetBoundaryOptions",
837
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
669✔
838
    {
839
      for (auto [key, value] : params)
12✔
840
      {
841
        auto c_key = key.cast<std::string>();
4✔
842
        if (c_key == "clear_boundary_conditions")
4✔
UNCOV
843
          self.ClearBoundaries();
×
844
        else if (c_key == "boundary_conditions")
4✔
845
        {
846
          auto boundaries = value.cast<py::list>();
4✔
847
          for (auto boundary : boundaries)
24✔
848
          {
849
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
16✔
850
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
16✔
851
            self.SetBoundaryOptions(input);
16✔
852
          }
16✔
853
        }
4✔
854
        else
UNCOV
855
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
856
      }
4✔
857
    },
4✔
858
    R"(
859
    Set or clear boundary conditions.
860

861
    Parameters
862
    ----------
863
    clear_boundary_conditions: bool, default=False
864
        If true, all current boundary conditions are deleted.
865
    boundary_conditions: List[Dict]
866
        A list of boundary condition dictionaries. Each dictionary supports:
867
          - name: str (required)
868
              Boundary name that identifies the specific boundary.
869
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
870
              Boundary type specification.
871
          - group_strength: List[float], optional
872
              Required when ``type='isotropic'``. Isotropic strength per group.
873
          - function: AngularFluxFunction, optional
874
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
875

876
    Notes
877
    -----
878
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
879
    Reapply boundaries with this method before solving in the new mode.
880
    )"
881
  );
882
  do_problem.def(
665✔
883
    "ZeroPsi",
884
    [](DiscreteOrdinatesProblem& self)
665✔
885
    {
NEW
886
      self.ZeroPsi();
×
887
    },
888
    R"(
889
    Zero the angular-flux storage.
890
    )"
891
  );
892
  do_problem.def(
665✔
893
    "GetPsi",
894
    [](DiscreteOrdinatesProblem& self)
673✔
895
    {
896
      const auto& psi = self.GetPsiNewLocal();
8✔
897
      py::list psi_list;
8✔
898
      for (const auto& vec : psi)
16✔
899
      {
900
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()),
8✔
901
                                         vec.data(),
902
                                         py::cast(self));
8✔
903
        psi_list.append(array);
8✔
904
      }
8✔
905
      return psi_list;
8✔
UNCOV
906
    },
×
907
    R"(
908
    Return psi as a list of NumPy arrays (float64), using zero-copy views into the
909
    underlying data.
910
    )"
911
  );
912
  do_problem.def(
665✔
913
    "GetAngularFieldFunctionList",
914
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
665✔
915
    {
UNCOV
916
      std::vector<unsigned int> group_ids;
×
917
      std::vector<size_t> angle_ids;
×
918
      group_ids.reserve(groups.size());
×
919
      angle_ids.reserve(angles.size());
×
920

UNCOV
921
      for (py::handle g : groups)
×
922
        group_ids.push_back(g.cast<unsigned int>());
×
923
      for (py::handle a : angles)
×
924
        angle_ids.push_back(a.cast<size_t>());
×
925

UNCOV
926
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
927
      py::list out;
×
928
      for (const auto& ff : ff_list)
×
929
        out.append(ff);
×
930
      return out;
×
931
    },
×
932
    R"(
933
    Create field functions for selected angular flux components.
934

935
    Note: You must enable angular flux storage (``save_angular_flux=True``) in
936
    the problem options. For transient problems this is required. Otherwise
937
    the returned field functions will remain zero.
938

939
    Example
940
    -------
941
    .. code-block:: python
942

943
       solver.Initialize()
944
       solver.Execute()
945
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
946

947
    For transient/time-dependent runs, each field function is still a snapshot. Either call
948
    this after each timestep to create a fresh object or keep the returned object and call
949
    ``Update()`` after each timestep before exporting or interpolating it. Two common patterns
950
    are:
951

952
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
953

954
    .. code-block:: python
955

956
       solver = TransientSolver(problem=phys)
957
       solver.Initialize()
958
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
959

960
       def post_advance():
961
           for ff in ang_ff:
962
               ff.Update()
963
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
964

965
       solver.SetPostAdvanceCallback(post_advance)
966
       solver.Execute()
967

968
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
969

970
    .. code-block:: python
971

972
       solver = TransientSolver(problem=phys)
973
       solver.Initialize()
974
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
975
       for _ in range(num_steps):
976
           solver.Advance()
977
           for ff in ang_ff:
978
               ff.Update()
979
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
980

981
    Parameters
982
    ----------
983
    groups : List[int]
984
        Global group indices to export.
985
    angles : List[int]
986
        Angle indices within the groupset quadrature for each group.
987

988
    Returns
989
    -------
990
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
991
        Field functions for the requested ``(group, angle)`` pairs. Each returned field function
992
        is a snapshot, but supports ``CanUpdate()`` and ``Update()`` while its owning problem is
993
        alive.
994
    )",
995
    py::arg("groups"),
1,330✔
996
    py::arg("angles")
665✔
997
  );
998
  do_problem.def(
665✔
999
    "ComputeLeakage",
1000
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
704✔
1001
    {
1002
      auto grid = self.GetGrid();
39✔
1003
      // get the supported boundaries
1004
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
39✔
1005
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
39✔
1006
      const auto coord_sys = grid->GetCoordinateSystem();
39✔
1007
      const auto mesh_type = grid->GetType();
39✔
1008
      const auto dim = grid->GetDimension();
39✔
1009
      // get the boundaries to parse, preserving user order
1010
      std::vector<std::uint64_t> bndry_ids;
39✔
1011
      if (bnd_names.size() > 1)
39✔
1012
      {
1013
        for (py::handle name : bnd_names)
184✔
1014
        {
1015
          auto sname = name.cast<std::string>();
112✔
1016
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
112✔
1017
          {
1018
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24✔
UNCOV
1019
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
1020
                                       "' is invalid for cylindrical orthogonal meshes. "
UNCOV
1021
                                       "Use rmin, rmax, zmin, zmax.");
×
1022

1023
            if (mesh_type == MeshType::ORTHOGONAL)
24✔
1024
            {
1025
              if (sname == "rmin") sname = "xmin";
24✔
1026
              else if (sname == "rmax") sname = "xmax";
24✔
1027
              else if (sname == "zmin") sname = "ymin";
16✔
1028
              else if (sname == "zmax") sname = "ymax";
8✔
1029
            }
1030
          }
1031
          bndry_ids.push_back(allowed_bd_names.at(sname));
112✔
1032
        }
112✔
1033
      }
1034
      else
1035
      {
1036
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
9✔
1037
      }
1038
      // compute the leakage
1039
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
39✔
1040
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
78✔
1041
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
39✔
1042

1043
      auto to_rz_name = [](const std::string& name)
63✔
1044
      {
1045
        if (name == "xmin") return std::string("rmin");
24✔
1046
        if (name == "xmax") return std::string("rmax");
24✔
1047
        if (name == "ymin") return std::string("zmin");
16✔
1048
        if (name == "ymax") return std::string("zmax");
8✔
UNCOV
1049
        return name;
×
1050
      };
1051

1052
      // convert result to native Python
1053
      py::dict result;
39✔
1054
      for (const auto& bndry_id : bndry_ids)
157✔
1055
      {
1056
        const auto it = leakage.find(bndry_id);
118✔
1057
        if (it == leakage.end())
118✔
UNCOV
1058
          continue;
×
1059
        // construct numpy array and copy contents
1060
        const auto& grp_wise_leakage = it->second;
118✔
1061
        py::array_t<double> np_vector(py::ssize_t(grp_wise_leakage.size()));
118✔
1062
        auto buffer = np_vector.request();
118✔
1063
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
118✔
1064
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
118✔
1065
        std::string name = allowed_bd_ids.at(bndry_id);
118✔
1066
        if (rz_ortho)
118✔
1067
          name = to_rz_name(name);
24✔
1068
        result[py::str(name)] = std::move(np_vector);
236✔
1069
      }
118✔
1070

1071
      return result;
39✔
1072
    },
78✔
1073
    R"(
1074
    Compute leakage for the problem.
1075

1076
    Parameters
1077
    ----------
1078
    bnd_names : List[str]
1079
        A list of boundary names for which leakage should be computed.
1080

1081
    Returns
1082
    -------
1083
    Dict[str, numpy.ndarray]
1084
        A dictionary mapping boundary names to group-wise leakage vectors.
1085
        Each array contains the outgoing angular flux (per group) integrated over
1086
        the corresponding boundary surface.
1087

1088
    Raises
1089
    ------
1090
    RuntimeError
1091
        If `save_angular_flux` option was not enabled during problem setup.
1092

1093
    ValueError
1094
        If one or more boundary ids are not present on the current mesh.
1095
    )",
1096
    py::arg("bnd_names")
665✔
1097
  );
1098

1099
  // discrete ordinates curvilinear problem
1100
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
665✔
1101
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1102
                                           DiscreteOrdinatesProblem>(
1103
    slv,
1104
    "DiscreteOrdinatesCurvilinearProblem",
1105
    R"(
1106
    Base class for discrete ordinates problems in curvilinear geometry.
1107

1108
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1109
    )"
1110
  );
665✔
1111
  do_curvilinear_problem.def(
1,330✔
1112
    py::init(
665✔
1113
      [](py::kwargs& params)
80✔
1114
      {
1115
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
80✔
1116
      }
1117
    ),
1118
    R"(
1119
    Construct a discrete ordinates problem for curvilinear geometry.
1120

1121
    Warnings
1122
    --------
1123
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1124

1125
    Parameters
1126
    ----------
1127
    mesh : MeshContinuum
1128
        The spatial mesh.
1129
    coord_system : int
1130
        Coordinate system to use. Must be set to 2 (cylindrical coordinates).
1131
    num_groups : int
1132
        The total number of energy groups.
1133
    groupsets : list of dict
1134
        A list of input parameter blocks, each block provides the iterative properties for a
1135
        groupset. Each dictionary supports:
1136
          - groups_from_to: List[int] (required)
1137
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1138
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1139
              Handle to an angular quadrature.
1140
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
1141
              Angle aggregation method to use during sweeping.
1142
          - angle_aggregation_num_subsets: int, default=1
1143
              Number of angle subsets used for aggregation.
1144
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1145
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
1146
              Iterative method used for inner linear solves.
1147
          - l_abs_tol: float, default=1.0e-6
1148
              Inner linear solver absolute residual tolerance.
1149
          - l_max_its: int, default=200
1150
              Inner linear solver maximum iterations.
1151
          - gmres_restart_interval: int, default=30
1152
              GMRES restart interval, if GMRES is used.
1153
          - allow_cycles: bool, default=True
1154
              Whether cyclic dependencies are allowed in sweeps.
1155
          - apply_wgdsa: bool, default=False
1156
              Enable within-group DSA for this groupset.
1157
          - wgdsa_l_abs_tol: float, default=1.0e-4
1158
              WGDSA linear absolute tolerance.
1159
          - wgdsa_l_max_its: int, default=30
1160
              WGDSA maximum iterations.
1161
          - wgdsa_verbose: bool, default=False
1162
              Verbose WGDSA output.
1163
          - wgdsa_petsc_options: str, default=''
1164
              PETSc options string for the WGDSA solver.
1165
          - apply_tgdsa: bool, default=False
1166
              Enable two-grid DSA for this groupset.
1167
          - tgdsa_l_abs_tol: float, default=1.0e-4
1168
              TGDSA linear absolute tolerance.
1169
          - tgdsa_l_max_its: int, default=30
1170
              TGDSA maximum iterations.
1171
          - tgdsa_verbose: bool, default=False
1172
              Verbose TGDSA output.
1173
          - tgdsa_petsc_options: str, default=''
1174
    xs_map : list of dict
1175
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1176
          - block_ids: List[int] (required)
1177
              Mesh block IDs to associate with the cross section.
1178
          - xs: pyopensn.xs.MultiGroupXS (required)
1179
              Cross-section object to assign to the specified blocks.
1180
    boundary_conditions: List[Dict], default=[]
1181
        A list containing tables for each boundary specification. Each dictionary supports:
1182
          - name: str (required)
1183
              Boundary name that identifies the specific boundary.
1184
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1185
              Boundary type specification.
1186
          - group_strength: List[float], optional
1187
              Required when ``type='isotropic'``. Isotropic strength per group.
1188
          - function: AngularFluxFunction, optional
1189
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
1190
    point_sources: List[pyopensn.source.PointSource], default=[]
1191
        A list of point sources.
1192
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1193
        A list of volumetric sources.
1194
    options : dict, optional
1195
        A block of optional configuration parameters applied at problem creation, including:
1196
          - max_mpi_message_size: int, default=32768
1197
          - restart_writes_enabled: bool, default=False
1198
          - write_delayed_psi_to_restart: bool, default=True
1199
          - read_restart_path: str, default=''
1200
          - write_restart_path: str, default=''
1201
          - write_restart_time_interval: int, default=0
1202
          - use_precursors: bool, default=True
1203
            Enable delayed-neutron precursor treatment. This remains active across later
1204
            ``SetXSMap`` calls, even if the current map temporarily has no precursor-bearing
1205
            material. When XS maps change, precursor concentrations are remapped by cell and
1206
            precursor-family index; newly introduced families start at zero and removed families
1207
            are discarded.
1208
          - use_source_moments: bool, default=False
1209
          - save_angular_flux: bool, default=False
1210
            Store angular flux state (`psi`) for transient mode, angular-flux
1211
            field functions, and angular-flux I/O.
1212
          - verbose_inner_iterations: bool, default=True
1213
          - verbose_outer_iterations: bool, default=True
1214
          - max_ags_iterations: int, default=100
1215
          - ags_tolerance: float, default=1.0e-6
1216
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1217
          - verbose_ags_iterations: bool, default=True
1218
          - power_default_kappa: float, default=3.20435e-11
1219
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1220
          - field_function_prefix: str, default=''
1221
    sweep_type : str, optional
1222
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1223
        If ``time_dependent=True``, ``options.save_angular_flux=True`` is required.
1224
    )"
1225
  );
1226
}
665✔
1227

1228
// Wrap steady-state solver
1229
void
1230
WrapSteadyState(py::module& slv)
665✔
1231
{
1232
  const auto balance_table_to_dict = [](const BalanceTable& table)
684✔
1233
  {
1234
    py::dict values;
19✔
1235
    values["absorption_rate"] = table.absorption_rate;
19✔
1236
    values["production_rate"] = table.production_rate;
19✔
1237
    values["inflow_rate"] = table.inflow_rate;
19✔
1238
    values["outflow_rate"] = table.outflow_rate;
19✔
1239
    values["balance"] = table.balance;
19✔
1240
    if (table.initial_inventory.has_value())
19✔
UNCOV
1241
      values["initial_inventory"] = table.initial_inventory.value();
×
1242
    if (table.final_inventory.has_value())
19✔
UNCOV
1243
      values["final_inventory"] = table.final_inventory.value();
×
1244
    if (table.predicted_inventory_change.has_value())
19✔
UNCOV
1245
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1246
    if (table.actual_inventory_change.has_value())
19✔
UNCOV
1247
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1248
    if (table.inventory_residual.has_value())
19✔
UNCOV
1249
      values["inventory_residual"] = table.inventory_residual.value();
×
1250
    return values;
19✔
UNCOV
1251
  };
×
1252

1253
  // clang-format off
1254
  // steady state solver
1255
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
665✔
1256
                                        Solver>(
1257
    slv,
1258
    "SteadyStateSourceSolver",
1259
    R"(
1260
    Steady state solver.
1261

1262
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1263
    )"
1264
  );
665✔
1265
  steady_state_solver.def(
1,330✔
1266
    py::init(
665✔
1267
      [](py::kwargs& params)
407✔
1268
      {
1269
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
407✔
1270
      }
1271
    ),
1272
    R"(
1273
    Construct a steady state solver.
1274

1275
    Parameters
1276
    ----------
1277
    problem : pyopensn.solver.LBSProblem
1278
        Existing LBSProblem instance.
1279

1280
    Notes
1281
    -----
1282
    If ``problem.options.read_restart_path`` is set, restart data is read
1283
    during :meth:`Initialize`. If ``problem.options.restart_writes_enabled`` is
1284
    true, a restart dump is written after :meth:`Execute` completes.
1285
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1286
        Existing discrete ordinates problem instance.
1287
    )"
1288
  );
1289
  steady_state_solver.def(
665✔
1290
    "ComputeBalanceTable",
1291
    [balance_table_to_dict](const SteadyStateSourceSolver& self)
665✔
1292
    {
1293
      return balance_table_to_dict(self.ComputeBalanceTable());
19✔
1294
    },
1295
    R"(
1296
    Compute and return the global balance table using the solver's normalization.
1297
    This is a collective operation and must be called on all ranks.
1298

1299
    Returns
1300
    -------
1301
    dict
1302
        Dictionary with the following entries:
1303

1304
        - ``absorption_rate``:
1305
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1306
          groups and the full domain.
1307
        - ``production_rate``:
1308
          Global volumetric production/source rate used by the solver,
1309
          approximately ``integral Q dV`` summed over groups and the full domain.
1310
        - ``inflow_rate``:
1311
          Global incoming boundary contribution integrated over incoming
1312
          angular flux on boundaries.
1313
        - ``outflow_rate``:
1314
          Global outgoing boundary contribution accumulated from face outflow
1315
          tallies.
1316
        - ``balance``:
1317
          Rate balance,
1318
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1319

1320
    Notes
1321
    -----
1322
    This solver applies no extra normalization to the balance table.
1323
    )"
1324
  );
1325
  // clang-format on
1326
}
665✔
1327

1328
// Wrap transient solver
1329
void
1330
WrapTransient(py::module& slv)
665✔
1331
{
1332
  const auto balance_table_to_dict = [](const BalanceTable& table)
665✔
1333
  {
UNCOV
1334
    py::dict values;
×
UNCOV
1335
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1336
    values["production_rate"] = table.production_rate;
×
UNCOV
1337
    values["inflow_rate"] = table.inflow_rate;
×
UNCOV
1338
    values["outflow_rate"] = table.outflow_rate;
×
1339
    values["balance"] = table.balance;
×
1340
    if (table.initial_inventory.has_value())
×
1341
      values["initial_inventory"] = table.initial_inventory.value();
×
1342
    if (table.final_inventory.has_value())
×
1343
      values["final_inventory"] = table.final_inventory.value();
×
1344
    if (table.predicted_inventory_change.has_value())
×
1345
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1346
    if (table.actual_inventory_change.has_value())
×
1347
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1348
    if (table.inventory_residual.has_value())
×
1349
      values["inventory_residual"] = table.inventory_residual.value();
×
1350
    return values;
×
1351
  };
×
1352
  // clang-format off
1353
  auto transient_solver =
665✔
1354
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1355
      slv,
1356
      "TransientSolver",
1357
      R"(
1358
      Transient solver.
1359

1360
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1361
      )"
1362
    );
665✔
1363
  transient_solver.def(
1,330✔
1364
    py::init(
665✔
1365
      [](py::kwargs& params)
180✔
1366
      {
1367
        return TransientSolver::Create(kwargs_to_param_block(params));
180✔
1368
      }
1369
    ),
1370
    R"(
1371
    Construct a transient solver.
1372

1373
    Parameters
1374
    ----------
1375
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1376
        Existing discrete ordinates problem instance.
1377
    dt : float, optional, default=2.0e-3
1378
        Time step size used during the simulation.
1379
    stop_time : float, optional, default=0.1
1380
        Simulation end time.
1381
    theta : float, optional, default=0.5
1382
        Time differencing scheme parameter.
1383
    initial_state : str, optional, default="existing"
1384
        Initial state for the transient solve. Allowed values: existing, zero.
1385
        In "zero" mode, scalar flux vectors are reset to zero.
1386
    verbose : bool, optional, default=True
1387
        Enable verbose logging.
1388

1389
    Notes
1390
    -----
1391
    The associated problem must have ``save_angular_flux=True`` enabled. This
1392
    is required for transient problems.
1393

1394
    If ``problem.options.read_restart_path`` is set, restart data is read
1395
    during :meth:`Initialize`. If ``problem.options.restart_writes_enabled`` is
1396
    true, timed restart dumps may be written during :meth:`Execute` and a final
1397
    restart dump is written when execution completes.
1398
    )"
1399
  );
1400
  transient_solver.def(
665✔
1401
    "SetTimeStep",
1402
    &TransientSolver::SetTimeStep,
665✔
1403
    R"(
1404
    Set the timestep size used by :meth:`Advance`.
1405

1406
    Parameters
1407
    ----------
1408
    dt : float
1409
        New timestep size.
1410
    )");
1411
  transient_solver.def(
665✔
1412
    "SetTheta",
1413
    &TransientSolver::SetTheta,
665✔
1414
    R"(
1415
    Set the theta parameter used by :meth:`Advance`.
1416

1417
    Parameters
1418
    ----------
1419
    theta : float
1420
        Theta value between 1.0e-16 and 1.
1421
    )");
1422
  transient_solver.def(
665✔
1423
    "Advance",
1424
    &TransientSolver::Advance,
665✔
1425
    R"(
1426
    Advance the solver by a single timestep.
1427

1428
    Notes
1429
    -----
1430
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1431
    :meth:`Execute`.
1432
    )");
1433
  transient_solver.def(
665✔
1434
    "SetPreAdvanceCallback",
1435
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
665✔
1436
      &TransientSolver::SetPreAdvanceCallback),
1437
    R"(
1438
    Register a callback that runs before each advance within :meth:`Execute`.
1439

1440
    Parameters
1441
    ----------
1442
    callback : Optional[Callable[[], None]]
1443
        Function invoked before the solver advances a timestep. Pass None to clear.
1444
        If the callback modifies the timestep, the new value is used for the
1445
        upcoming step.
1446
    )");
1447
  transient_solver.def(
665✔
1448
    "SetPreAdvanceCallback",
1449
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
665✔
1450
      &TransientSolver::SetPreAdvanceCallback),
1451
    "Clear the PreAdvance callback by passing None.");
1452
  transient_solver.def(
665✔
1453
    "SetPostAdvanceCallback",
1454
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
665✔
1455
      &TransientSolver::SetPostAdvanceCallback),
1456
    R"(
1457
    Register a callback that runs after each advance within :meth:`Execute`.
1458

1459
    Parameters
1460
    ----------
1461
    callback : Optional[Callable[[], None]]
1462
        Function invoked after the solver advances a timestep. Pass None to clear.
1463
    )");
1464
  transient_solver.def(
665✔
1465
    "SetPostAdvanceCallback",
1466
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
665✔
1467
      &TransientSolver::SetPostAdvanceCallback),
1468
    "Clear the PostAdvance callback by passing None.");
1469
  transient_solver.def(
665✔
1470
    "ComputeBalanceTable",
1471
    [balance_table_to_dict](const TransientSolver& self)
665✔
1472
    {
UNCOV
1473
      return balance_table_to_dict(self.ComputeBalanceTable());
×
1474
    },
1475
    R"(
1476
    Compute and return the global balance table using the solver's normalization.
1477
    This is a collective operation and must be called on all ranks.
1478

1479
    Returns
1480
    -------
1481
    dict
1482
        Dictionary with the following entries:
1483

1484
        - ``absorption_rate``:
1485
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1486
          groups and the full domain.
1487
        - ``production_rate``:
1488
          Global volumetric production/source rate used by the solver,
1489
          approximately ``integral Q dV`` summed over groups and the full domain.
1490
        - ``inflow_rate``:
1491
          Global incoming boundary contribution integrated over incoming
1492
          angular flux on boundaries.
1493
        - ``outflow_rate``:
1494
          Global outgoing boundary contribution accumulated from face outflow
1495
          tallies.
1496
        - ``balance``:
1497
          Rate balance,
1498
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1499
        - ``initial_inventory``:
1500
          Total particle inventory at the start of the timestep, computed as
1501
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
1502
        - ``final_inventory``:
1503
          Total particle inventory at the end of the timestep, computed as
1504
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
1505
        - ``predicted_inventory_change``:
1506
          Inventory change predicted by the current timestep balance, computed as 
1507
          ``dt * balance``.
1508
        - ``actual_inventory_change``:
1509
          Measured change in total particle inventory over the timestep, computed as
1510
          ``final_inventory - initial_inventory``.
1511
        - ``inventory_residual``:
1512
          Mismatch between the measured and predicted timestep inventory
1513
          changes, computed as
1514
          ``actual_inventory_change - predicted_inventory_change``.
1515

1516
    Notes
1517
    -----
1518
    This solver applies no extra normalization to the balance table.
1519

1520
    The transient inventory terms currently use the end-of-step rate balance to
1521
    estimate the timestep inventory change.
1522
    )"
1523
  );
1524
  slv.attr("BackwardEuler") = 1.0;
665✔
1525
  slv.attr("CrankNicolson") = 0.5;
1,330✔
1526
  // clang-format on
1527
}
665✔
1528

1529
// Wrap non-linear k-eigen solver
1530
void
1531
WrapNLKEigen(py::module& slv)
665✔
1532
{
1533
  const auto balance_table_to_dict = [](const BalanceTable& table)
665✔
1534
  {
UNCOV
1535
    py::dict values;
×
UNCOV
1536
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1537
    values["production_rate"] = table.production_rate;
×
UNCOV
1538
    values["inflow_rate"] = table.inflow_rate;
×
UNCOV
1539
    values["outflow_rate"] = table.outflow_rate;
×
1540
    values["balance"] = table.balance;
×
1541
    if (table.initial_inventory.has_value())
×
1542
      values["initial_inventory"] = table.initial_inventory.value();
×
1543
    if (table.final_inventory.has_value())
×
1544
      values["final_inventory"] = table.final_inventory.value();
×
1545
    if (table.predicted_inventory_change.has_value())
×
1546
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1547
    if (table.actual_inventory_change.has_value())
×
1548
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1549
    if (table.inventory_residual.has_value())
×
1550
      values["inventory_residual"] = table.inventory_residual.value();
×
1551
    return values;
×
1552
  };
×
1553
  // clang-format off
1554
  // non-linear k-eigen solver
1555
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
665✔
1556
                                              Solver>(
1557
    slv,
1558
    "NonLinearKEigenSolver",
1559
    R"(
1560
    Non-linear k-eigenvalue solver.
1561

1562
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1563
    )"
1564
  );
665✔
1565
  non_linear_k_eigen_solver.def(
1,330✔
1566
    py::init(
665✔
1567
      [](py::kwargs& params)
37✔
1568
      {
1569
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
37✔
1570
      }
1571
        ),
1572
    R"(
1573
    Construct a non-linear k-eigenvalue solver.
1574

1575
    Parameters
1576
    ----------
1577
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1578
        Existing discrete ordinates problem instance.
1579
    nl_abs_tol: float, default=1.0e-8
1580
        Non-linear absolute tolerance.
1581
    nl_rel_tol: float, default=1.0e-8
1582
        Non-linear relative tolerance.
1583
    nl_sol_tol: float, default=1.0e-50
1584
        Non-linear solution tolerance.
1585
    nl_max_its: int, default=50
1586
        Non-linear algorithm maximum iterations.
1587
    l_abs_tol: float, default=1.0e-8
1588
        Linear absolute tolerance.
1589
    l_rel_tol: float, default=1.0e-8
1590
        Linear relative tolerance.
1591
    l_div_tol: float, default=1.0e6
1592
        Linear divergence tolerance.
1593
    l_max_its: int, default=50
1594
        Linear algorithm maximum iterations.
1595
    l_gmres_restart_intvl: int, default=30
1596
        GMRES restart interval.
1597
    l_gmres_breakdown_tol: float, default=1.0e6
1598
        GMRES breakdown tolerance.
1599
    reset_phi0: bool, default=True
1600
        If true, reinitializes scalar fluxes to 1.0.
1601
    num_initial_power_iterations: int, default=0
1602
        Number of initial power iterations before the non-linear solve.
1603
    )"
1604
  );
1605
  non_linear_k_eigen_solver.def(
665✔
1606
    "GetEigenvalue",
1607
    &NonLinearKEigenSolver::GetEigenvalue,
665✔
1608
    R"(
1609
    Return the current k-eigenvalue.
1610
    )"
1611
  );
1612
  non_linear_k_eigen_solver.def(
665✔
1613
    "ComputeBalanceTable",
1614
    [balance_table_to_dict](const NonLinearKEigenSolver& self)
665✔
1615
    {
UNCOV
1616
      return balance_table_to_dict(self.ComputeBalanceTable());
×
1617
    },
1618
    R"(
1619
    Compute and return the global balance table using the solver's normalization.
1620
    This is a collective operation and must be called on all ranks.
1621

1622
    Returns
1623
    -------
1624
    dict
1625
        Dictionary with the following entries:
1626

1627
        - ``absorption_rate``:
1628
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1629
          groups and the full domain.
1630
        - ``production_rate``:
1631
          Global volumetric production/source rate used by the solver,
1632
          approximately ``integral Q dV`` summed over groups and the full domain.
1633
        - ``inflow_rate``:
1634
          Global incoming boundary contribution integrated over incoming
1635
          angular flux on boundaries.
1636
        - ``outflow_rate``:
1637
          Global outgoing boundary contribution accumulated from face outflow
1638
          tallies.
1639
        - ``balance``:
1640
          Rate balance,
1641
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1642

1643
    Notes
1644
    -----
1645
    For k-eigenvalue balance reporting, this solver scales the production term by
1646
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1647
    )"
1648
  );
1649
  // clang-format on
1650
}
665✔
1651

1652
// Wrap power iteration solvers
1653
void
1654
WrapPIteration(py::module& slv)
665✔
1655
{
1656
  const auto balance_table_to_dict = [](const BalanceTable& table)
669✔
1657
  {
1658
    py::dict values;
4✔
1659
    values["absorption_rate"] = table.absorption_rate;
4✔
1660
    values["production_rate"] = table.production_rate;
4✔
1661
    values["inflow_rate"] = table.inflow_rate;
4✔
1662
    values["outflow_rate"] = table.outflow_rate;
4✔
1663
    values["balance"] = table.balance;
4✔
1664
    if (table.initial_inventory.has_value())
4✔
UNCOV
1665
      values["initial_inventory"] = table.initial_inventory.value();
×
1666
    if (table.final_inventory.has_value())
4✔
UNCOV
1667
      values["final_inventory"] = table.final_inventory.value();
×
1668
    if (table.predicted_inventory_change.has_value())
4✔
UNCOV
1669
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1670
    if (table.actual_inventory_change.has_value())
4✔
UNCOV
1671
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1672
    if (table.inventory_residual.has_value())
4✔
UNCOV
1673
      values["inventory_residual"] = table.inventory_residual.value();
×
1674
    return values;
4✔
UNCOV
1675
  };
×
1676
  // clang-format off
1677
  // power iteration k-eigen solver
1678
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
665✔
1679
                                      Solver>(
1680
    slv,
1681
    "PowerIterationKEigenSolver",
1682
    R"(
1683
    Power iteration k-eigenvalue solver.
1684

1685
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
1686
    )"
1687
  );
665✔
1688
  pi_k_eigen_solver.def(
1,330✔
1689
    py::init(
665✔
1690
      [](py::kwargs& params)
161✔
1691
      {
1692
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
161✔
1693
      }
1694
    ),
1695
    R"(
1696
    Construct a power iteration k-eigen solver.
1697

1698
    Parameters
1699
    ----------
1700
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1701
        Existing DiscreteOrdinatesProblem instance.
1702
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
1703
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
1704
    max_iters: int, default = 1000
1705
        Maximum power iterations allowed.
1706
    k_tol: float, default = 1.0e-10
1707
        Tolerance on the k-eigenvalue.
1708
    reset_solution: bool, default=True
1709
        If true, initialize flux moments to 1.0.
1710
    reset_phi0: bool, default=True
1711
        If true, reinitializes scalar fluxes to 1.0.
1712

1713
    Notes
1714
    -----
1715
    If ``problem.options.read_restart_path`` is set, restart data is read
1716
    during :meth:`Initialize`. If ``problem.options.restart_writes_enabled`` is
1717
    true, timed restart dumps may be written during the outer iteration loop
1718
    and a final restart dump is written when execution completes.
1719
    )"
1720
  );
1721
  pi_k_eigen_solver.def(
665✔
1722
    "GetEigenvalue",
1723
    &PowerIterationKEigenSolver::GetEigenvalue,
665✔
1724
    R"(
1725
    Return the current k-eigenvalue.
1726
    )"
1727
  );
1728
  pi_k_eigen_solver.def(
665✔
1729
    "ComputeBalanceTable",
1730
    [balance_table_to_dict](const PowerIterationKEigenSolver& self)
665✔
1731
    {
1732
      return balance_table_to_dict(self.ComputeBalanceTable());
4✔
1733
    },
1734
    R"(
1735
    Compute and return the global balance table using the solver's normalization.
1736
    This is a collective operation and must be called on all ranks.
1737

1738
    Returns
1739
    -------
1740
    dict
1741
        Dictionary with the following entries:
1742

1743
        - ``absorption_rate``:
1744
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1745
          groups and the full domain.
1746
        - ``production_rate``:
1747
          Global volumetric production/source rate used by the solver,
1748
          approximately ``integral Q dV`` summed over groups and the full domain.
1749
        - ``inflow_rate``:
1750
          Global incoming boundary contribution integrated over incoming
1751
          angular flux on boundaries.
1752
        - ``outflow_rate``:
1753
          Global outgoing boundary contribution accumulated from face outflow
1754
          tallies.
1755
        - ``balance``:
1756
          Rate balance,
1757
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1758

1759
    Notes
1760
    -----
1761
    For k-eigenvalue balance reporting, this solver scales the production term by
1762
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1763
    )"
1764
  );
1765
  // clang-format on
1766
}
665✔
1767

1768
// Wrap LBS solver
1769
void
1770
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
665✔
1771
{
1772
  // clang-format off
1773
  // discrete ordinates k-eigen acceleration base
1774
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
665✔
1775
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
1776
    slv,
1777
    "DiscreteOrdinatesKEigenAcceleration",
1778
    R"(
1779
    Base class for discrete ordinates k-eigenvalue acceleration methods.
1780

1781
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
1782
    )"
1783
  );
665✔
1784
  // SCDSA acceleration
1785
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
665✔
1786
                                       std::shared_ptr<SCDSAAcceleration>,
1787
                                       DiscreteOrdinatesKEigenAcceleration>(
1788
    slv,
1789
    "SCDSAAcceleration",
1790
    R"(
1791
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
1792

1793
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
1794
    )"
1795
  );
665✔
1796
  scdsa_acceleration.def(
1,330✔
1797
    py::init(
665✔
1798
      [](py::kwargs& params)
8✔
1799
      {
1800
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8✔
1801
      }
1802
    ),
1803
    R"(
1804
    SCDSA acceleration for the power iteration k-eigenvalue solver.
1805

1806
    Parameters
1807
    ----------
1808
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1809
        Existing DiscreteOrdinatesProblem instance.
1810
    l_abs_tol: float, defauilt=1.0e-10
1811
        Absolute residual tolerance.
1812
    max_iters: int, default=100
1813
        Maximum allowable iterations.
1814
    verbose: bool, default=False
1815
        If true, enables verbose output.
1816
    petsc_options: str, default="ssss"
1817
        Additional PETSc options.
1818
    pi_max_its: int, default=50
1819
        Maximum allowable iterations for inner power iterations.
1820
    pi_k_tol: float, default=1.0e-10
1821
        k-eigenvalue tolerance for the inner power iterations.
1822
    sdm: str, default="pwld"
1823
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1824
            - 'pwld' : Piecewise Linear Discontinuous
1825
            - 'pwlc' : Piecewise Linear Continuous
1826
    )"
1827
  );
1828
  // SMM acceleration
1829
  auto smm_acceleration = py::class_<SMMAcceleration,
665✔
1830
                                     std::shared_ptr<SMMAcceleration>,
1831
                                     DiscreteOrdinatesKEigenAcceleration>(
1832
    slv,
1833
    "SMMAcceleration",
1834
    R"(
1835
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
1836

1837
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
1838
    )"
1839
  );
665✔
1840
  smm_acceleration.def(
1,330✔
1841
    py::init(
665✔
1842
      [](py::kwargs& params)
4✔
1843
      {
1844
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4✔
1845
      }
1846
    ),
1847
    R"(
1848
    SMM acceleration for the power iteration k-eigenvalue solver.
1849

1850
    Warnings
1851
    --------
1852
       SMM acceleration is **experimental** and should be used with caution!
1853
       SMM accleration only supports problems with isotropic scattering.
1854

1855
    Parameters
1856
    ----------
1857
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1858
        Existing DiscreteOrdinatesProblem instance.
1859
    l_abs_tol: float, defauilt=1.0e-10
1860
        Absolute residual tolerance.
1861
    max_iters: int, default=100
1862
        Maximum allowable iterations.
1863
    verbose: bool, default=False
1864
        If true, enables verbose output.
1865
    petsc_options: str, default="ssss"
1866
        Additional PETSc options.
1867
    pi_max_its: int, default=50
1868
        Maximum allowable iterations for inner power iterations.
1869
    pi_k_tol: float, default=1.0e-10
1870
        k-eigenvalue tolerance for the inner power iterations.
1871
    sdm: str, default="pwld"
1872
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1873
            - 'pwld' : Piecewise Linear Discontinuous
1874
            - 'pwlc' : Piecewise Linear Continuous
1875
    )"
1876
  );
1877
  // clang-format on
1878
}
665✔
1879

1880
// Wrap the solver components of OpenSn
1881
void
1882
py_solver(py::module& pyopensn)
72✔
1883
{
1884
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
72✔
1885
  WrapProblem(slv);
72✔
1886
  WrapSolver(slv);
72✔
1887
  WrapLBS(slv);
72✔
1888
  WrapSteadyState(slv);
72✔
1889
  WrapTransient(slv);
72✔
1890
  WrapNLKEigen(slv);
72✔
1891
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
72✔
1892
  WrapPIteration(slv);
72✔
1893
}
72✔
1894

1895
} // 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