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

Open-Sn / opensn / 36229024049

26 Sep 2026 01:02AM UTC coverage: 77.779% (+0.2%) from 77.587%
36229024049

push

github

web-flow
Merge pull request #1160 from wdhawkins/pr810_emergency_fix

Fixing PR810's outdated mesh API usage.

13 of 13 new or added lines in 1 file covered. (100.0%)

89 existing lines in 2 files now uncovered.

27428 of 35264 relevant lines covered (77.78%)

86882432.76 hits per line

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

81.43
/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/logging/log.h"
7
#include <pybind11/stl.h>
8
#include "framework/runtime.h"
9
#include "framework/field_functions/field_function_grid_based.h"
10
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/discrete_ordinates_keigen_acceleration.h"
11
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/cmfd_acceleration.h"
12
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/scdsa_acceleration.h"
13
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/acceleration/smm_acceleration.h"
14
#include "modules/linear_boltzmann_solvers/discrete_ordinates_curvilinear_problem/discrete_ordinates_curvilinear_problem.h"
15
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/discrete_ordinates_problem.h"
16
#include "modules/linear_boltzmann_solvers/uncollided_problem/uncollided_problem.h"
17
#include "modules/linear_boltzmann_solvers/uncollided_problem/uncollided_solver.h"
18
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/io/discrete_ordinates_problem_io.h"
19
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/transient_solver.h"
20
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/steady_state_solver.h"
21
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/nl_keigen_solver.h"
22
#include "modules/linear_boltzmann_solvers/discrete_ordinates_problem/solvers/pi_keigen_solver.h"
23
#include "modules/linear_boltzmann_solvers/lbs_problem/io/lbs_problem_io.h"
24
#include "modules/linear_boltzmann_solvers/lbs_problem/lbs_problem.h"
25
#include "modules/linear_boltzmann_solvers/lbs_problem/compute/lbs_compute.h"
26
#include "modules/solver.h"
27
#include <pybind11/numpy.h>
28
#include <algorithm>
29
#include <cstddef>
30
#include <cstdint>
31
#include <map>
32
#include <memory>
33
#include <string>
34
#include <vector>
35

36
namespace opensn
37
{
38

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

51
    Wrapper of :cpp:class:`opensn::Problem`.
52
    )"
53
  );
1,099 ✔
54
  // clang-format on
55
}
1,099 ✔
56

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

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

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

102
    Wrapper of :cpp:class:`opensn::LBSProblem`.
103
    )"
104
  );
1,099 ✔
105
  lbs_problem.def(
1,099 ✔
106
    "GetScalarFluxFieldFunction",
107
    [](LBSProblem& self, bool only_scalar_flux)
1,099 ✔
108
    {
109
      py::list field_function_list_per_group;
15,279 ✔
110
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
54,530 ✔
111
      {
112
        if (only_scalar_flux)
39,251 ✔
113
        {
114
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
60,538 ✔
115
        }
116
        else
117
        {
118
          py::list field_function_list_per_moment;
8,982 ✔
119
          for (unsigned int moment = 0; moment < self.GetNumMoments(); moment++)
37,172 ✔
120
          {
121
            field_function_list_per_moment.append(self.CreateScalarFluxFieldFunction(group, moment));
56,380 ✔
122
          }
123
          field_function_list_per_group.append(field_function_list_per_moment);
8,982 ✔
124
        }
8,982 ✔
125
      }
126
      return field_function_list_per_group;
15,279 ✔
127
    },
×
128
    R"(
129
    Return scalar-flux or flux-moment field functions grouped by energy group.
130

131
    Parameters
132
    ----------
133
    only_scalar_flux : bool, default=True
134
        If True, returns only the zeroth moment (scalar flux) field function for each group.
135
        If False, returns all moment field functions for each group.
136

137
    Returns
138
    -------
139
    Union[List[pyopensn.fieldfunc.FieldFunctionGridBased], List[List[pyopensn.fieldfunc.FieldFunctionGridBased]]]
140
        If ``only_scalar_flux=True``:
141
        ``result[g]`` is the scalar-flux field function for group ``g``.
142

143
        If ``only_scalar_flux=False``:
144
        ``result[g][m]`` is the field function for group ``g`` and moment ``m``.
145

146
    Notes
147
    -----
148
    Field functions are created on demand from the current solver state.
149

150
    The returned field functions are snapshots of the solver state at creation time; they are not
151
    refreshed automatically if the solver state changes.
152

153
    They support ``CanUpdate()`` and ``Update()`` while their owning problem is still alive.
154
    Calling ``Update()`` explicitly refreshes the same field-function object from the solver's
155
    latest state.
156

157
    Calling ``GetScalarFluxFieldFunction(only_scalar_flux=False)`` creates all requested
158
    moments from the current ``phi`` iterate at the time of the call.
159

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

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

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

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

192
    If ``xs_name == "power"``, the same power-generation formula used elsewhere by the solver
193
    is applied on demand.
194

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

199
    The returned field function is a fresh object created for this call. It is not
200
    automatically updated by later solves or timesteps.
201
    )",
202
    py::arg("name"),
2,198 ✔
203
    py::arg("xs_name"),
1,099 ✔
204
    py::arg("power_normalization_target") = -1.0
1,099 ✔
205
  );
206
  lbs_problem.def(
1,099 ✔
207
    "ComputeFieldFunctionPowerScaleFactor",
208
    &LBSProblem::ComputeFieldFunctionPowerScaleFactor,
2,198 ✔
209
    R"(
210
    Compute the scale factor that would normalize the current scalar flux's total power to a
211
    target value.
212

213
    This is a collective operation and must be called on all MPI ranks.
214

215
    Parameters
216
    ----------
217
    power_normalization_target : float
218
        Target total power (must be positive). The returned factor is
219
        ``power_normalization_target / global_total_power``, where ``global_total_power`` is the
220
        same MPI-reduced, kappa-weighted total power ``CreateFieldFunction(..., "power", ...)``
221
        and the ``power`` field function use.
222

223
    Notes
224
    -----
225
    This does not mutate any solver state. It is the same scale factor
226
    ``CreateFieldFunction(..., "power", power_normalization_target)`` applies internally, exposed
227
    directly so callers can apply it to their own derived quantities (e.g. postprocessor output)
228
    without needing a field function.
229
    )",
230
    py::arg("power_normalization_target")
1,099 ✔
231
  );
232
  lbs_problem.def(
1,099 ✔
233
    "GetTime",
234
    &LBSProblem::GetTime,
1,099 ✔
235
    R"(
236
    Get the current simulation time in seconds.
237
    )"
238
  );
239
  lbs_problem.def(
1,099 ✔
240
    "GetTimeStep",
241
    &LBSProblem::GetTimeStep,
1,099 ✔
242
    R"(
243
    Get the current timestep size.
244
    )"
245
  );
246
  lbs_problem.def(
1,099 ✔
247
    "ComputeFissionRate",
248
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,100 ✔
249
    {
250
      const std::vector<double>* phi_ptr = nullptr;
1 ✔
251
      if (scalar_flux_iterate == "old")
1 ✔
252
      {
253
        phi_ptr = &self.GetPhiOldLocal();
×
254
      }
255
      else if (scalar_flux_iterate == "new")
1 ✔
256
      {
257
        phi_ptr = &self.GetPhiNewLocal();
1 ✔
258
      }
259
      else
260
      {
261
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
262
      }
263
      return ComputeFissionRate(self, *phi_ptr);
1 ✔
264
    },
265
    R"(
266
    Computes the total fission rate.
267

268
    Parameters
269
    ----------
270
    scalar_flux_iterate : {'old', 'new'}
271
        Specifies which scalar flux vector to use in the calculation.
272
            - 'old': Use the previous scalar flux iterate.
273
            - 'new': Use the current scalar flux iterate.
274

275
    Returns
276
    -------
277
    float
278
        The total fission rate.
279

280
    Raises
281
    ------
282
    ValueError
283
        If `scalar_flux_iterate` is not 'old' or 'new'.
284
    )",
285
    py::arg("scalar_flux_iterate")
1,099 ✔
286
  );
287
  lbs_problem.def(
1,099 ✔
288
    "ComputeFissionProduction",
289
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
2,640 ✔
290
    {
291
      const std::vector<double>* phi_ptr = nullptr;
1,541 ✔
292
      if (scalar_flux_iterate == "old")
1,541 ✔
293
      {
294
        phi_ptr = &self.GetPhiOldLocal();
88 ✔
295
      }
296
      else if (scalar_flux_iterate == "new")
1,453 ✔
297
      {
298
        phi_ptr = &self.GetPhiNewLocal();
1,453 ✔
299
      }
300
      else
301
      {
302
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
303
      }
304
      return ComputeFissionProduction(self, *phi_ptr);
1,541 ✔
305
    },
306
    R"(
307
    Computes the total fission production (nu*fission).
308

309
    Parameters
310
    ----------
311
    scalar_flux_iterate : {'old', 'new'}
312
        Specifies which scalar flux vector to use in the calculation.
313
            - 'old': Use the previous scalar flux iterate.
314
            - 'new': Use the current scalar flux iterate.
315

316
    Returns
317
    -------
318
    float
319
        The total fission production.
320

321
    Raises
322
    ------
323
    ValueError
324
        If `scalar_flux_iterate` is not 'old' or 'new'.
325
    )",
326
    py::arg("scalar_flux_iterate")
1,099 ✔
327
  );
328
  lbs_problem.def(
1,099 ✔
329
    "GetPhiOldLocal",
330
    [](LBSProblem& self)
1,195 ✔
331
    {
332
      return convert_vector(self.GetPhiOldLocal());
96 ✔
333
    },
334
    R"(
335
    Get the previous scalar flux iterate (local vector).
336

337
    Returns
338
    -------
339
    memoryview
340
        Memory view of the local old scalar flux vector.
341
    )"
342
  );
343
  lbs_problem.def(
1,099 ✔
344
    "GetPhiNewLocal",
345
    [](LBSProblem& self)
1,855 ✔
346
    {
347
      return convert_vector(self.GetPhiNewLocal());
756 ✔
348
    },
349
    R"(
350
    Get the current scalar flux iterate (local vector).
351

352
    Returns
353
    -------
354
    memoryview
355
        Memory view of the local new scalar flux vector.
356
    )"
357
  );
358
  lbs_problem.def(
1,099 ✔
359
    "WriteFluxMoments",
360
    [](LBSProblem& self, const std::string& file_base)
1,138 ✔
361
    {
362
      LBSSolverIO::WriteFluxMoments(self, file_base);
39 ✔
363
    },
364
    R"(
365
    Write flux moments to file.
366

367
    Parameters
368
    ----------
369
    file_base: str
370
        File basename.
371
    )",
372
    py::arg("file_base")
1,099 ✔
373
  );
374
  lbs_problem.def(
1,099 ✔
375
    "CreateAndWriteSourceMoments",
376
    [](LBSProblem& self, const std::string& file_base)
1,103 ✔
377
    {
378
      std::vector<double> source_moments = self.MakeSourceMomentsFromPhi();
4 ✔
379
      LBSSolverIO::WriteFluxMoments(self, file_base, source_moments);
4 ✔
380
    },
4 ✔
381
    R"(
382
    Write source moments from latest flux iterate to file.
383

384
    Parameters
385
    ----------
386
    file_base: str
387
        File basename.
388
    )",
389
    py::arg("file_base")
1,099 ✔
390
  );
391
  lbs_problem.def(
1,099 ✔
392
    "ReadFluxMomentsAndMakeSourceMoments",
393
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,099 ✔
394
    {
395
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
×
396
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
×
397
      self.SetExtSrcMomentsFrom(ext_src_moments);
×
398
      log.Log() << "Making source moments from flux file.";
×
399
      const auto temp_phi = self.GetPhiOldLocal();
×
400
      self.SetPhiOldFrom(self.GetExtSrcMomentsLocal());
×
401
      self.SetExtSrcMomentsFrom(self.MakeSourceMomentsFromPhi());
×
402
      self.SetPhiOldFrom(temp_phi);
×
403
    },
×
404
    R"(
405
    Read flux moments and compute corresponding source moments.
406

407
    Parameters
408
    ----------
409
    file_base: str
410
        File basename.
411
    single_file_flag: bool
412
        True if all flux moments are in a single file.
413
    )",
414
    py::arg("file_base"),
2,198 ✔
415
    py::arg("single_file_flag")
1,099 ✔
416
  );
417
  lbs_problem.def(
1,099 ✔
418
    "ReadSourceMoments",
419
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,103 ✔
420
    {
421
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
4 ✔
422
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
4 ✔
423
      self.SetExtSrcMomentsFrom(ext_src_moments);
4 ✔
424
    },
4 ✔
425
    R"(
426
    Read source moments from file.
427

428
    Parameters
429
    ----------
430
    file_base: str
431
        File basename.
432
    single_file_flag: bool
433
        True if all source moments are in a single file.
434
    )",
435
    py::arg("file_base"),
2,198 ✔
436
    py::arg("single_file_flag")
1,099 ✔
437
  );
438
  lbs_problem.def(
1,099 ✔
439
    "ReadFluxMoments",
440
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,099 ✔
441
    {
442
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag);
×
443
    },
444
    R"(
445
    Read flux moment data.
446

447
    Parameters
448
    ----------
449
    file_base: str
450
        File basename.
451
    single_file_flag: bool
452
        True if all flux moments are in a single file.
453
    )",
454
    py::arg("file_base"),
2,198 ✔
455
    py::arg("single_file_flag")
1,099 ✔
456
  );
457
  lbs_problem.def(
1,099 ✔
458
    "WriteAngularFluxes",
459
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,106 ✔
460
    {
461
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
7 ✔
462
    },
463
    R"(
464
    Write angular flux data to file.
465

466
    Parameters
467
    ----------
468
    file_base: str
469
        File basename.
470
    )",
471
    py::arg("file_base")
1,099 ✔
472
  );
473
  lbs_problem.def(
1,099 ✔
474
    "ReadAngularFluxes",
475
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,103 ✔
476
    {
477
      DiscreteOrdinatesProblemIO::ReadAngularFluxes(self, file_base);
4 ✔
478
    },
479
    R"(
480
    Read angular fluxes from file.
481

482
    Parameters
483
    ----------
484
    file_base: str
485
        File basename.
486
    )",
487
    py::arg("file_base")
1,099 ✔
488
  );
489
  lbs_problem.def(
2,198 ✔
490
    "WriteSurfaceAngularFluxes",
491
    [](DiscreteOrdinatesProblem& self,
1,111 ✔
492
       const std::string& file_base,
493
       const std::vector<std::string>& boundary_surfaces,
494
       const std::map<std::string, std::pair<std::string, double>>& interior_surfaces)
495
    {
496
      DiscreteOrdinatesProblemIO::WriteSurfaceAngularFluxes(
12 ✔
497
        self, file_base, boundary_surfaces, interior_surfaces);
498
    },
499
    R"(
500
    Write surface angular flux data to file.
501

502
    Parameters
503
    ----------
504
    file_base: str
505
        File basename.
506
    boundary_surfaces: list[str], default=[]
507
        Boundary names to export.
508
    interior_surfaces: dict[str, tuple[str, float]], default={}
509
        Interior surfaces in the form {'name': ('axis', value)}, where axis is 'x', 'y', or 'z'.
510
        Each interior surface is written under two tags: '<name>_u' for faces whose outward normal
511
        aligns with the positive axis and '<name>_d' for faces with the opposite orientation.
512
        An interior surface may not coincide with an exterior boundary; export that boundary by
513
        name using boundary_surfaces instead.
514
    )",
515
    py::arg("file_base"),
1,099 ✔
516
    py::arg("boundary_surfaces") = std::vector<std::string>{},
2,198 ✔
517
    py::arg("interior_surfaces") = std::map<std::string, std::pair<std::string, double>>{}
2,198 ✔
518
  );
519
  lbs_problem.def(
1,099 ✔
520
    "ReadSurfaceAngularFluxes",
521
    [](DiscreteOrdinatesProblem& self, const std::string& file_base, py::list surfaces)
1,109 ✔
522
    {
523
      std::vector<std::string> surface_ids;
10 ✔
524
      for (py::handle surface : surfaces)
36 ✔
525
        surface_ids.push_back(surface.cast<std::string>());
32 ✔
526

527
      const auto surface_fluxes =
10 ✔
528
        DiscreteOrdinatesProblemIO::ReadSurfaceAngularFluxes(self, file_base, surface_ids);
10 ✔
529
      py::list result;
10 ✔
530
      for (const auto& surface_flux : surface_fluxes)
26 ✔
531
      {
532
        py::dict mapping;
16 ✔
533
        mapping["cell_ids"] = surface_flux.mapping.cell_ids;
16 ✔
534
        mapping["num_face_nodes"] = surface_flux.mapping.num_face_nodes;
16 ✔
535
        mapping["cell_map"] = surface_flux.mapping.cell_map;
16 ✔
536
        mapping["cell_stride"] = surface_flux.mapping.cell_stride;
16 ✔
537
        mapping["nodes_x"] = surface_flux.mapping.nodes_x;
16 ✔
538
        mapping["nodes_y"] = surface_flux.mapping.nodes_y;
16 ✔
539
        mapping["nodes_z"] = surface_flux.mapping.nodes_z;
16 ✔
540

541
        py::dict data;
16 ✔
542
        data["omega"] = surface_flux.data.omega;
16 ✔
543
        data["mu"] = surface_flux.data.mu;
16 ✔
544
        data["wt_d"] = surface_flux.data.wt_d;
16 ✔
545
        data["M_ij"] = surface_flux.data.mass_matrix;
16 ✔
546
        data["fe_shape"] = surface_flux.data.fe_shape;
16 ✔
547
        data["psi"] = surface_flux.data.psi;
16 ✔
548
        data["node_index"] = surface_flux.data.node_index;
16 ✔
549
        data["dir_index"] = surface_flux.data.dir_index;
16 ✔
550

551
        py::dict entry;
16 ✔
552
        entry["groupset_id"] = surface_flux.groupset_id;
16 ✔
553
        entry["surface_name"] = surface_flux.surface_name;
16 ✔
554
        entry["mapping"] = std::move(mapping);
16 ✔
555
        entry["data"] = std::move(data);
16 ✔
556
        result.append(std::move(entry));
16 ✔
557
      }
16 ✔
558
      return result;
10 ✔
559
    },
10 ✔
560
    R"(
561
    Read surface angular fluxes from file.
562

563
    Parameters
564
    ----------
565
    file_base: str
566
        File basename.
567
    surfaces: list[str]
568
        Stored surface tags to read. Use the boundary name for an exterior boundary. For an
569
        interior surface named '<name>', use '<name>_u' for faces whose outward normal aligns with
570
        the positive axis and '<name>_d' for faces with the opposite orientation.
571

572
    Returns
573
    -------
574
    List[dict]
575
        Surface mapping and angular-flux data for each groupset and requested surface.
576
    )",
577
    py::arg("file_base"),
2,198 ✔
578
    py::arg("surfaces")
1,099 ✔
579
  );
580
  lbs_problem.def(
1,099 ✔
581
    "SetPointSources",
582
    [](LBSProblem& self, py::kwargs& params)
1,099 ✔
583
    {
UNCOV
584
      for (auto [key, value] : params)
×
585
      {
UNCOV
586
        auto c_key = key.cast<std::string>();
×
UNCOV
587
        if (c_key == "clear_point_sources")
×
UNCOV
588
          self.ClearPointSources();
×
UNCOV
589
        else if (c_key == "point_sources")
×
590
        {
UNCOV
591
          auto sources = value.cast<py::list>();
×
UNCOV
592
          for (auto source : sources)
×
593
          {
UNCOV
594
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
595
          }
UNCOV
596
        }
×
597
        else
UNCOV
598
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
UNCOV
599
      }
×
UNCOV
600
    },
×
601
    R"(
602
    Set or clear point sources.
603

604
    Parameters
605
    ----------
606
    clear_point_sources: bool, default=False
607
        If true, all current the point sources of the problem are deleted.
608
    point_sources: List[pyopensn.source.PointSource]
609
        List of new point sources to be added to the problem.
610
    )"
611
  );
612
  lbs_problem.def(
1,099 ✔
613
    "SetVolumetricSources",
614
    [](LBSProblem& self, py::kwargs& params)
1,167 ✔
615
    {
616
      for (auto [key, value] : params)
204 ✔
617
      {
618
        auto c_key = key.cast<std::string>();
68 ✔
619
        if (c_key == "clear_volumetric_sources")
68 ✔
620
          self.ClearVolumetricSources();
33 ✔
621
        else if (c_key == "volumetric_sources")
35 ✔
622
        {
623
          auto sources = value.cast<py::list>();
35 ✔
624
          for (auto source : sources)
105 ✔
625
          {
626
            self.AddVolumetricSource(source.cast<std::shared_ptr<VolumetricSource>>());
70 ✔
627
          }
628
        }
35 ✔
629
        else
UNCOV
630
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
631
      }
68 ✔
632
    },
68 ✔
633
    R"(
634
    Set or clear volumetric sources.
635

636
    Parameters
637
    ----------
638
    clear_volumetric_sources: bool, default=False
639
        If true, all current the volumetric sources of the problem are deleted.
640
    volumetric_sources: List[pyopensn.source.VolumetricSource]
641
        List of new volumetric sources to be added to the problem.
642
    )"
643
  );
644
  lbs_problem.def(
1,099 ✔
645
    "SetXSMap",
646
    [](LBSProblem& self, py::kwargs& params)
1,471 ✔
647
    {
648
      BlockID2XSMap xs_map;
372 ✔
649
      for (auto [key, value] : params)
1,116 ✔
650
      {
651
        auto c_key = key.cast<std::string>();
372 ✔
652
        if (c_key == "xs_map")
372 ✔
653
        {
654
          auto xs_entries = value.cast<py::list>();
372 ✔
655
          for (auto entry : xs_entries)
1,116 ✔
656
          {
657
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
372 ✔
658
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
372 ✔
659
            const auto& block_ids =
372 ✔
660
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
372 ✔
661
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
372 ✔
662
            for (const auto& block_id : block_ids)
744 ✔
663
              xs_map[block_id] = xs;
372 ✔
664
          }
372 ✔
665
        }
372 ✔
666
        else
UNCOV
667
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
668
      }
372 ✔
669
      self.SetBlockID2XSMap(xs_map);
372 ✔
670
    },
372 ✔
671
    R"(
672
    Replace the block-id to cross-section map.
673

674
    Parameters
675
    ----------
676
    xs_map: List[Dict]
677
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
678
          - block_ids: List[int] (required)
679
              Mesh block ids to associate with the cross section.
680
          - xs: pyopensn.xs.MultiGroupXS (required)
681
              Cross section object.
682

683
    Notes
684
    -----
685
    The problem is refreshed immediately after replacing the map. Material metadata,
686
    precursor storage, GPU carriers, and derived solver state owned by the concrete
687
    problem are rebuilt for the new cross sections.
688

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

694
    Existing precursor concentrations are remapped by local cell and precursor-family
695
    index. When the new material for a cell has fewer precursor families, extra old
696
    families are dropped. When it has more families, newly introduced families are
697
    initialized to zero. If a cell changes through a material with zero precursors,
698
    the precursor history for that cell is discarded and any later reintroduced
699
    precursor families start from zero.
700

701
    If any fissionable material in the new map contains delayed-neutron precursor
702
    data and ``options.use_precursors=True``, all fissionable materials in the map
703
    must contain precursor data. Non-fissionable materials may have zero precursors.
704

705
    Forward/adjoint mode toggles via :meth:`LBSProblem.SetAdjoint` do not change this map.
706
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer. If the same
707
    ``MultiGroupXS`` handle is shared across multiple problems, toggling adjoint mode in one
708
    problem also changes the transport mode seen by the others.
709
    )"
710
  );
711
  lbs_problem.def(
1,099 ✔
712
    "ZeroPhi",
713
    [](LBSProblem& self)
1,291 ✔
714
    {
715
      self.ZeroPhi();
192 ✔
716
    },
717
    R"(
718
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
719
    )"
720
  );
721
  lbs_problem.def(
1,099 ✔
722
    "SetAdjoint",
723
    [](LBSProblem& self, bool adjoint)
1,099 ✔
724
    {
725
      self.SetAdjoint(adjoint);
29 ✔
726
    },
727
    py::arg("adjoint") = true,
1,099 ✔
728
    R"(
729
    Set forward/adjoint transport mode.
730

731
    Parameters
732
    ----------
733
    adjoint: bool, default=True
734
        ``True`` enables adjoint mode and ``False`` enables forward mode.
735

736
    Notes
737
    -----
738
    This is one of two supported mode-setting paths in Python:
739
      1. ``options={'adjoint': ...}`` in the problem constructor.
740
      2. ``SetAdjoint(...)`` (this method).
741

742
    If this changes mode, OpenSn performs a full mode-transition reset:
743
      - Materials are reinitialized in the selected mode.
744
      - Point and volumetric sources are cleared.
745
      - Boundary conditions are cleared.
746
      - Scalar and angular flux vectors are zeroed.
747

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

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

754
    After a mode change, reapply the desired driving terms before solving, typically:
755
      - :meth:`LBSProblem.SetPointSources`
756
      - :meth:`LBSProblem.SetVolumetricSources`
757
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
758

759
    This routine is intentionally destructive with respect to source/boundary/flux state
760
    to avoid hidden coupling between forward and adjoint setups.
761
    )"
762
  );
763
  lbs_problem.def(
1,099 ✔
764
    "SetForward",
765
    &LBSProblem::SetForward,
1,099 ✔
766
    R"(
767
    Set forward transport mode.
768

769
    Equivalent to ``SetAdjoint(False)``.
770
    )"
771
  );
772
  lbs_problem.def(
1,099 ✔
773
    "IsAdjoint",
774
    &LBSProblem::IsAdjoint,
1,099 ✔
775
    R"(
776
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
777
    )"
778
  );
779
  lbs_problem.def(
1,099 ✔
780
    "IsTimeDependent",
781
    &LBSProblem::IsTimeDependent,
1,099 ✔
782
    R"(
783
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
784
    )"
785
  );
786

787
  auto unc_problem =
1,099 ✔
788
    py::class_<UncollidedProblem, std::shared_ptr<UncollidedProblem>, LBSProblem>(
789
      slv,
790
      "UncollidedProblem",
791
      R"(
792
    Define an uncollided transport problem for a first-collision calculation.
793

794
    The problem stores the mesh, materials, point sources, boundaries, and
795
    near-source regions used by :class:`UncollidedSolver`. Its near-source
796
    ray-tracing and conservation treatment follow C. Woodsford, J. C. Ragusa,
797
    and J. E. Morel, "Sweep-based uncollided-flux treatment on unstructured
798
    grids," Progress in Nuclear Energy, vol. 200, 106524, 2026,
799
    https://doi.org/10.1016/j.pnucene.2026.106524.
800

801
    Wrapper of :cpp:class:`opensn::UncollidedProblem`.
802
    )");
1,099 ✔
803
  unc_problem.def(
2,198 ✔
804
    py::init(
1,099 ✔
805
      [](py::kwargs& params)
30 ✔
806
      {
807
        return UncollidedProblem::Create(kwargs_to_param_block(params));
30 ✔
808
      }),
809
    R"(
810
    Construct an uncollided transport problem.
811

812
    Parameters
813
    ----------
814
    mesh : MeshContinuum
815
        Cartesian two- or three-dimensional spatial mesh.
816
    num_groups : int
817
        Number of energy groups.
818
    groupsets : List[Dict], default=[]
819
        Groupset definitions required by the common LBS problem interface. The
820
        uncollided calculation itself does not use an angular quadrature.
821
    xs_map : List[Dict], default=[]
822
        Block-to-cross-section mappings. Total cross sections are used for
823
        attenuation and are recorded in the output file for compatibility
824
        checking by the collided problem.
825
    boundary_conditions : List[Dict], default=[]
826
        Vacuum or reflecting boundary conditions, using the same ``name`` and
827
        ``type`` entries as ``DiscreteOrdinatesProblem``. Reflecting boundaries
828
        are represented with directly projected image sources and must be
829
        planar, mutually orthogonal symmetry planes without an opposing
830
        reflecting plane.
831
    point_sources : List[pyopensn.source.PointSource], default=[]
832
        Explicit point sources. At least one point source is required. For now,
833
        each source must lie strictly inside a single cell; sources exactly on
834
        faces, edges, or vertices are rejected.
835
    near_source : List[pyopensn.logvol.LogicalVolume]
836
        Near-source ray-traced region for each point source, in the same order
837
        as ``point_sources``. This list is required whenever point sources are
838
        provided. Its length must equal the number of point sources, and every
839
        point source must lie in its corresponding region.
840
    scattering_order : int, default=0
841
        Maximum spherical-harmonic order written to the HDF5 file. It must be
842
        at least the scattering order of the collided problem that consumes
843
        the file.
844
    Notes
845
    -----
846
    Only explicit point sources are supported by the uncollided generator.
847
    Each point source must lie strictly inside a single cell. Uncollided
848
    generation must run on a Cartesian two- or three-dimensional mesh with
849
    exactly one MPI rank.
850

851
    Point-source placement strongly affects spatial accuracy. Construct or
852
    locally refine the mesh so that each source lies well inside its containing
853
    cell and near the cell centroid whenever the physical model permits. Exact
854
    balance after the near-source correction does not guarantee that the local
855
    flux shape is resolved; monitor the reported correction and verify mesh
856
    convergence.
857

858
    A volumetric source may be approximated externally by multiple weighted
859
    point sources, as in the Kobayashi benchmark example. Choose the source
860
    quadrature and mesh together so that those points lie well inside their
861
    cells, and verify convergence with respect to both discretizations.
862

863
    The ``options`` and ``use_gpus`` parameters are inherited from the base
864
    class interface but have no effect on uncollided generation and should be
865
    omitted.
866

867
    The uncollided file generated by :class:`UncollidedSolver` is
868
    partition-independent and may be consumed by a serial or parallel collided
869
    calculation on the same mesh. The collided problem must use the same
870
    reflecting boundaries recorded in the file.
871
    Each reflected image source is ray traced to every finite-element volume
872
    quadrature point and projected directly into the spatial discretization.
873
    )");
874

875
  // discrete ordinate solver
876
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
1,099 ✔
877
                               LBSProblem>(
878
    slv,
879
    "DiscreteOrdinatesProblem",
880
    R"(
881
    Base class for discrete ordinates problems in Cartesian geometry.
882

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

887
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
888
    )"
889
  );
1,099 ✔
890
  do_problem.def(
2,198 ✔
891
    py::init(
1,099 ✔
892
      [](py::kwargs& params)
1,556 ✔
893
      {
894
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
1,556 ✔
895
      }
896
    ),
897
    R"(
898
    Construct a discrete ordinates problem with Cartesian geometry.
899

900
    Parameters
901
    ----------
902
    mesh : MeshContinuum
903
        The spatial mesh.
904
    num_groups : int
905
        The total number of energy groups.
906
    groupsets : List[Dict], default=[]
907
        A list of input parameter blocks, each block provides the iterative properties for a
908
        groupset. Each dictionary supports:
909
          - groups_from_to: List[int] (required)
910
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
911
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
912
              Handle to an angular quadrature.
913
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
914
              Angle aggregation method to use during sweeping.
915
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
916
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
917
              Iterative method used for inner linear solves.
918
          - l_abs_tol: float, default=1.0e-6
919
              Inner linear solver absolute residual tolerance.
920
          - l_max_its: int, default=200
921
              Inner linear solver maximum iterations.
922
          - gmres_restart_interval: int, default=30
923
              GMRES restart interval, if GMRES is used.
924
          - allow_cycles: bool, default=True
925
              Whether cyclic dependencies are allowed in sweeps.
926
          - apply_wgdsa: bool, default=False
927
              Enable within-group DSA for this groupset.
928
          - wgdsa_l_abs_tol: float, default=1.0e-4
929
              WGDSA linear absolute tolerance.
930
          - wgdsa_l_max_its: int, default=30
931
              WGDSA maximum iterations.
932
          - wgdsa_verbose: bool, default=False
933
              Verbose WGDSA output.
934
          - wgdsa_petsc_options: str, default=''
935
              PETSc options string for the WGDSA solver.
936
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
937
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
938
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
939
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
940
              PETSc KSP/PC setup.
941
          - wgdsa_direct_solve_threshold: int, default=20000
942
              Maximum global WGDSA diffusion unknown count for the automatic direct solve.
943
          - apply_tgdsa: bool, default=False
944
              Enable two-grid DSA for this groupset.
945
          - tgdsa_l_abs_tol: float, default=1.0e-4
946
              TGDSA linear absolute tolerance.
947
          - tgdsa_l_max_its: int, default=30
948
              TGDSA maximum iterations.
949
          - tgdsa_verbose: bool, default=False
950
              Verbose TGDSA output.
951
          - tgdsa_petsc_options: str, default=''
952
              PETSc options string for the TGDSA solver.
953
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
954
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
955
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
956
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
957
              PETSc KSP/PC setup.
958
          - tgdsa_direct_solve_threshold: int, default=20000
959
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
960
    xs_map : List[Dict], default=[]
961
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
962
          - block_ids: List[int] (required)
963
              Mesh block IDs to associate with the cross section.
964
          - xs: pyopensn.xs.MultiGroupXS (required)
965
              Cross-section object to assign to the specified blocks.
966
    boundary_conditions: List[Dict], default=[]
967
        A list containing tables for each boundary specification. Each dictionary supports:
968
          - name: str (required)
969
              Boundary name that identifies the specific boundary.
970
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
971
              Boundary type specification.
972
          - group_strength: List[float], optional
973
              Required when ``type='isotropic'``. Isotropic strength per group.
974
          - start_time: float, optional
975
              Active start time for isotropic boundaries only. Defaults to -infinity.
976
          - end_time: float, optional
977
              Active end time for isotropic boundaries only. Defaults to infinity.
978
          - function: AngularFluxFunction, optional
979
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
980
              that returns incoming angular flux from group and direction.
981
          - time_function: AngularFluxTimeFunction, optional
982
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
983
              returns incoming angular flux from group, direction, and time.
984

985
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
986
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
987
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
988
        that callback.
989
    point_sources: List[pyopensn.source.PointSource], default=[]
990
        A list of point sources.
991
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
992
        A list of volumetric sources.
993
    options : Dict, default={}
994
        A block of optional configuration parameters, including:
995
          - max_mpi_message_size: int, default=32768
996
          - restart_writes_enabled: bool, default=False
997
              Enable restart dump writes for solvers that support restart output.
998
          - write_delayed_psi_to_restart: bool, default=True
999
              Include delayed sweep angular-flux buffers. Full continuation restarts require
1000
              these buffers whenever the problem has delayed sweep angular state, including
1001
              partitioned parallel, reflected-boundary, and cyclic-sweep cases. These buffers
1002
              are optional when a steady-state restart is used only as a transient initial
1003
              condition because the transient initialization can reconstruct angular state
1004
              from the flux moments.
1005
          - write_angular_flux_to_restart: bool, default=True
1006
              Include stored angular fluxes in restart dumps when ``save_angular_flux=True``.
1007
              This is required for continuing a time-dependent restart, but optional when a
1008
              steady-state restart is used only as a transient initial condition.
1009
          - read_restart_path: str, default=''
1010
              File stem for reading a full restart. The number of MPI ranks and partitioned
1011
              state layout must match the run that wrote the restart files.
1012
          - read_initial_condition_path: str, default=''
1013
              File stem for reading restart data as an initial condition. A steady-state
1014
              restart may be used by ``TransientSolver`` through this option.
1015
          - write_restart_path: str, default=''
1016
              File stem for restart dump writes. OpenSn appends the MPI rank and
1017
              ``.restart.h5`` to this stem.
1018
          - write_restart_time_interval: int, default=0
1019
            (must be 0 or >=30)
1020
          - use_precursors: bool, default=True
1021
            Enable delayed-neutron precursor treatment. This is treated as user intent and remains
1022
            active across later ``SetXSMap`` calls, even if the current XS map temporarily contains
1023
            no precursor-bearing material. When XS maps are swapped, existing precursor
1024
            concentrations are remapped by cell and precursor-family index; new families start at
1025
            zero and removed families are discarded. If any fissionable material in the active map
1026
            has precursor data and ``use_precursors=True``, all fissionable materials in that map
1027
            must have precursor data. Non-fissionable materials may have zero precursors.
1028
          - use_source_moments: bool, default=False
1029
          - save_angular_flux: bool, default=False
1030
            Store angular flux state (`psi`) for transient mode, angular-flux
1031
            field functions, and angular-flux I/O.
1032
          - adjoint: bool, default=False
1033
          - verbose_inner_iterations: bool, default=True
1034
            Print inner iteration details, including WGS and AGS iterations.
1035
          - verbose_outer_iterations: bool, default=True
1036
            Print outer solver progress, including PI/NLKE iterations and transient steps.
1037
          - max_ags_iterations: int, default=100
1038
          - ags_tolerance: float, default=1.0e-6
1039
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1040
          - power_default_kappa: float, default=3.20435e-11
1041
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1042
          - field_function_prefix: str, default=''
1043

1044
        These options are applied at problem creation.
1045
    sweep_type : str, default="AAH"
1046
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1047
        Both sweep types support time-dependent (transient) mode.
1048
    time_dependent : bool, default=False
1049
        If true, the problem starts in time-dependent mode. Otherwise it starts in
1050
        steady-state mode. Requires ``options.save_angular_flux=True``.
1051
        Both ``AAH`` and ``CBC`` sweep types support time-dependent mode.
1052
    uncollided_flux : str, default=""
1053
        HDF5 file generated by :class:`UncollidedSolver`. For steady-state
1054
        forward fixed-source calculations, OpenSn uses its moments to
1055
        construct the first-collision scattering and fission source, solves
1056
        for the collided component, and adds the uncollided moments back to
1057
        the converged state. The file must match the current group count, mesh
1058
        cell IDs, cell-node layout and coordinates, total cross sections, and
1059
        reflecting boundary set. Its maximum moment order must be at least
1060
        this problem's scattering order. The same serial file may be read by
1061
        any MPI partitioning of the matching mesh.
1062
    use_gpus : bool, default=False
1063
        A flag specifying whether GPU acceleration is used for the sweep. Both
1064
        ```AAH``` and ```CBC``` sweep types support GPU acceleration.
1065
    )"
1066
  );
1067
  do_problem.def(
1,099 ✔
1068
    "SetTimeDependentMode",
1069
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
1,099 ✔
1070
    R"(
1071
    Set the problem to time-dependent mode.
1072

1073
    Notes
1074
    -----
1075
    Switch problem from steady-state to time-dependent mode. This updates problem
1076
    internals (sweep chunk mode and source-function) while preserving user boundary
1077
    conditions and fixed sources.
1078

1079
    Requires ``options.save_angular_flux=True`` at problem creation.
1080
    )"
1081
  );
1082
  do_problem.def(
1,099 ✔
1083
    "SetSteadyStateMode",
1084
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
1,099 ✔
1085
    R"(
1086
    Set the problem to steady-state mode.
1087

1088
    Notes
1089
    -----
1090
    Switch problem from time-dependent to steady-state mode. This updates problem
1091
    internals (sweep chunk mode and source-function) while preserving user boundary
1092
    conditions and fixed sources.
1093
    )"
1094
  );
1095
  do_problem.def(
1,099 ✔
1096
    "IsTimeDependent",
1097
    &DiscreteOrdinatesProblem::IsTimeDependent,
1,099 ✔
1098
    R"(
1099
    Return ``True`` if the problem is currently in time-dependent mode.
1100
    )"
1101
  );
1102
  do_problem.def(
1,099 ✔
1103
    "SetBoundaryOptions",
1104
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
1,281 ✔
1105
    {
1106
      bool clear_boundary_conditions = false;
182 ✔
1107
      std::vector<InputParameters> boundary_params;
182 ✔
1108
      for (auto [key, value] : params)
722 ✔
1109
      {
1110
        auto c_key = key.cast<std::string>();
358 ✔
1111
        if (c_key == "clear_boundary_conditions")
358 ✔
1112
          clear_boundary_conditions = value.cast<bool>();
176 ✔
1113
        else if (c_key == "boundary_conditions")
182 ✔
1114
        {
1115
          auto boundaries = value.cast<py::list>();
182 ✔
1116
          for (auto boundary : boundaries)
976 ✔
1117
          {
1118
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
612 ✔
1119
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
612 ✔
1120
            boundary_params.push_back(std::move(input));
612 ✔
1121
          }
612 ✔
1122
        }
182 ✔
1123
        else
1124
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
1125
      }
358 ✔
1126
      if (clear_boundary_conditions or not boundary_params.empty())
182 ✔
1127
        self.SetBoundaryOptions(boundary_params, clear_boundary_conditions);
182 ✔
1128
    },
182 ✔
1129
    R"(
1130
    Set or clear boundary conditions.
1131

1132
    Parameters
1133
    ----------
1134
    clear_boundary_conditions: bool, default=False
1135
        If true, all current boundary conditions are deleted.
1136
    boundary_conditions: List[Dict]
1137
        A list of boundary condition dictionaries. Each dictionary supports:
1138
          - name: str (required)
1139
              Boundary name that identifies the specific boundary.
1140
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1141
              Boundary type specification.
1142
          - group_strength: List[float], optional
1143
              Required when ``type='isotropic'``. Isotropic strength per group.
1144
          - start_time: float, optional
1145
              Active start time for isotropic boundaries only. Defaults to -infinity.
1146
          - end_time: float, optional
1147
              Active end time for isotropic boundaries only. Defaults to infinity.
1148
          - function: AngularFluxFunction, optional
1149
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1150
              that returns incoming angular flux from group and direction.
1151
          - time_function: AngularFluxTimeFunction, optional
1152
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1153
              returns incoming angular flux from group, direction, and time.
1154

1155
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1156
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1157
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1158
        that callback.
1159

1160
    Notes
1161
    -----
1162
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
1163
    Reapply boundaries with this method before solving in the new mode.
1164
    )"
1165
  );
1166
  do_problem.def(
1,099 ✔
1167
    "ZeroPsi",
1168
    [](DiscreteOrdinatesProblem& self)
1,099 ✔
1169
    {
UNCOV
1170
      self.ZeroPsi();
×
1171
    },
1172
    R"(
1173
    Zero the angular-flux storage.
1174
    )"
1175
  );
1176
  do_problem.def(
1,099 ✔
1177
    "GetPsi",
1178
    [](DiscreteOrdinatesProblem& self)
1,187 ✔
1179
    {
1180
      const auto& psi = self.GetPsiNewLocal();
88 ✔
1181
      py::list psi_list;
88 ✔
1182
      for (const auto& vec : psi)
176 ✔
1183
      {
1184
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()));
88 ✔
1185
        std::copy(vec.begin(), vec.end(), static_cast<double*>(array.mutable_data()));
88 ✔
1186
        psi_list.append(array);
88 ✔
1187
      }
88 ✔
1188
      return psi_list;
88 ✔
UNCOV
1189
    },
×
1190
    R"(
1191
    Return psi as a list of NumPy arrays (float64).
1192

1193
    The arrays are copies of the current angular-flux state. Mutating them does not
1194
    mutate the problem.
1195
    )"
1196
  );
1197
  do_problem.def(
1,099 ✔
1198
    "GetAngularFieldFunctionList",
1199
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
1,099 ✔
1200
    {
UNCOV
1201
      std::vector<unsigned int> group_ids;
×
UNCOV
1202
      std::vector<size_t> angle_ids;
×
UNCOV
1203
      group_ids.reserve(groups.size());
×
UNCOV
1204
      angle_ids.reserve(angles.size());
×
1205

UNCOV
1206
      for (py::handle g : groups)
×
UNCOV
1207
        group_ids.push_back(g.cast<unsigned int>());
×
UNCOV
1208
      for (py::handle a : angles)
×
UNCOV
1209
        angle_ids.push_back(a.cast<size_t>());
×
1210

UNCOV
1211
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
UNCOV
1212
      py::list out;
×
1213
      for (const auto& ff : ff_list)
×
UNCOV
1214
        out.append(ff);
×
1215
      return out;
×
UNCOV
1216
    },
×
1217
    R"(
1218
    Create field functions for selected angular flux components.
1219

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

1224
    Example
1225
    -------
1226
    .. code-block:: python
1227

1228
       solver.Initialize()
1229
       solver.Execute()
1230
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1231

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

1237
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
1238

1239
    .. code-block:: python
1240

1241
       solver = TransientSolver(problem=phys)
1242
       solver.Initialize()
1243
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1244

1245
       def post_advance():
1246
           for ff in ang_ff:
1247
               ff.Update()
1248
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1249

1250
       solver.SetPostAdvanceCallback(post_advance)
1251
       solver.Execute()
1252

1253
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
1254

1255
    .. code-block:: python
1256

1257
       solver = TransientSolver(problem=phys)
1258
       solver.Initialize()
1259
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1260
       for _ in range(num_steps):
1261
           solver.Advance()
1262
           for ff in ang_ff:
1263
               ff.Update()
1264
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1265

1266
    Parameters
1267
    ----------
1268
    groups : List[int]
1269
        Global group indices to export.
1270
    angles : List[int]
1271
        Angle indices within the groupset quadrature for each group.
1272

1273
    Returns
1274
    -------
1275
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
1276
        Field functions for the requested ``(group, angle)`` pairs. Each returned field function
1277
        is a snapshot, but supports ``CanUpdate()`` and ``Update()`` while its owning problem is
1278
        alive.
1279
    )",
1280
    py::arg("groups"),
2,198 ✔
1281
    py::arg("angles")
1,099 ✔
1282
  );
1283
  do_problem.def(
1,099 ✔
1284
    "ComputeLeakage",
1285
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
1,532 ✔
1286
    {
1287
      auto grid = self.GetGrid();
433 ✔
1288
      // get the supported boundaries
1289
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
433 ✔
1290
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
433 ✔
1291
      const auto coord_sys = grid->GetCoordinateSystem();
433 ✔
1292
      const auto mesh_type = grid->GetType();
433 ✔
1293
      const auto dim = grid->GetDimension();
433 ✔
1294
      // get the boundaries to parse, preserving user order
1295
      std::vector<std::uint64_t> bndry_ids;
433 ✔
1296
      if (bnd_names.size() > 1)
433 ✔
1297
      {
1298
        for (py::handle name : bnd_names)
2,504 ✔
1299
        {
1300
          auto sname = name.cast<std::string>();
1,658 ✔
1301
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
1,658 ✔
1302
          {
1303
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24 ✔
UNCOV
1304
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
1305
                                       "' is invalid for cylindrical orthogonal meshes. "
UNCOV
1306
                                       "Use rmin, rmax, zmin, zmax.");
×
1307

1308
            if (mesh_type == MeshType::ORTHOGONAL)
24 ✔
1309
            {
1310
              if (sname == "rmin") sname = "xmin";
24 ✔
1311
              else if (sname == "rmax") sname = "xmax";
24 ✔
1312
              else if (sname == "zmin") sname = "ymin";
16 ✔
1313
              else if (sname == "zmax") sname = "ymax";
8 ✔
1314
            }
1315
          }
1316
          bndry_ids.push_back(allowed_bd_names.at(sname));
1,658 ✔
1317
        }
1,658 ✔
1318
      }
1319
      else
1320
      {
1321
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
30 ✔
1322
      }
1323
      // compute the leakage
1324
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
433 ✔
1325
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
866 ✔
1326
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
433 ✔
1327

1328
      auto ToRZName = [](const std::string& name)
457 ✔
1329
      {
1330
        if (name == "xmin") return std::string("rmin");
24 ✔
1331
        if (name == "xmax") return std::string("rmax");
24 ✔
1332
        if (name == "ymin") return std::string("zmin");
16 ✔
1333
        if (name == "ymax") return std::string("zmax");
8 ✔
UNCOV
1334
        return name;
×
1335
      };
1336

1337
      // convert result to native Python
1338
      py::dict result;
433 ✔
1339
      for (const auto& bndry_id : bndry_ids)
2,122 ✔
1340
      {
1341
        const auto it = leakage.find(bndry_id);
1,689 ✔
1342
        if (it == leakage.end())
1,689 ✔
UNCOV
1343
          continue;
×
1344
        // construct numpy array and copy contents
1345
        const auto& grp_wise_leakage = it->second;
1,689 ✔
1346
        py::array_t<double> np_vector(static_cast<py::ssize_t>(grp_wise_leakage.size()));
1,689 ✔
1347
        auto buffer = np_vector.request();
1,689 ✔
1348
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
1,689 ✔
1349
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
1,689 ✔
1350
        std::string name = allowed_bd_ids.at(bndry_id);
1,689 ✔
1351
        if (rz_ortho)
1,689 ✔
1352
          name = ToRZName(name);
24 ✔
1353
        result[py::str(name)] = std::move(np_vector);
3,378 ✔
1354
      }
1,689 ✔
1355

1356
      return result;
433 ✔
1357
    },
866 ✔
1358
    R"(
1359
    Compute leakage for the problem.
1360

1361
    Parameters
1362
    ----------
1363
    bnd_names : List[str]
1364
        A list of boundary names for which leakage should be computed.
1365

1366
    Returns
1367
    -------
1368
    Dict[str, numpy.ndarray]
1369
        A dictionary mapping boundary names to group-wise leakage vectors.
1370
        Each array contains the outgoing angular flux (per group) integrated over
1371
        the corresponding boundary surface.
1372

1373
    Raises
1374
    ------
1375
    RuntimeError
1376
        If `save_angular_flux` option was not enabled during problem setup.
1377

1378
    ValueError
1379
        If one or more boundary ids are not present on the current mesh.
1380
    )",
1381
    py::arg("bnd_names")
1,099 ✔
1382
  );
1383

1384
  // discrete ordinates curvilinear problem
1385
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
1,099 ✔
1386
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1387
                                           DiscreteOrdinatesProblem>(
1388
    slv,
1389
    "DiscreteOrdinatesCurvilinearProblem",
1390
    R"(
1391
    Base class for discrete ordinates problems in curvilinear geometry.
1392

1393
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1394
    )"
1395
  );
1,099 ✔
1396
  do_curvilinear_problem.def(
2,198 ✔
1397
    py::init(
1,099 ✔
1398
      [](py::kwargs& params)
80 ✔
1399
      {
1400
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
80 ✔
1401
      }
1402
    ),
1403
    R"(
1404
    Construct a discrete ordinates problem for curvilinear geometry.
1405

1406
    Warnings
1407
    --------
1408
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1409

1410
    Parameters
1411
    ----------
1412
    mesh : MeshContinuum
1413
        The spatial mesh. Its geometry type (cylindrical or spherical) is taken directly from
1414
        the mesh -- e.g. from the ``coord_sys`` argument used to build it with a mesh
1415
        generator. There is no separate ``coord_system`` parameter on this class; supplying
1416
        one is rejected as an unknown parameter.
1417
    num_groups : int
1418
        The total number of energy groups.
1419
    groupsets : list of dict
1420
        A list of input parameter blocks, each block provides the iterative properties for a
1421
        groupset. Each dictionary supports:
1422
          - groups_from_to: List[int] (required)
1423
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1424
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1425
              Handle to an angular quadrature.
1426
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
1427
              Angle aggregation method to use during sweeping.
1428
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1429
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
1430
              Iterative method used for inner linear solves.
1431
          - l_abs_tol: float, default=1.0e-6
1432
              Inner linear solver absolute residual tolerance.
1433
          - l_max_its: int, default=200
1434
              Inner linear solver maximum iterations.
1435
          - gmres_restart_interval: int, default=30
1436
              GMRES restart interval, if GMRES is used.
1437
          - allow_cycles: bool, default=True
1438
              Whether cyclic dependencies are allowed in sweeps.
1439
          - apply_wgdsa: bool, default=False
1440
              Enable within-group DSA for this groupset.
1441
          - wgdsa_l_abs_tol: float, default=1.0e-4
1442
              WGDSA linear absolute tolerance.
1443
          - wgdsa_l_max_its: int, default=30
1444
              WGDSA maximum iterations.
1445
          - wgdsa_verbose: bool, default=False
1446
              Verbose WGDSA output.
1447
          - wgdsa_petsc_options: str, default=''
1448
              PETSc options string for the WGDSA solver.
1449
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1450
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1451
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1452
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
1453
              PETSc KSP/PC setup.
1454
          - wgdsa_direct_solve_threshold: int, default=20000
1455
              Maximum global WGDSA diffusion unknown count for the automatic direct solve.
1456
          - apply_tgdsa: bool, default=False
1457
              Enable two-grid DSA for this groupset.
1458
          - tgdsa_l_abs_tol: float, default=1.0e-4
1459
              TGDSA linear absolute tolerance.
1460
          - tgdsa_l_max_its: int, default=30
1461
              TGDSA maximum iterations.
1462
          - tgdsa_verbose: bool, default=False
1463
              Verbose TGDSA output.
1464
          - tgdsa_petsc_options: str, default=''
1465
              PETSc options string for the TGDSA solver.
1466
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1467
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1468
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1469
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
1470
              PETSc KSP/PC setup.
1471
          - tgdsa_direct_solve_threshold: int, default=20000
1472
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
1473
    xs_map : list of dict
1474
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1475
          - block_ids: List[int] (required)
1476
              Mesh block IDs to associate with the cross section.
1477
          - xs: pyopensn.xs.MultiGroupXS (required)
1478
              Cross-section object to assign to the specified blocks.
1479
    boundary_conditions: List[Dict], default=[]
1480
        A list containing tables for each boundary specification. Each dictionary supports:
1481
          - name: str (required)
1482
              Boundary name that identifies the specific boundary.
1483
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1484
              Boundary type specification.
1485
          - group_strength: List[float], optional
1486
              Required when ``type='isotropic'``. Isotropic strength per group.
1487
          - start_time: float, optional
1488
              Active start time for isotropic boundaries only. Defaults to -infinity.
1489
          - end_time: float, optional
1490
              Active end time for isotropic boundaries only. Defaults to infinity.
1491
          - function: AngularFluxFunction, optional
1492
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1493
              that returns incoming angular flux from group and direction.
1494
          - time_function: AngularFluxTimeFunction, optional
1495
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1496
              returns incoming angular flux from group, direction, and time.
1497

1498
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1499
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1500
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1501
        that callback.
1502
    point_sources: List[pyopensn.source.PointSource], default=[]
1503
        A list of point sources.
1504
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1505
        A list of volumetric sources.
1506
    options : dict, optional
1507
        A block of optional configuration parameters applied at problem creation, including:
1508
          - max_mpi_message_size: int, default=32768
1509
          - restart_writes_enabled: bool, default=False
1510
              Enable restart dump writes for solvers that support restart output.
1511
          - write_delayed_psi_to_restart: bool, default=True
1512
              Include delayed sweep angular-flux buffers. Full continuation restarts require
1513
              these buffers whenever the problem has delayed sweep angular state, including
1514
              partitioned parallel, reflected-boundary, and cyclic-sweep cases. These buffers
1515
              are optional when a steady-state restart is used only as a transient initial
1516
              condition because the transient initialization can reconstruct angular state
1517
              from the flux moments.
1518
          - write_angular_flux_to_restart: bool, default=True
1519
              Include stored angular fluxes in restart dumps when ``save_angular_flux=True``.
1520
              This is required for continuing a time-dependent restart, but optional when a
1521
              steady-state restart is used only as a transient initial condition.
1522
          - read_restart_path: str, default=''
1523
              File stem for reading a full restart. The number of MPI ranks and partitioned
1524
              state layout must match the run that wrote the restart files.
1525
          - read_initial_condition_path: str, default=''
1526
              File stem for reading restart data as an initial condition. A steady-state
1527
              restart may be used by ``TransientSolver`` through this option.
1528
          - write_restart_path: str, default=''
1529
              File stem for restart dump writes. OpenSn appends the MPI rank and
1530
              ``.restart.h5`` to this stem.
1531
          - write_restart_time_interval: int, default=0
1532
          - use_precursors: bool, default=True
1533
            Enable delayed-neutron precursor treatment. This remains active across later
1534
            ``SetXSMap`` calls, even if the current map temporarily has no precursor-bearing
1535
            material. When XS maps change, precursor concentrations are remapped by cell and
1536
            precursor-family index; newly introduced families start at zero and removed families
1537
            are discarded.
1538
          - use_source_moments: bool, default=False
1539
          - save_angular_flux: bool, default=False
1540
            Store angular flux state (`psi`) for transient mode, angular-flux
1541
            field functions, and angular-flux I/O.
1542
          - verbose_inner_iterations: bool, default=True
1543
            Print inner iteration details, including WGS and AGS iterations.
1544
          - verbose_outer_iterations: bool, default=True
1545
            Print outer solver progress, including PI/NLKE iterations and transient steps.
1546
          - max_ags_iterations: int, default=100
1547
          - ags_tolerance: float, default=1.0e-6
1548
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1549
          - power_default_kappa: float, default=3.20435e-11
1550
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1551
          - field_function_prefix: str, default=''
1552
    sweep_type : str, optional
1553
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1554
        If ``time_dependent=True``, ``options.save_angular_flux=True`` is required.
1555
    )"
1556
  );
1557
}
1,099 ✔
1558

1559
// Wrap uncollided solver
1560
void
1561
WrapUncollidedSolver(py::module& slv)
1,099 ✔
1562
{
1563
  // clang-format off
1564
  auto uncollided_solver =
1,099 ✔
1565
    py::class_<UncollidedSolver, std::shared_ptr<UncollidedSolver>, Solver>(
1566
      slv,
1567
      "UncollidedSolver",
1568
      R"(
1569
    Generate and write uncollided flux moments for first-collision transport.
1570

1571
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1572
    )"
1573
  );
1,099 ✔
1574
  uncollided_solver.def(
2,198 ✔
1575
    py::init(
1,099 ✔
1576
      [](py::kwargs& params)
23 ✔
1577
      {
1578
        return UncollidedSolver::Create(kwargs_to_param_block(params));
23 ✔
1579
      }
1580
    ),
1581
    R"(
1582
    Construct an uncollided transport solver.
1583

1584
    Parameters
1585
    ----------
1586
    problem : pyopensn.solver.UncollidedProblem
1587
        Existing uncollided problem instance.
1588
    file_name : str
1589
        Output HDF5 file containing uncollided flux moments, mesh and total
1590
        cross-section metadata, and production/removal/outflow balance terms.
1591
    progress_interval : int, default=5
1592
        Percentage interval for source-point progress reports. Reports include
1593
        completed source points, elapsed time, and estimated remaining time.
1594
        Set to zero to disable progress reporting. Valid values are integers in
1595
        ``[0, 100]``.
1596

1597
    Notes
1598
    -----
1599
    :meth:`Initialize` requires exactly one MPI rank. The generated HDF5 file
1600
    may then be reused by a serial or parallel collided calculation on the
1601
    same mesh.
1602

1603
    Internal threading in the current uncollided implementation is capped by
1604
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1605
    the variable is unset or invalid.
1606
    )"
1607
  );
1608
  // clang-format on
1609
}
1,099 ✔
1610

1611
// Wrap steady-state solver
1612
void
1613
WrapSteadyState(py::module& slv)
1,099 ✔
1614
{
1615
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,148 ✔
1616
  {
1617
    py::dict values;
49 ✔
1618
    values["absorption_rate"] = table.absorption_rate;
49 ✔
1619
    values["production_rate"] = table.production_rate;
49 ✔
1620
    values["inflow_rate"] = table.inflow_rate;
49 ✔
1621
    values["outflow_rate"] = table.outflow_rate;
49 ✔
1622
    values["balance"] = table.balance;
49 ✔
1623
    if (table.initial_inventory.has_value())
49 ✔
UNCOV
1624
      values["initial_inventory"] = table.initial_inventory.value();
×
1625
    if (table.final_inventory.has_value())
49 ✔
UNCOV
1626
      values["final_inventory"] = table.final_inventory.value();
×
1627
    if (table.predicted_inventory_change.has_value())
49 ✔
UNCOV
1628
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1629
    if (table.actual_inventory_change.has_value())
49 ✔
1630
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1631
    if (table.inventory_residual.has_value())
49 ✔
1632
      values["inventory_residual"] = table.inventory_residual.value();
×
1633
    return values;
49 ✔
1634
  };
×
1635

1636
  // clang-format off
1637
  // steady state solver
1638
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,099 ✔
1639
                                        Solver>(
1640
    slv,
1641
    "SteadyStateSourceSolver",
1642
    R"(
1643
    Steady state solver.
1644

1645
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1646
    )"
1647
  );
1,099 ✔
1648
  steady_state_solver.def(
2,198 ✔
1649
    py::init(
1,099 ✔
1650
      [](py::kwargs& params)
792 ✔
1651
      {
1652
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
792 ✔
1653
      }
1654
    ),
1655
    R"(
1656
    Construct a steady state solver.
1657

1658
    Parameters
1659
    ----------
1660
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1661
        Existing discrete ordinates problem instance.
1662

1663
    Notes
1664
    -----
1665
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1666
    restart data is read during :meth:`Initialize`. If it was constructed with
1667
    ``options={'restart_writes_enabled': True, ...}``, a restart dump is written
1668
    after :meth:`Execute` completes.
1669

1670
    Restart files are rank-layout specific. A restart written with ``N`` MPI
1671
    ranks must be read with the same rank count and a compatible problem
1672
    definition.
1673
    )"
1674
  );
1675
  steady_state_solver.def(
1,099 ✔
1676
    "ComputeBalanceTable",
1677
    [BalanceTableToDict](const SteadyStateSourceSolver& self)
1,099 ✔
1678
    {
1679
      return BalanceTableToDict(self.ComputeBalanceTable());
49 ✔
1680
    },
1681
    R"(
1682
    Compute and return the global balance table using the solver's normalization.
1683
    This is a collective operation and must be called on all ranks.
1684

1685
    Returns
1686
    -------
1687
    dict
1688
        Dictionary with the following entries:
1689

1690
        - ``absorption_rate``:
1691
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1692
          groups and the full domain.
1693
        - ``production_rate``:
1694
          Global volumetric production/source rate used by the solver,
1695
          approximately ``integral Q dV`` summed over groups and the full domain.
1696
        - ``inflow_rate``:
1697
          Global incoming boundary contribution integrated over incoming
1698
          angular flux on boundaries.
1699
        - ``outflow_rate``:
1700
          Global outgoing boundary contribution accumulated from face outflow
1701
          tallies.
1702
        - ``balance``:
1703
          Rate balance,
1704
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1705

1706
    Notes
1707
    -----
1708
    This solver applies no extra normalization to the balance table.
1709
    )"
1710
  );
1711
  // clang-format on
1712
}
1,099 ✔
1713

1714
// Wrap transient solver
1715
void
1716
WrapTransient(py::module& slv)
1,099 ✔
1717
{
1718
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,099 ✔
1719
  {
UNCOV
1720
    py::dict values;
×
UNCOV
1721
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1722
    values["production_rate"] = table.production_rate;
×
UNCOV
1723
    values["inflow_rate"] = table.inflow_rate;
×
UNCOV
1724
    values["outflow_rate"] = table.outflow_rate;
×
UNCOV
1725
    values["balance"] = table.balance;
×
UNCOV
1726
    if (table.initial_inventory.has_value())
×
UNCOV
1727
      values["initial_inventory"] = table.initial_inventory.value();
×
UNCOV
1728
    if (table.final_inventory.has_value())
×
UNCOV
1729
      values["final_inventory"] = table.final_inventory.value();
×
UNCOV
1730
    if (table.predicted_inventory_change.has_value())
×
UNCOV
1731
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
UNCOV
1732
    if (table.actual_inventory_change.has_value())
×
UNCOV
1733
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
UNCOV
1734
    if (table.inventory_residual.has_value())
×
UNCOV
1735
      values["inventory_residual"] = table.inventory_residual.value();
×
UNCOV
1736
    return values;
×
UNCOV
1737
  };
×
1738
  // clang-format off
1739
  auto transient_solver =
1,099 ✔
1740
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1741
      slv,
1742
      "TransientSolver",
1743
      R"(
1744
      Transient solver.
1745

1746
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1747
      )"
1748
    );
1,099 ✔
1749
  transient_solver.def(
2,198 ✔
1750
    py::init(
1,099 ✔
1751
      [](py::kwargs& params)
614 ✔
1752
      {
1753
        return TransientSolver::Create(kwargs_to_param_block(params));
614 ✔
1754
      }
1755
    ),
1756
    R"(
1757
    Construct a transient solver.
1758

1759
    Parameters
1760
    ----------
1761
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1762
        Existing discrete ordinates problem instance.
1763
    dt : float, optional, default=2.0e-3
1764
        Time step size used during the simulation.
1765
    stop_time : float, optional, default=0.1
1766
        Simulation end time.
1767
    theta : float, optional, default=0.5
1768
        Time differencing scheme parameter.
1769
    initial_state : str, optional, default="existing"
1770
        Initial state for the transient solve. Allowed values: existing, zero.
1771
        In "zero" mode, scalar flux vectors are reset to zero.
1772
    verbose : bool, optional, default=True
1773
        Enable verbose logging.
1774

1775
    Notes
1776
    -----
1777
    The associated problem must have ``save_angular_flux=True`` enabled. This
1778
    is required for transient problems.
1779

1780
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1781
    restart data is read during :meth:`Initialize`. If it was constructed with
1782
    ``options={'read_initial_condition_path': ...}``, restart data is read as a
1783
    transient initial condition during :meth:`Initialize`, then the problem is
1784
    switched to time-dependent mode; a steady-state restart may be used in this
1785
    mode. In this initial-condition path, stored angular fluxes and delayed
1786
    sweep angular-flux buffers are optional in the steady-state restart; the
1787
    transient solver reconstructs angular state from the loaded flux moments.
1788
    If it was constructed with
1789
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
1790
    be written during :meth:`Execute` and a final restart dump is written when
1791
    execution completes.
1792

1793
    A full transient continuation restart read with ``read_restart_path`` is
1794
    different from a steady-state initial-condition restart read with
1795
    ``read_initial_condition_path``. Full transient continuation restarts require
1796
    angular flux state in the restart file; if the problem has delayed sweep
1797
    angular state, including partitioned parallel, reflected-boundary, or
1798
    cyclic-sweep cases, continuation also requires delayed sweep angular-flux
1799
    buffers in the restart. Restart files are rank-layout specific: use the same MPI rank
1800
    count and compatible problem definition when reading files written by a
1801
    previous run.
1802
    )"
1803
  );
1804
  transient_solver.def(
1,099 ✔
1805
    "SetTimeStep",
1806
    &TransientSolver::SetTimeStep,
1,099 ✔
1807
    R"(
1808
    Set the timestep size used by :meth:`Advance`.
1809

1810
    Parameters
1811
    ----------
1812
    dt : float
1813
        New timestep size.
1814
    )");
1815
  transient_solver.def(
1,099 ✔
1816
    "SetTheta",
1817
    &TransientSolver::SetTheta,
1,099 ✔
1818
    R"(
1819
    Set the theta parameter used by :meth:`Advance`.
1820

1821
    Parameters
1822
    ----------
1823
    theta : float
1824
        Theta value between 1.0e-16 and 1.
1825
    )");
1826
  transient_solver.def(
1,099 ✔
1827
    "Advance",
1828
    &TransientSolver::Advance,
1,099 ✔
1829
    R"(
1830
    Advance the solver by a single timestep.
1831

1832
    Notes
1833
    -----
1834
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1835
    :meth:`Execute`.
1836
    )");
1837
  transient_solver.def(
1,099 ✔
1838
    "SetPreAdvanceCallback",
1839
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,099 ✔
1840
      &TransientSolver::SetPreAdvanceCallback),
1841
    R"(
1842
    Register a callback that runs before each advance within :meth:`Execute`.
1843

1844
    :param callback: Function invoked before the solver advances a timestep. Pass None
1845
        to clear. If the callback modifies the timestep, the new value is used for the
1846
        upcoming step.
1847
    :type callback: Optional[Callable[[], None]]
1848
    )");
1849
  transient_solver.def(
1,099 ✔
1850
    "SetPreAdvanceCallback",
1851
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPreAdvanceCallback),
1,099 ✔
1852
    "Clear the PreAdvance callback by passing None.");
1853
  transient_solver.def(
1,099 ✔
1854
    "SetPostAdvanceCallback",
1855
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,099 ✔
1856
      &TransientSolver::SetPostAdvanceCallback),
1857
    R"(
1858
    Register a callback that runs after each advance within :meth:`Execute`.
1859

1860
    :param callback: Function invoked after the solver advances a timestep. Pass None
1861
        to clear.
1862
    :type callback: Optional[Callable[[], None]]
1863
    )");
1864
  transient_solver.def(
1,099 ✔
1865
    "SetPostAdvanceCallback",
1866
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPostAdvanceCallback),
1,099 ✔
1867
    "Clear the PostAdvance callback by passing None.");
1868
  transient_solver.def(
1,099 ✔
1869
    "ComputeBalanceTable",
1870
    [BalanceTableToDict](const TransientSolver& self)
1,099 ✔
1871
    {
UNCOV
1872
      return BalanceTableToDict(self.ComputeBalanceTable());
×
1873
    },
1874
    R"(
1875
    Compute and return the global balance table using the solver's normalization.
1876
    This is a collective operation and must be called on all ranks.
1877

1878
    Returns
1879
    -------
1880
    dict
1881
        Dictionary with the following entries:
1882

1883
        - ``absorption_rate``:
1884
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1885
          groups and the full domain.
1886
        - ``production_rate``:
1887
          Global volumetric production/source rate used by the solver,
1888
          approximately ``integral Q dV`` summed over groups and the full domain.
1889
        - ``inflow_rate``:
1890
          Global incoming boundary contribution integrated over incoming
1891
          angular flux on boundaries.
1892
        - ``outflow_rate``:
1893
          Global outgoing boundary contribution accumulated from face outflow
1894
          tallies.
1895
        - ``balance``:
1896
          Rate balance,
1897
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1898
        - ``initial_inventory``:
1899
          Total particle inventory at the start of the timestep, computed as
1900
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
1901
        - ``final_inventory``:
1902
          Total particle inventory at the end of the timestep, computed as
1903
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
1904
        - ``predicted_inventory_change``:
1905
          Inventory change predicted by the current timestep balance, computed as
1906
          ``dt * balance``.
1907
        - ``actual_inventory_change``:
1908
          Measured change in total particle inventory over the timestep, computed as
1909
          ``final_inventory - initial_inventory``.
1910
        - ``inventory_residual``:
1911
          Mismatch between the measured and predicted timestep inventory
1912
          changes, computed as
1913
          ``actual_inventory_change - predicted_inventory_change``.
1914

1915
    Notes
1916
    -----
1917
    This solver applies no extra normalization to the balance table.
1918

1919
    The transient inventory terms currently use the end-of-step rate balance to
1920
    estimate the timestep inventory change.
1921
    )"
1922
  );
1923
  slv.attr("BackwardEuler") = 1.0;
1,099 ✔
1924
  slv.attr("CrankNicolson") = 0.5;
2,198 ✔
1925
  // clang-format on
1926
}
1,099 ✔
1927

1928
// Wrap non-linear k-eigen solver
1929
void
1930
WrapNLKEigen(py::module& slv)
1,099 ✔
1931
{
1932
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,099 ✔
1933
  {
UNCOV
1934
    py::dict values;
×
UNCOV
1935
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1936
    values["production_rate"] = table.production_rate;
×
UNCOV
1937
    values["inflow_rate"] = table.inflow_rate;
×
1938
    values["outflow_rate"] = table.outflow_rate;
×
UNCOV
1939
    values["balance"] = table.balance;
×
UNCOV
1940
    if (table.initial_inventory.has_value())
×
UNCOV
1941
      values["initial_inventory"] = table.initial_inventory.value();
×
UNCOV
1942
    if (table.final_inventory.has_value())
×
UNCOV
1943
      values["final_inventory"] = table.final_inventory.value();
×
UNCOV
1944
    if (table.predicted_inventory_change.has_value())
×
UNCOV
1945
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
UNCOV
1946
    if (table.actual_inventory_change.has_value())
×
UNCOV
1947
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
UNCOV
1948
    if (table.inventory_residual.has_value())
×
UNCOV
1949
      values["inventory_residual"] = table.inventory_residual.value();
×
UNCOV
1950
    return values;
×
UNCOV
1951
  };
×
1952
  // clang-format off
1953
  // non-linear k-eigen solver
1954
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
1,099 ✔
1955
                                              Solver>(
1956
    slv,
1957
    "NonLinearKEigenSolver",
1958
    R"(
1959
    Non-linear k-eigenvalue solver.
1960

1961
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1962

1963
    Supports one or more groupsets when the groupsets run without WGDSA/TGDSA.
1964
    If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen solve
1965
    must use a single groupset.
1966
    )"
1967
  );
1,099 ✔
1968
  non_linear_k_eigen_solver.def(
2,198 ✔
1969
    py::init(
1,099 ✔
1970
      [](py::kwargs& params)
45 ✔
1971
      {
1972
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
45 ✔
1973
      }
1974
        ),
1975
    R"(
1976
    Construct a non-linear k-eigenvalue solver.
1977

1978
    Parameters
1979
    ----------
1980
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1981
        Existing discrete ordinates problem instance.
1982
        Multiple groupsets are supported when groupset WGDSA/TGDSA is disabled.
1983
        If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen
1984
        solve must use a single groupset.
1985
    nl_abs_tol: float, default=1.0e-8
1986
        Non-linear absolute tolerance.
1987
    nl_rel_tol: float, default=1.0e-8
1988
        Non-linear relative tolerance.
1989
    nl_sol_tol: float, default=1.0e-50
1990
        Non-linear solution tolerance.
1991
    nl_max_its: int, default=50
1992
        Non-linear algorithm maximum iterations.
1993
    l_abs_tol: float, default=1.0e-8
1994
        Linear absolute tolerance.
1995
    l_rel_tol: float, default=1.0e-8
1996
        Linear relative tolerance.
1997
    l_div_tol: float, default=1.0e6
1998
        Linear divergence tolerance.
1999
    l_max_its: int, default=50
2000
        Linear algorithm maximum iterations.
2001
    l_gmres_restart_intvl: int, default=30
2002
        GMRES restart interval.
2003
    l_gmres_breakdown_tol: float, default=0.1
2004
        GMRES breakdown tolerance.
2005
    reset_phi0: bool, default=True
2006
        If true, reinitializes scalar fluxes to 1.0.
2007
    num_initial_power_iterations: int, default=0
2008
        Number of initial power iterations before the non-linear solve.
2009

2010
    Notes
2011
    -----
2012
    PETSc convergence failures and iteration limits are reported through the
2013
    solver status and log output, consistent with the other transport solvers.
2014
    Invalid residual states are reported to PETSc as function-domain
2015
    errors so SNES can backtrack or terminate with the appropriate reason.
2016
    )"
2017
  );
2018
  non_linear_k_eigen_solver.def(
1,099 ✔
2019
    "GetEigenvalue",
2020
    &NonLinearKEigenSolver::GetEigenvalue,
1,099 ✔
2021
    R"(
2022
    Return the current k-eigenvalue.
2023
    )"
2024
  );
2025
  non_linear_k_eigen_solver.def(
1,099 ✔
2026
    "ComputeBalanceTable",
2027
    [BalanceTableToDict](const NonLinearKEigenSolver& self)
1,099 ✔
2028
    {
UNCOV
2029
      return BalanceTableToDict(self.ComputeBalanceTable());
×
2030
    },
2031
    R"(
2032
    Compute and return the global balance table using the solver's normalization.
2033
    This is a collective operation and must be called on all ranks.
2034

2035
    Returns
2036
    -------
2037
    dict
2038
        Dictionary with the following entries:
2039

2040
        - ``absorption_rate``:
2041
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2042
          groups and the full domain.
2043
        - ``production_rate``:
2044
          Global volumetric production/source rate used by the solver,
2045
          approximately ``integral Q dV`` summed over groups and the full domain.
2046
        - ``inflow_rate``:
2047
          Global incoming boundary contribution integrated over incoming
2048
          angular flux on boundaries.
2049
        - ``outflow_rate``:
2050
          Global outgoing boundary contribution accumulated from face outflow
2051
          tallies.
2052
        - ``balance``:
2053
          Rate balance,
2054
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2055

2056
    Notes
2057
    -----
2058
    For k-eigenvalue balance reporting, this solver scales the production term by
2059
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2060
    )"
2061
  );
2062
  // clang-format on
2063
}
1,099 ✔
2064

2065
// Wrap power iteration solvers
2066
void
2067
WrapPIteration(py::module& slv)
1,099 ✔
2068
{
2069
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,112 ✔
2070
  {
2071
    py::dict values;
13 ✔
2072
    values["absorption_rate"] = table.absorption_rate;
13 ✔
2073
    values["production_rate"] = table.production_rate;
13 ✔
2074
    values["inflow_rate"] = table.inflow_rate;
13 ✔
2075
    values["outflow_rate"] = table.outflow_rate;
13 ✔
2076
    values["balance"] = table.balance;
13 ✔
2077
    if (table.initial_inventory.has_value())
13 ✔
UNCOV
2078
      values["initial_inventory"] = table.initial_inventory.value();
×
2079
    if (table.final_inventory.has_value())
13 ✔
UNCOV
2080
      values["final_inventory"] = table.final_inventory.value();
×
2081
    if (table.predicted_inventory_change.has_value())
13 ✔
UNCOV
2082
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
2083
    if (table.actual_inventory_change.has_value())
13 ✔
UNCOV
2084
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
2085
    if (table.inventory_residual.has_value())
13 ✔
UNCOV
2086
      values["inventory_residual"] = table.inventory_residual.value();
×
2087
    return values;
13 ✔
UNCOV
2088
  };
×
2089
  // clang-format off
2090
  // power iteration k-eigen solver
2091
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
1,099 ✔
2092
                                      Solver>(
2093
    slv,
2094
    "PowerIterationKEigenSolver",
2095
    R"(
2096
    Power iteration k-eigenvalue solver.
2097

2098
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
2099
    )"
2100
  );
1,099 ✔
2101
  pi_k_eigen_solver.def(
2,198 ✔
2102
    py::init(
1,099 ✔
2103
      [](py::kwargs& params)
426 ✔
2104
      {
2105
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
426 ✔
2106
      }
2107
    ),
2108
    R"(
2109
    Construct a power iteration k-eigen solver.
2110

2111
    Parameters
2112
    ----------
2113
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2114
        Existing DiscreteOrdinatesProblem instance.
2115
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
2116
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
2117
    max_iters: int, default = 1000
2118
        Maximum power iterations allowed.
2119
    k_tol: float, default = 1.0e-10
2120
        Tolerance on the k-eigenvalue.
2121
    reset_solution: bool, default=True
2122
        If true, initialize flux moments to 1.0.
2123
    reset_phi0: bool, default=True
2124
        If true, reinitializes scalar fluxes to 1.0.
2125

2126
    Notes
2127
    -----
2128
    If the problem was constructed with ``options={'read_restart_path': ...}``,
2129
    restart data is read during :meth:`Initialize`. If it was constructed with
2130
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
2131
    be written during the outer iteration loop and a final restart dump is
2132
    written when execution completes.
2133
    )"
2134
  );
2135
  pi_k_eigen_solver.def(
1,099 ✔
2136
    "GetEigenvalue",
2137
    &PowerIterationKEigenSolver::GetEigenvalue,
1,099 ✔
2138
    R"(
2139
    Return the current k-eigenvalue.
2140
    )"
2141
  );
2142
  pi_k_eigen_solver.def(
1,099 ✔
2143
    "GetNumPowerIterations",
2144
    &PowerIterationKEigenSolver::GetNumPowerIterations,
1,099 ✔
2145
    R"(
2146
    Return the number of completed power iterations.
2147
    )"
2148
  );
2149
  pi_k_eigen_solver.def(
1,099 ✔
2150
    "GetNumSweeps",
2151
    &PowerIterationKEigenSolver::GetNumSweeps,
1,099 ✔
2152
    R"(
2153
    Return the total number of transport sweeps applied by all WGS solvers.
2154
    )"
2155
  );
2156
  pi_k_eigen_solver.def(
1,099 ✔
2157
    "ComputeBalanceTable",
2158
    [BalanceTableToDict](const PowerIterationKEigenSolver& self)
1,099 ✔
2159
    {
2160
      return BalanceTableToDict(self.ComputeBalanceTable());
13 ✔
2161
    },
2162
    R"(
2163
    Compute and return the global balance table using the solver's normalization.
2164
    This is a collective operation and must be called on all ranks.
2165

2166
    Returns
2167
    -------
2168
    dict
2169
        Dictionary with the following entries:
2170

2171
        - ``absorption_rate``:
2172
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2173
          groups and the full domain.
2174
        - ``production_rate``:
2175
          Global volumetric production/source rate used by the solver,
2176
          approximately ``integral Q dV`` summed over groups and the full domain.
2177
        - ``inflow_rate``:
2178
          Global incoming boundary contribution integrated over incoming
2179
          angular flux on boundaries.
2180
        - ``outflow_rate``:
2181
          Global outgoing boundary contribution accumulated from face outflow
2182
          tallies.
2183
        - ``balance``:
2184
          Rate balance,
2185
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2186

2187
    Notes
2188
    -----
2189
    For k-eigenvalue balance reporting, this solver scales the production term by
2190
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2191
    )"
2192
  );
2193
  // clang-format on
2194
}
1,099 ✔
2195

2196
// Wrap LBS solver
2197
void
2198
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
1,099 ✔
2199
{
2200
  // clang-format off
2201
  // discrete ordinates k-eigen acceleration base
2202
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
1,099 ✔
2203
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
2204
    slv,
2205
    "DiscreteOrdinatesKEigenAcceleration",
2206
    R"(
2207
    Base class for discrete ordinates k-eigenvalue acceleration methods.
2208

2209
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
2210
    )"
2211
  );
1,099 ✔
2212
  // SCDSA acceleration
2213
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
1,099 ✔
2214
                                       std::shared_ptr<SCDSAAcceleration>,
2215
                                       DiscreteOrdinatesKEigenAcceleration>(
2216
    slv,
2217
    "SCDSAAcceleration",
2218
    R"(
2219
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
2220

2221
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
2222
    )"
2223
  );
1,099 ✔
2224
  scdsa_acceleration.def(
2,198 ✔
2225
    py::init(
1,099 ✔
2226
      [](py::kwargs& params)
8 ✔
2227
      {
2228
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8 ✔
2229
      }
2230
    ),
2231
    R"(
2232
    SCDSA acceleration for the power iteration k-eigenvalue solver.
2233

2234
    Parameters
2235
    ----------
2236
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2237
        Existing DiscreteOrdinatesProblem instance.
2238
    l_abs_tol: float, default=1.0e-10
2239
        Absolute residual tolerance.
2240
    max_iters: int, default=100
2241
        Maximum allowable iterations.
2242
    verbose: bool, default=False
2243
        If true, enables verbose output.
2244
    petsc_options: str, default="ssss"
2245
        Additional PETSc options.
2246
    pi_max_its: int, default=50
2247
        Maximum allowable iterations for inner power iterations.
2248
    pi_k_tol: float, default=1.0e-10
2249
        k-eigenvalue tolerance for the inner power iterations.
2250
    sdm: str, default="pwld"
2251
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2252
            - 'pwld' : Piecewise Linear Discontinuous
2253
            - 'pwlc' : Piecewise Linear Continuous
2254
    )"
2255
  );
2256
  // SMM acceleration
2257
  auto smm_acceleration = py::class_<SMMAcceleration,
1,099 ✔
2258
                                     std::shared_ptr<SMMAcceleration>,
2259
                                     DiscreteOrdinatesKEigenAcceleration>(
2260
    slv,
2261
    "SMMAcceleration",
2262
    R"(
2263
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
2264

2265
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
2266
    )"
2267
  );
1,099 ✔
2268
  smm_acceleration.def(
2,198 ✔
2269
    py::init(
1,099 ✔
2270
      [](py::kwargs& params)
4 ✔
2271
      {
2272
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4 ✔
2273
      }
2274
    ),
2275
    R"(
2276
    SMM acceleration for the power iteration k-eigenvalue solver.
2277

2278
    Warnings
2279
    --------
2280
       SMM acceleration is **experimental** and should be used with caution!
2281
       SMM acceleration only supports problems with isotropic scattering.
2282

2283
    Parameters
2284
    ----------
2285
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2286
        Existing DiscreteOrdinatesProblem instance.
2287
    l_abs_tol: float, default=1.0e-10
2288
        Absolute residual tolerance.
2289
    max_iters: int, default=100
2290
        Maximum allowable iterations.
2291
    verbose: bool, default=False
2292
        If true, enables verbose output.
2293
    petsc_options: str, default="ssss"
2294
        Additional PETSc options.
2295
    pi_max_its: int, default=50
2296
        Maximum allowable iterations for inner power iterations.
2297
    pi_k_tol: float, default=1.0e-10
2298
        k-eigenvalue tolerance for the inner power iterations.
2299
    sdm: str, default="pwld"
2300
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2301
            - 'pwld' : Piecewise Linear Discontinuous
2302
            - 'pwlc' : Piecewise Linear Continuous
2303
    )"
2304
  );
2305
  // CMFD acceleration
2306
  auto cmfd_acceleration = py::class_<CMFDAcceleration,
1,099 ✔
2307
                                      std::shared_ptr<CMFDAcceleration>,
2308
                                      DiscreteOrdinatesKEigenAcceleration>(
2309
    slv,
2310
    "CMFDAcceleration",
2311
    R"(
2312
    Construct a CMFD accelerator for the power iteration k-eigenvalue solver.
2313

2314
    Wrapper of :cpp:class:`opensn::CMFDAcceleration`.
2315
    )"
2316
  );
1,099 ✔
2317
  cmfd_acceleration.def(
2,198 ✔
2318
    py::init(
1,099 ✔
2319
      [](py::kwargs& params)
92 ✔
2320
      {
2321
        return CMFDAcceleration::Create(kwargs_to_param_block(params));
92 ✔
2322
      }
2323
    ),
2324
    R"(
2325
    CMFD acceleration for the power iteration k-eigenvalue solver.
2326

2327
    With CMFD, each power iteration performs a configured number of high-order WGS
2328
    transport update iterations, then applies a bounded low-order scalar-flux correction.
2329
    The defaults use one WGS update iteration, automatic current closure, fixed
2330
    correction relaxation, and a transport-current balance gate before outer power
2331
    iteration is allowed to converge.
2332

2333
    Most users should tune only the common controls: ``coarse_mesh``, ``current_closure``,
2334
    ``aggregation_size``, ``group_aggregation_size``, ``relaxation``,
2335
    ``update_wgs_max_its``, ``update_wgs_abs_tol``, and
2336
    ``balance_residual_tolerance``. Parameters marked "developer/debug" are exposed for
2337
    investigations and regression testing, but normally should be left at their defaults.
2338

2339
    Parameters
2340
    ----------
2341
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2342
        Existing DiscreteOrdinatesProblem instance.
2343
    coarse_mesh: str, default="local_aggregation"
2344
        Common option. Coarse-mesh construction method. Valid choices are:
2345
            - 'identity' : one CMFD coarse cell per transport cell
2346
            - 'local_aggregation' : connected same-block fine cells aggregated locally
2347
            - 'global_aggregation' : connected same-block fine cells aggregated across MPI ranks
2348

2349
        ``"local_aggregation"`` is the conservative default. ``"global_aggregation"`` can
2350
        reduce the coarse problem size on larger distributed meshes. Global aggregation is a
2351
        logical CMFD coarse-space construction: it does not repartition the transport mesh or
2352
        change fine-cell ownership. Each global coarse cell has one owning rank, while ranks
2353
        owning member fine cells keep membership records for restriction and prolongation.
2354
        Aggregates are connected by face adjacency, remain within one mesh block, and may be
2355
        smaller than ``aggregation_size`` near boundaries or disconnected regions. ``"identity"``
2356
        is primarily for debugging and method comparisons.
2357
    current_closure: str, default="auto"
2358
        Common option. CMFD face-current closure. Valid choices are:
2359
            - 'auto' : choose net, partial, or a blend from early coarse-balance behavior
2360
            - 'net' : match the signed transport current across each coarse face
2361
            - 'partial' : build face coupling from outgoing partial currents on both sides
2362

2363
        ``"auto"`` is recommended for production use. A fixed closure is useful when
2364
        comparing methods, reproducing a benchmark setting, or diagnosing a case where
2365
        automatic selection is not robust.
2366
    aggregation_size: int, default=32
2367
        Common option. Target number of fine cells per aggregated coarse cell for
2368
        ``coarse_mesh="local_aggregation"`` or ``coarse_mesh="global_aggregation"``.
2369
        Larger values reduce CMFD matrix size and
2370
        setup/solve cost, but make the spatial correction less detailed. Smaller values
2371
        increase the coarse-system cost but may improve robustness.
2372
    group_aggregation_size: int, default=1
2373
        Common option. Number of transport energy groups per CMFD coarse group, not the
2374
        final number of coarse groups. A value of 1 preserves the full transport group
2375
        structure in the low-order system. To target ``N`` total coarse groups, use
2376
        ``(num_groups + N - 1) // N``.
2377
    relaxation: float, default=0.5
2378
        Common option. Strictly positive relaxation factor applied to the CMFD scalar-flux correction.
2379
        This is the requested correction strength. The correction limiter may damp or
2380
        skip an individual correction if the requested update would produce an invalid
2381
        k-eigenvalue, non-finite flux, or excessive negative scalar flux.
2382
    update_wgs_max_its: int, default=1
2383
        Common option. Maximum WGS iterations used before each CMFD correction. The
2384
        default performs one transport update iteration per power iteration; larger
2385
        values make each transport update more accurate but more expensive.
2386
    update_wgs_abs_tol: float, default=1.0e-12
2387
        Common option. WGS absolute tolerance used before each CMFD correction. When
2388
        ``update_wgs_max_its=1``, this tolerance is normally not the stopping criterion
2389
        because only one WGS iteration is allowed. It matters when more WGS iterations
2390
        are allowed.
2391
    balance_residual_tolerance: float, default=1.0e-6
2392
        Common option. Restricted transport-current balance residual tolerance required
2393
        before CMFD permits outer power-iteration convergence. This prevents false
2394
        convergence when ``k_eff_change`` is small but the CMFD-restricted transport
2395
        balance is still inconsistent. This is a consistency guard, not an estimate of
2396
        the k-eigenvalue error. For large 3D cases it may need to be looser than the
2397
        outer ``k_tol``; choose it tight enough to prevent false convergence but not so
2398
        tight that it forces unnecessary asymptotic balance iterations.
2399
    l_abs_tol: float, default=1.0e-7
2400
        Developer/debug. Absolute residual tolerance for each CMFD coarse linear solve.
2401
        This affects the low-order linear solve, not the outer transport convergence.
2402
    max_iters: int, default=100
2403
        Developer/debug. Maximum iterations for each CMFD coarse linear solve. Increase
2404
        only if the coarse KSP solve is reaching its iteration limit. CMFD skips a
2405
        correction when any coarse linear solve fails to converge within this limit. If
2406
        the log reports ``correction = skipped (coarse_linear_solve_not_converged)``,
2407
        use a direct coarse solve for modest coarse systems, increase this limit for
2408
        iterative solves, or provide stronger ``petsc_options``.
2409
    verbose: bool, default=False
2410
        Developer/debug. If true, prints CMFD diagnostic and timing metrics. These are
2411
        useful for regression tests and performance studies but can be noisy in production.
2412
    petsc_options: str, default=""
2413
        Developer/debug. Additional PETSc options for the CMFD coarse solver. Used only
2414
        for solver experiments, especially with ``coarse_solver_policy="petsc_options"``.
2415
    pi_max_its: int, default=50
2416
        Developer/debug. Maximum inner power iterations for the CMFD coarse k solve (at least 1).
2417
        Increase only if the coarse k solve is not converging enough to give useful
2418
        corrections.
2419
    pi_k_tol: float, default=1.0e-8
2420
        Developer/debug. k-eigenvalue tolerance for CMFD coarse power iterations. This
2421
        is separate from the outer ``PowerIterationKEigenSolver`` k tolerance.
2422
    correction_max_attempts: int, default=10
2423
        Developer/debug. Maximum CMFD correction damping attempts before skipping the
2424
        correction for the current transport update. Each failed attempt halves the
2425
        damping. If the log reports ``correction = skipped (negative_flux_guard)``,
2426
        first reduce ``relaxation`` or use finer spatial/energy aggregation; changing
2427
        this option is mainly for diagnostics.
2428
    correction_min_damping: float, default=1.0e-4
2429
        Developer/debug. Minimum CMFD correction damping factor considered during correction
2430
        limiting. If no admissible correction is found above this damping, CMFD skips
2431
        the correction for that transport update.
2432
    negative_flux_tolerance: float, default=1.0e-6
2433
        Developer/debug. Allowed scalar-flux undershoot for accepting a CMFD correction.
2434
        Corrections producing scalar flux below this guard are damped or skipped. Raising
2435
        this value can hide unstable corrections and should be accompanied by final-k and
2436
        scalar-flux checks.
2437
    inactive_iterations: int, default=0
2438
        Developer/debug. Number of initial power iterations before applying CMFD
2439
        corrections. These iterations retain the WGS settings configured on the problem.
2440
        CMFD applies its transport update controls starting with the first active iteration.
2441
    coarse_solver_policy: str, default="auto"
2442
        Developer/debug. Coarse solver policy. Valid choices are:
2443
            - 'auto' : PETSc preonly+LU below ``coarse_direct_solve_threshold``
2444
              global unknowns, GMRES+Jacobi otherwise
2445
            - 'direct' : PETSc preonly+LU
2446
            - 'iterative' : GMRES+Jacobi
2447
            - 'petsc_options' : allow ``petsc_options`` to override the PETSc KSP/PC setup
2448

2449
        CMFD corrections from unconverged coarse linear solves are always skipped.
2450
        The skipped correction uses the unaccelerated transport update for that power
2451
        iteration, including the raw transport k update so that power iteration can continue
2452
        to move.
2453
    coarse_direct_solve_threshold: int, default=20000
2454
        Developer/debug. Maximum global CMFD unknown count for automatic direct coarse
2455
        solves when ``coarse_solver_policy="auto"``. Larger values make ``"auto"`` use
2456
        direct solves for larger coarse systems; choose values based on available host
2457
        memory and acceptable factorization cost.
2458
    )"
2459
  );
2460
  // clang-format on
2461
}
1,099 ✔
2462

2463
// Wrap the solver components of OpenSn
2464
void
2465
py_solver(py::module& pyopensn)
105 ✔
2466
{
2467
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
105 ✔
2468
  WrapProblem(slv);
105 ✔
2469
  WrapSolver(slv);
105 ✔
2470
  WrapLBS(slv);
105 ✔
2471
  WrapUncollidedSolver(slv);
105 ✔
2472
  WrapSteadyState(slv);
105 ✔
2473
  WrapTransient(slv);
105 ✔
2474
  WrapNLKEigen(slv);
105 ✔
2475
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
105 ✔
2476
  WrapPIteration(slv);
105 ✔
2477
}
105 ✔
2478

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

© 2026 Coveralls, Inc