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

Open-Sn / opensn / 24437745168

15 Apr 2026 03:39AM UTC coverage: 74.813% (-0.2%) from 75.028%
24437745168

push

github

web-flow
Merge pull request #955 from wdhawkins/cepxs

Add support for CEPXS cross sections

15 of 212 new or added lines in 6 files covered. (7.08%)

319 existing lines in 10 files now uncovered.

21208 of 28348 relevant lines covered (74.81%)

65820875.2 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/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)
665✔
38
{
39
  // clang-format off
40
  // problem base
41
  auto problem = py::class_<Problem, std::shared_ptr<Problem>>(
665✔
42
    slv,
43
    "Problem",
44
    R"(
45
    Base class for all problems.
46

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

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

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

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

98
    Wrapper of :cpp:class:`opensn::LBSProblem`.
99
    )"
100
  );
665✔
101
  lbs_problem.def(
665✔
102
    "GetScalarFluxFieldFunction",
103
    [](LBSProblem& self, bool only_scalar_flux)
665✔
104
    {
105
      py::list field_function_list_per_group;
574✔
106
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
22,315✔
107
      {
108
        if (only_scalar_flux)
21,741✔
109
        {
110
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
25,520✔
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.CreateScalarFluxFieldFunction(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;
574✔
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
    Field functions are created on demand from the current solver state.
145

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

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

153
    Calling ``GetScalarFluxFieldFunction(only_scalar_flux=False)`` creates all requested
154
    moments from the current ``phi`` iterate at the time of the call.
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
665✔
160
  );
161
  lbs_problem.def(
1,330✔
162
    "CreateFieldFunction",
163
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
1,330✔
164
      const std::string&, const std::string&, double)>(&LBSProblem::CreateFieldFunction),
165
    R"(
166
    Create a named scalar field function derived from a 1D XS or ``power``.
167

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

620
    Parameters
621
    ----------
622
    adjoint: bool, default=True
623
        ``True`` enables adjoint mode and ``False`` enables forward mode.
624

625
    Notes
626
    -----
627
    This is one of two supported mode-setting paths in Python:
628
      1. ``options={'adjoint': ...}`` in the problem constructor.
629
      2. ``SetAdjoint(...)`` (this method).
630

631
    If this changes mode, OpenSn performs a full mode-transition reset:
632
      - Materials are reinitialized in the selected mode.
633
      - Point and volumetric sources are cleared.
634
      - Boundary conditions are cleared.
635
      - Scalar and angular flux vectors are zeroed.
636

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

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

643
    After a mode change, reapply the desired driving terms before solving, typically:
644
      - :meth:`LBSProblem.SetPointSources`
645
      - :meth:`LBSProblem.SetVolumetricSources`
646
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
647

648
    This routine is intentionally destructive with respect to source/boundary/flux state
649
    to avoid hidden coupling between forward and adjoint setups.
650
    )"
651
  );
652
  lbs_problem.def(
665✔
653
    "SetForward",
654
    &LBSProblem::SetForward,
665✔
655
    R"(
656
    Set forward transport mode.
657

658
    Equivalent to ``SetAdjoint(False)``.
659
    )"
660
  );
661
  lbs_problem.def(
665✔
662
    "IsAdjoint",
663
    &LBSProblem::IsAdjoint,
665✔
664
    R"(
665
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
666
    )"
667
  );
668
  lbs_problem.def(
665✔
669
    "IsTimeDependent",
670
    &LBSProblem::IsTimeDependent,
665✔
671
    R"(
672
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
673
    )"
674
  );
675

676
  // discrete ordinate solver
677
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
665✔
678
                               LBSProblem>(
679
    slv,
680
    "DiscreteOrdinatesProblem",
681
    R"(
682
    Base class for discrete ordinates problems in Cartesian geometry.
683

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

688
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
689
    )"
690
  );
665✔
691
  do_problem.def(
1,330✔
692
    py::init(
665✔
693
      [](py::kwargs& params)
583✔
694
      {
695
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
583✔
696
      }
697
    ),
698
    R"(
699
    Construct a discrete ordinates problem with Cartesian geometry.
700

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

815
    Notes
816
    -----
817
    Switch problem from steady-state to time-dependent mode. This updates problem
818
    internals (sweep chunk mode and source-function) while preserving user boundary
819
    conditions and fixed sources.
820

821
    Requires ``options.save_angular_flux=True`` at problem creation.
822
    )"
823
  );
824
  do_problem.def(
665✔
825
    "SetSteadyStateMode",
826
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
665✔
827
    R"(
828
    Set the problem to steady-state mode.
829

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

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

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

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

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

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

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

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

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

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

953
    .. code-block:: python
954

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

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

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

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

969
    .. code-block:: python
970

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1245
  // clang-format off
1246
  // steady state solver
1247
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
665✔
1248
                                        Solver>(
1249
    slv,
1250
    "SteadyStateSourceSolver",
1251
    R"(
1252
    Steady state solver.
1253

1254
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1255
    )"
1256
  );
665✔
1257
  steady_state_solver.def(
1,330✔
1258
    py::init(
665✔
1259
      [](py::kwargs& params)
407✔
1260
      {
1261
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
407✔
1262
      }
1263
    ),
1264
    R"(
1265
    Construct a steady state solver.
1266

1267
    Parameters
1268
    ----------
1269
    problem : pyopensn.solver.LBSProblem
1270
        Existing LBSProblem instance.
1271

1272
    Notes
1273
    -----
1274
    If ``problem.options.read_restart_path`` is set, restart data is read
1275
    during :meth:`Initialize`. If ``problem.options.restart_writes_enabled`` is
1276
    true, a restart dump is written after :meth:`Execute` completes.
1277
    )"
1278
  );
1279
  steady_state_solver.def(
665✔
1280
    "ComputeBalanceTable",
1281
    [balance_table_to_dict](const SteadyStateSourceSolver& self)
665✔
1282
    {
1283
      return balance_table_to_dict(self.ComputeBalanceTable());
19✔
1284
    },
1285
    R"(
1286
    Compute and return the global balance table using the solver's normalization.
1287
    This is a collective operation and must be called on all ranks.
1288

1289
    Returns
1290
    -------
1291
    dict
1292
        Dictionary with the following entries:
1293

1294
        - ``absorption_rate``:
1295
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1296
          groups and the full domain.
1297
        - ``production_rate``:
1298
          Global volumetric production/source rate used by the solver,
1299
          approximately ``integral Q dV`` summed over groups and the full domain.
1300
        - ``inflow_rate``:
1301
          Global incoming boundary contribution integrated over incoming
1302
          angular flux on boundaries.
1303
        - ``outflow_rate``:
1304
          Global outgoing boundary contribution accumulated from face outflow
1305
          tallies.
1306
        - ``balance``:
1307
          Rate balance,
1308
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1309

1310
    Notes
1311
    -----
1312
    This solver applies no extra normalization to the balance table.
1313
    )"
1314
  );
1315
  // clang-format on
1316
}
665✔
1317

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

1350
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1351
      )"
1352
    );
665✔
1353
  transient_solver.def(
1,330✔
1354
    py::init(
665✔
1355
      [](py::kwargs& params)
180✔
1356
      {
1357
        return TransientSolver::Create(kwargs_to_param_block(params));
180✔
1358
      }
1359
    ),
1360
    R"(
1361
    Construct a transient solver.
1362

1363
    Parameters
1364
    ----------
1365
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1366
        Existing discrete ordinates problem instance.
1367
    dt : float, optional, default=2.0e-3
1368
        Time step size used during the simulation.
1369
    stop_time : float, optional, default=0.1
1370
        Simulation end time.
1371
    theta : float, optional, default=0.5
1372
        Time differencing scheme parameter.
1373
    initial_state : str, optional, default="existing"
1374
        Initial state for the transient solve. Allowed values: existing, zero.
1375
        In "zero" mode, scalar flux vectors are reset to zero.
1376
    verbose : bool, optional, default=True
1377
        Enable verbose logging.
1378

1379
    Notes
1380
    -----
1381
    The associated problem must have ``save_angular_flux=True`` enabled. This
1382
    is required for transient problems.
1383

1384
    If ``problem.options.read_restart_path`` is set, restart data is read
1385
    during :meth:`Initialize`. If ``problem.options.restart_writes_enabled`` is
1386
    true, timed restart dumps may be written during :meth:`Execute` and a final
1387
    restart dump is written when execution completes.
1388
    )"
1389
  );
1390
  transient_solver.def(
665✔
1391
    "SetTimeStep",
1392
    &TransientSolver::SetTimeStep,
665✔
1393
    R"(
1394
    Set the timestep size used by :meth:`Advance`.
1395

1396
    Parameters
1397
    ----------
1398
    dt : float
1399
        New timestep size.
1400
    )");
1401
  transient_solver.def(
665✔
1402
    "SetTheta",
1403
    &TransientSolver::SetTheta,
665✔
1404
    R"(
1405
    Set the theta parameter used by :meth:`Advance`.
1406

1407
    Parameters
1408
    ----------
1409
    theta : float
1410
        Theta value between 1.0e-16 and 1.
1411
    )");
1412
  transient_solver.def(
665✔
1413
    "Advance",
1414
    &TransientSolver::Advance,
665✔
1415
    R"(
1416
    Advance the solver by a single timestep.
1417

1418
    Notes
1419
    -----
1420
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1421
    :meth:`Execute`.
1422
    )");
1423
  transient_solver.def(
665✔
1424
    "SetPreAdvanceCallback",
1425
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
665✔
1426
      &TransientSolver::SetPreAdvanceCallback),
1427
    R"(
1428
    Register a callback that runs before each advance within :meth:`Execute`.
1429

1430
    Parameters
1431
    ----------
1432
    callback : Optional[Callable[[], None]]
1433
        Function invoked before the solver advances a timestep. Pass None to clear.
1434
        If the callback modifies the timestep, the new value is used for the
1435
        upcoming step.
1436
    )");
1437
  transient_solver.def(
665✔
1438
    "SetPreAdvanceCallback",
1439
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
665✔
1440
      &TransientSolver::SetPreAdvanceCallback),
1441
    "Clear the PreAdvance callback by passing None.");
1442
  transient_solver.def(
665✔
1443
    "SetPostAdvanceCallback",
1444
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
665✔
1445
      &TransientSolver::SetPostAdvanceCallback),
1446
    R"(
1447
    Register a callback that runs after each advance within :meth:`Execute`.
1448

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

1469
    Returns
1470
    -------
1471
    dict
1472
        Dictionary with the following entries:
1473

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

1506
    Notes
1507
    -----
1508
    This solver applies no extra normalization to the balance table.
1509

1510
    The transient inventory terms currently use the end-of-step rate balance to
1511
    estimate the timestep inventory change.
1512
    )"
1513
  );
1514
  slv.attr("BackwardEuler") = 1.0;
665✔
1515
  slv.attr("CrankNicolson") = 0.5;
1,330✔
1516
  // clang-format on
1517
}
665✔
1518

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

1552
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1553
    )"
1554
  );
665✔
1555
  non_linear_k_eigen_solver.def(
1,330✔
1556
    py::init(
665✔
1557
      [](py::kwargs& params)
37✔
1558
      {
1559
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
37✔
1560
      }
1561
        ),
1562
    R"(
1563
    Construct a non-linear k-eigenvalue solver.
1564

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

1612
    Returns
1613
    -------
1614
    dict
1615
        Dictionary with the following entries:
1616

1617
        - ``absorption_rate``:
1618
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1619
          groups and the full domain.
1620
        - ``production_rate``:
1621
          Global volumetric production/source rate used by the solver,
1622
          approximately ``integral Q dV`` summed over groups and the full domain.
1623
        - ``inflow_rate``:
1624
          Global incoming boundary contribution integrated over incoming
1625
          angular flux on boundaries.
1626
        - ``outflow_rate``:
1627
          Global outgoing boundary contribution accumulated from face outflow
1628
          tallies.
1629
        - ``balance``:
1630
          Rate balance,
1631
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1632

1633
    Notes
1634
    -----
1635
    For k-eigenvalue balance reporting, this solver scales the production term by
1636
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1637
    )"
1638
  );
1639
  // clang-format on
1640
}
665✔
1641

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

1675
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
1676
    )"
1677
  );
665✔
1678
  pi_k_eigen_solver.def(
1,330✔
1679
    py::init(
665✔
1680
      [](py::kwargs& params)
161✔
1681
      {
1682
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
161✔
1683
      }
1684
    ),
1685
    R"(
1686
    Construct a power iteration k-eigen solver.
1687

1688
    Parameters
1689
    ----------
1690
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1691
        Existing DiscreteOrdinatesProblem instance.
1692
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
1693
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
1694
    max_iters: int, default = 1000
1695
        Maximum power iterations allowed.
1696
    k_tol: float, default = 1.0e-10
1697
        Tolerance on the k-eigenvalue.
1698
    reset_solution: bool, default=True
1699
        If true, initialize flux moments to 1.0.
1700
    reset_phi0: bool, default=True
1701
        If true, reinitializes scalar fluxes to 1.0.
1702

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

1728
    Returns
1729
    -------
1730
    dict
1731
        Dictionary with the following entries:
1732

1733
        - ``absorption_rate``:
1734
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1735
          groups and the full domain.
1736
        - ``production_rate``:
1737
          Global volumetric production/source rate used by the solver,
1738
          approximately ``integral Q dV`` summed over groups and the full domain.
1739
        - ``inflow_rate``:
1740
          Global incoming boundary contribution integrated over incoming
1741
          angular flux on boundaries.
1742
        - ``outflow_rate``:
1743
          Global outgoing boundary contribution accumulated from face outflow
1744
          tallies.
1745
        - ``balance``:
1746
          Rate balance,
1747
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1748

1749
    Notes
1750
    -----
1751
    For k-eigenvalue balance reporting, this solver scales the production term by
1752
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1753
    )"
1754
  );
1755
  // clang-format on
1756
}
665✔
1757

1758
// Wrap LBS solver
1759
void
1760
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
665✔
1761
{
1762
  // clang-format off
1763
  // discrete ordinates k-eigen acceleration base
1764
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
665✔
1765
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
1766
    slv,
1767
    "DiscreteOrdinatesKEigenAcceleration",
1768
    R"(
1769
    Base class for discrete ordinates k-eigenvalue acceleration methods.
1770

1771
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
1772
    )"
1773
  );
665✔
1774
  // SCDSA acceleration
1775
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
665✔
1776
                                       std::shared_ptr<SCDSAAcceleration>,
1777
                                       DiscreteOrdinatesKEigenAcceleration>(
1778
    slv,
1779
    "SCDSAAcceleration",
1780
    R"(
1781
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
1782

1783
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
1784
    )"
1785
  );
665✔
1786
  scdsa_acceleration.def(
1,330✔
1787
    py::init(
665✔
1788
      [](py::kwargs& params)
8✔
1789
      {
1790
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8✔
1791
      }
1792
    ),
1793
    R"(
1794
    SCDSA acceleration for the power iteration k-eigenvalue solver.
1795

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

1827
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
1828
    )"
1829
  );
665✔
1830
  smm_acceleration.def(
1,330✔
1831
    py::init(
665✔
1832
      [](py::kwargs& params)
4✔
1833
      {
1834
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4✔
1835
      }
1836
    ),
1837
    R"(
1838
    SMM acceleration for the power iteration k-eigenvalue solver.
1839

1840
    Warnings
1841
    --------
1842
       SMM acceleration is **experimental** and should be used with caution!
1843
       SMM accleration only supports problems with isotropic scattering.
1844

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

1870
// Wrap the solver components of OpenSn
1871
void
1872
py_solver(py::module& pyopensn)
72✔
1873
{
1874
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
72✔
1875
  WrapProblem(slv);
72✔
1876
  WrapSolver(slv);
72✔
1877
  WrapLBS(slv);
72✔
1878
  WrapSteadyState(slv);
72✔
1879
  WrapTransient(slv);
72✔
1880
  WrapNLKEigen(slv);
72✔
1881
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
72✔
1882
  WrapPIteration(slv);
72✔
1883
}
72✔
1884

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