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

Open-Sn / opensn / 37110753927

29 Sep 2026 01:52AM UTC coverage: 78.506%. Remained the same
37110753927

push

github

web-flow
Merge pull request #1161 from wdhawkins/adjoint_sensitivity_fixes

Fix adjoint reorientation, sensitivities, and surface flux I/O

280 of 287 new or added lines in 12 files covered. (97.56%)

290 existing lines in 12 files now uncovered.

27825 of 35443 relevant lines covered (78.51%)

86424746.59 hits per line

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

81.53
/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,111 ✔
42
{
43
  // clang-format off
44
  // problem base
45
  auto problem = py::class_<Problem, std::shared_ptr<Problem>>(
1,111 ✔
46
    slv,
47
    "Problem",
48
    R"(
49
    Base class for all problems.
50

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

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

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

90
// Wrap LBS solver
91
void
92
WrapLBS(py::module& slv)
1,111 ✔
93
{
94
  // clang-format off
95
  // LBS problem
96
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
1,111 ✔
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,111 ✔
105
  lbs_problem.def(
1,111 ✔
106
    "GetScalarFluxFieldFunction",
107
    [](LBSProblem& self, bool only_scalar_flux)
1,111 ✔
108
    {
109
      py::list field_function_list_per_group;
15,289 ✔
110
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
54,550 ✔
111
      {
112
        if (only_scalar_flux)
39,261 ✔
113
        {
114
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
60,558 ✔
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,289 ✔
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,111 ✔
164
  );
165
  lbs_problem.def(
2,222 ✔
166
    "CreateFieldFunction",
167
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
2,222 ✔
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,222 ✔
203
    py::arg("xs_name"),
1,111 ✔
204
    py::arg("power_normalization_target") = -1.0
1,111 ✔
205
  );
206
  lbs_problem.def(
1,111 ✔
207
    "ComputeFieldFunctionPowerScaleFactor",
208
    &LBSProblem::ComputeFieldFunctionPowerScaleFactor,
2,222 ✔
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,111 ✔
231
  );
232
  lbs_problem.def(
1,111 ✔
233
    "GetTime",
234
    &LBSProblem::GetTime,
1,111 ✔
235
    R"(
236
    Get the current simulation time in seconds.
237
    )"
238
  );
239
  lbs_problem.def(
1,111 ✔
240
    "GetTimeStep",
241
    &LBSProblem::GetTimeStep,
1,111 ✔
242
    R"(
243
    Get the current timestep size.
244
    )"
245
  );
246
  lbs_problem.def(
1,111 ✔
247
    "ComputeFissionRate",
248
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,112 ✔
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,111 ✔
286
  );
287
  lbs_problem.def(
1,111 ✔
288
    "ComputeFissionProduction",
289
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
2,652 ✔
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,111 ✔
327
  );
328
  lbs_problem.def(
1,111 ✔
329
    "GetPhiOldLocal",
330
    [](LBSProblem& self)
1,207 ✔
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,111 ✔
344
    "GetPhiNewLocal",
345
    [](LBSProblem& self)
1,867 ✔
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,111 ✔
359
    "WriteFluxMoments",
360
    [](LBSProblem& self, const std::string& file_base)
1,158 ✔
361
    {
362
      LBSSolverIO::WriteFluxMoments(self, file_base);
47 ✔
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,111 ✔
373
  );
374
  lbs_problem.def(
1,111 ✔
375
    "CreateAndWriteSourceMoments",
376
    [](LBSProblem& self, const std::string& file_base)
1,115 ✔
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,111 ✔
390
  );
391
  lbs_problem.def(
1,111 ✔
392
    "ReadFluxMomentsAndMakeSourceMoments",
393
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,111 ✔
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,222 ✔
415
    py::arg("single_file_flag")
1,111 ✔
416
  );
417
  lbs_problem.def(
1,111 ✔
418
    "ReadSourceMoments",
419
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,115 ✔
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,222 ✔
436
    py::arg("single_file_flag")
1,111 ✔
437
  );
438
  lbs_problem.def(
1,111 ✔
439
    "ReadFluxMoments",
440
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,111 ✔
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,222 ✔
455
    py::arg("single_file_flag")
1,111 ✔
456
  );
457
  lbs_problem.def(
1,111 ✔
458
    "WriteAngularFluxes",
459
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,126 ✔
460
    {
461
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
15 ✔
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,111 ✔
472
  );
473
  lbs_problem.def(
1,111 ✔
474
    "ReadAngularFluxes",
475
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,115 ✔
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,111 ✔
488
  );
489
  lbs_problem.def(
2,222 ✔
490
    "WriteSurfaceAngularFluxes",
491
    [](DiscreteOrdinatesProblem& self,
1,135 ✔
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(
24 ✔
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 must match at least one cell face, must lie on cell faces inside the
513
        mesh, and may not share a plane with another interior surface. Its generated tags may not
514
        equal a requested boundary name. It may not coincide with an exterior boundary; export
515
        that boundary by name using boundary_surfaces instead.
516

517
    Notes
518
    -----
519
    Requires ``options.save_angular_flux=True``. Collective over all ranks; surface selections may
520
    differ by rank. Each rank writes ``<file_base><rank>.h5``. The file records whether the problem
521
    is in adjoint mode. After an adjoint steady-state solve, the stored angular flux is the adjoint
522
    flux for the listed direction, so forward and adjoint data with the same indices refer to the
523
    same direction.
524
    )",
525
    py::arg("file_base"),
1,111 ✔
526
    py::arg("boundary_surfaces") = std::vector<std::string>{},
2,222 ✔
527
    py::arg("interior_surfaces") = std::map<std::string, std::pair<std::string, double>>{}
2,222 ✔
528
  );
529
  lbs_problem.def(
1,111 ✔
530
    "ReadSurfaceAngularFluxes",
531
    [](DiscreteOrdinatesProblem& self, const std::string& file_base, py::list surfaces)
1,123 ✔
532
    {
533
      std::vector<std::string> surface_ids;
12 ✔
534
      for (py::handle surface : surfaces)
44 ✔
535
        surface_ids.push_back(surface.cast<std::string>());
40 ✔
536

537
      const auto surface_fluxes =
12 ✔
538
        DiscreteOrdinatesProblemIO::ReadSurfaceAngularFluxes(self, file_base, surface_ids);
12 ✔
539
      py::list result;
12 ✔
540
      for (const auto& surface_flux : surface_fluxes)
32 ✔
541
      {
542
        py::dict mapping;
20 ✔
543
        mapping["cell_ids"] = surface_flux.mapping.cell_ids;
20 ✔
544
        mapping["num_face_nodes"] = surface_flux.mapping.num_face_nodes;
20 ✔
545
        mapping["cell_map"] = surface_flux.mapping.cell_map;
20 ✔
546
        mapping["centroid_origin"] = surface_flux.mapping.centroid_origin;
20 ✔
547
        mapping["centroid_spacing"] = surface_flux.mapping.centroid_spacing;
20 ✔
548
        mapping["cell_stride"] = surface_flux.mapping.cell_stride;
20 ✔
549
        mapping["nodes_x"] = surface_flux.mapping.nodes_x;
20 ✔
550
        mapping["nodes_y"] = surface_flux.mapping.nodes_y;
20 ✔
551
        mapping["nodes_z"] = surface_flux.mapping.nodes_z;
20 ✔
552

553
        py::dict data;
20 ✔
554
        data["omega"] = surface_flux.data.omega;
20 ✔
555
        data["mu"] = surface_flux.data.mu;
20 ✔
556
        data["wt_d"] = surface_flux.data.wt_d;
20 ✔
557
        data["M_ij"] = surface_flux.data.mass_matrix;
20 ✔
558
        data["fe_shape"] = surface_flux.data.fe_shape;
20 ✔
559
        data["psi"] = surface_flux.data.psi;
20 ✔
560
        data["node_index"] = surface_flux.data.node_index;
20 ✔
561
        data["dir_index"] = surface_flux.data.dir_index;
20 ✔
562

563
        py::dict entry;
20 ✔
564
        entry["groupset_id"] = surface_flux.groupset_id;
20 ✔
565
        entry["adjoint"] = surface_flux.adjoint;
20 ✔
566
        entry["surface_name"] = surface_flux.surface_name;
20 ✔
567
        entry["mapping"] = std::move(mapping);
20 ✔
568
        entry["data"] = std::move(data);
20 ✔
569
        result.append(std::move(entry));
20 ✔
570
      }
20 ✔
571
      return result;
12 ✔
572
    },
12 ✔
573
    R"(
574
    Read surface angular fluxes from file.
575

576
    Parameters
577
    ----------
578
    file_base: str
579
        File basename.
580
    surfaces: list[str]
581
        Stored surface tags to read. Use the boundary name for an exterior boundary. For an
582
        interior surface named '<name>', use '<name>_u' for faces whose outward normal aligns with
583
        the positive axis and '<name>_d' for faces with the opposite orientation.
584

585
    Returns
586
    -------
587
    List[dict]
588
        Surface mapping and angular-flux data for each groupset and requested surface. Each entry
589
        has keys 'groupset_id', 'adjoint', 'surface_name', 'mapping', and 'data'. 'adjoint' is
590
        True when the file was written from an adjoint problem. The 'cell_map' in 'mapping' maps
591
        face centroids, with 'centroid_origin' subtracted before each coordinate is divided by
592
        'centroid_spacing' (1e-9 times the global mesh extent) and rounded to an integer, to the
593
        surface face index. The same face has the same key from either side of an interior surface
594
        and in every file for the same mesh.
595

596
    Notes
597
    -----
598
    Each rank reads ``<file_base><rank>.h5``; the files must have been written with the same mesh
599
    partitioning.
600
    )",
601
    py::arg("file_base"),
2,222 ✔
602
    py::arg("surfaces")
1,111 ✔
603
  );
604
  lbs_problem.def(
1,111 ✔
605
    "SetPointSources",
606
    [](LBSProblem& self, py::kwargs& params)
1,111 ✔
607
    {
UNCOV
608
      for (auto [key, value] : params)
×
609
      {
UNCOV
610
        auto c_key = key.cast<std::string>();
×
UNCOV
611
        if (c_key == "clear_point_sources")
×
UNCOV
612
          self.ClearPointSources();
×
UNCOV
613
        else if (c_key == "point_sources")
×
614
        {
UNCOV
615
          auto sources = value.cast<py::list>();
×
UNCOV
616
          for (auto source : sources)
×
617
          {
UNCOV
618
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
619
          }
UNCOV
620
        }
×
621
        else
UNCOV
622
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
UNCOV
623
      }
×
UNCOV
624
    },
×
625
    R"(
626
    Set or clear point sources.
627

628
    Parameters
629
    ----------
630
    clear_point_sources: bool, default=False
631
        If true, all current the point sources of the problem are deleted.
632
    point_sources: List[pyopensn.source.PointSource]
633
        List of new point sources to be added to the problem.
634
    )"
635
  );
636
  lbs_problem.def(
1,111 ✔
637
    "SetVolumetricSources",
638
    [](LBSProblem& self, py::kwargs& params)
1,181 ✔
639
    {
640
      for (auto [key, value] : params)
210 ✔
641
      {
642
        auto c_key = key.cast<std::string>();
70 ✔
643
        if (c_key == "clear_volumetric_sources")
70 ✔
644
          self.ClearVolumetricSources();
33 ✔
645
        else if (c_key == "volumetric_sources")
37 ✔
646
        {
647
          auto sources = value.cast<py::list>();
37 ✔
648
          for (auto source : sources)
111 ✔
649
          {
650
            self.AddVolumetricSource(source.cast<std::shared_ptr<VolumetricSource>>());
74 ✔
651
          }
652
        }
37 ✔
653
        else
UNCOV
654
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
655
      }
70 ✔
656
    },
70 ✔
657
    R"(
658
    Set or clear volumetric sources.
659

660
    Parameters
661
    ----------
662
    clear_volumetric_sources: bool, default=False
663
        If true, all current the volumetric sources of the problem are deleted.
664
    volumetric_sources: List[pyopensn.source.VolumetricSource]
665
        List of new volumetric sources to be added to the problem.
666
    )"
667
  );
668
  lbs_problem.def(
1,111 ✔
669
    "SetXSMap",
670
    [](LBSProblem& self, py::kwargs& params)
1,483 ✔
671
    {
672
      BlockID2XSMap xs_map;
372 ✔
673
      for (auto [key, value] : params)
1,116 ✔
674
      {
675
        auto c_key = key.cast<std::string>();
372 ✔
676
        if (c_key == "xs_map")
372 ✔
677
        {
678
          auto xs_entries = value.cast<py::list>();
372 ✔
679
          for (auto entry : xs_entries)
1,116 ✔
680
          {
681
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
372 ✔
682
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
372 ✔
683
            const auto& block_ids =
372 ✔
684
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
372 ✔
685
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
372 ✔
686
            for (const auto& block_id : block_ids)
744 ✔
687
              xs_map[block_id] = xs;
372 ✔
688
          }
372 ✔
689
        }
372 ✔
690
        else
UNCOV
691
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
692
      }
372 ✔
693
      self.SetBlockID2XSMap(xs_map);
372 ✔
694
    },
372 ✔
695
    R"(
696
    Replace the block-id to cross-section map.
697

698
    Parameters
699
    ----------
700
    xs_map: List[Dict]
701
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
702
          - block_ids: List[int] (required)
703
              Mesh block ids to associate with the cross section.
704
          - xs: pyopensn.xs.MultiGroupXS (required)
705
              Cross section object.
706

707
    Notes
708
    -----
709
    The problem is refreshed immediately after replacing the map. Material metadata,
710
    precursor storage, GPU carriers, and derived solver state owned by the concrete
711
    problem are rebuilt for the new cross sections.
712

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

718
    Existing precursor concentrations are remapped by local cell and precursor-family
719
    index. When the new material for a cell has fewer precursor families, extra old
720
    families are dropped. When it has more families, newly introduced families are
721
    initialized to zero. If a cell changes through a material with zero precursors,
722
    the precursor history for that cell is discarded and any later reintroduced
723
    precursor families start from zero.
724

725
    If any fissionable material in the new map contains delayed-neutron precursor
726
    data and ``options.use_precursors=True``, all fissionable materials in the map
727
    must contain precursor data. Non-fissionable materials may have zero precursors.
728

729
    Forward/adjoint mode toggles via :meth:`LBSProblem.SetAdjoint` do not change this map.
730
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer. If the same
731
    ``MultiGroupXS`` handle is shared across multiple problems, toggling adjoint mode in one
732
    problem also changes the transport mode seen by the others.
733
    )"
734
  );
735
  lbs_problem.def(
1,111 ✔
736
    "ZeroPhi",
737
    [](LBSProblem& self)
1,303 ✔
738
    {
739
      self.ZeroPhi();
192 ✔
740
    },
741
    R"(
742
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
743
    )"
744
  );
745
  lbs_problem.def(
1,111 ✔
746
    "SetAdjoint",
747
    [](LBSProblem& self, bool adjoint)
1,111 ✔
748
    {
749
      self.SetAdjoint(adjoint);
37 ✔
750
    },
751
    py::arg("adjoint") = true,
1,111 ✔
752
    R"(
753
    Set forward/adjoint transport mode.
754

755
    Parameters
756
    ----------
757
    adjoint: bool, default=True
758
        ``True`` enables adjoint mode and ``False`` enables forward mode. Adjoint mode is
759
        supported only for Cartesian geometries with standard quadrature operators
760
        (``operator_method='standard'``).
761

762
    Notes
763
    -----
764
    This is one of two supported mode-setting paths in Python:
765
      1. ``options={'adjoint': ...}`` in the problem constructor.
766
      2. ``SetAdjoint(...)`` (this method).
767

768
    If this changes mode, OpenSn performs a full mode-transition reset:
769
      - Materials are reinitialized in the selected mode.
770
      - Point and volumetric sources are cleared.
771
      - Boundary conditions are cleared.
772
      - Scalar and angular flux vectors are zeroed.
773

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

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

780
    After a mode change, reapply the desired driving terms before solving, typically:
781
      - :meth:`LBSProblem.SetPointSources`
782
      - :meth:`LBSProblem.SetVolumetricSources`
783
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
784

785
    This routine is intentionally destructive with respect to source/boundary/flux state
786
    to avoid hidden coupling between forward and adjoint setups.
787
    )"
788
  );
789
  lbs_problem.def(
1,111 ✔
790
    "SetForward",
791
    &LBSProblem::SetForward,
1,111 ✔
792
    R"(
793
    Set forward transport mode.
794

795
    Equivalent to ``SetAdjoint(False)``.
796
    )"
797
  );
798
  lbs_problem.def(
1,111 ✔
799
    "IsAdjoint",
800
    &LBSProblem::IsAdjoint,
1,111 ✔
801
    R"(
802
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
803
    )"
804
  );
805
  lbs_problem.def(
1,111 ✔
806
    "IsTimeDependent",
807
    &LBSProblem::IsTimeDependent,
1,111 ✔
808
    R"(
809
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
810
    )"
811
  );
812

813
  auto unc_problem =
1,111 ✔
814
    py::class_<UncollidedProblem, std::shared_ptr<UncollidedProblem>, LBSProblem>(
815
      slv,
816
      "UncollidedProblem",
817
      R"(
818
    Define an uncollided transport problem for a first-collision calculation.
819

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

827
    Wrapper of :cpp:class:`opensn::UncollidedProblem`.
828
    )");
1,111 ✔
829
  unc_problem.def(
2,222 ✔
830
    py::init(
1,111 ✔
831
      [](py::kwargs& params)
30 ✔
832
      {
833
        return UncollidedProblem::Create(kwargs_to_param_block(params));
30 ✔
834
      }),
835
    R"(
836
    Construct an uncollided transport problem.
837

838
    Parameters
839
    ----------
840
    mesh : MeshContinuum
841
        Cartesian two- or three-dimensional spatial mesh.
842
    num_groups : int
843
        Number of energy groups.
844
    groupsets : List[Dict], default=[]
845
        Groupset definitions required by the common LBS problem interface. The
846
        uncollided calculation itself does not use an angular quadrature.
847
    xs_map : List[Dict], default=[]
848
        Block-to-cross-section mappings. Total cross sections are used for
849
        attenuation and are recorded in the output file for compatibility
850
        checking by the collided problem.
851
    boundary_conditions : List[Dict], default=[]
852
        Vacuum or reflecting boundary conditions, using the same ``name`` and
853
        ``type`` entries as ``DiscreteOrdinatesProblem``. Reflecting boundaries
854
        are represented with directly projected image sources and must be
855
        planar, mutually orthogonal symmetry planes without an opposing
856
        reflecting plane.
857
    point_sources : List[pyopensn.source.PointSource], default=[]
858
        Explicit point sources. At least one point source is required. For now,
859
        each source must lie strictly inside a single cell; sources exactly on
860
        faces, edges, or vertices are rejected.
861
    near_source : List[pyopensn.logvol.LogicalVolume]
862
        Near-source ray-traced region for each point source, in the same order
863
        as ``point_sources``. This list is required whenever point sources are
864
        provided. Its length must equal the number of point sources, and every
865
        point source must lie in its corresponding region.
866
    scattering_order : int, default=0
867
        Maximum spherical-harmonic order written to the HDF5 file. It must be
868
        at least the scattering order of the collided problem that consumes
869
        the file.
870
    Notes
871
    -----
872
    Only explicit point sources are supported by the uncollided generator.
873
    Each point source must lie strictly inside a single cell. Uncollided
874
    generation must run on a Cartesian two- or three-dimensional mesh with
875
    exactly one MPI rank.
876

877
    Point-source placement strongly affects spatial accuracy. Construct or
878
    locally refine the mesh so that each source lies well inside its containing
879
    cell and near the cell centroid whenever the physical model permits. Exact
880
    balance after the near-source correction does not guarantee that the local
881
    flux shape is resolved; monitor the reported correction and verify mesh
882
    convergence.
883

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

889
    The ``options`` and ``use_gpus`` parameters are inherited from the base
890
    class interface but have no effect on uncollided generation and should be
891
    omitted.
892

893
    The uncollided file generated by :class:`UncollidedSolver` is
894
    partition-independent and may be consumed by a serial or parallel collided
895
    calculation on the same mesh. The collided problem must use the same
896
    reflecting boundaries recorded in the file.
897
    Each reflected image source is ray traced to every finite-element volume
898
    quadrature point and projected directly into the spatial discretization.
899
    )");
900

901
  // discrete ordinate solver
902
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
1,111 ✔
903
                               LBSProblem>(
904
    slv,
905
    "DiscreteOrdinatesProblem",
906
    R"(
907
    Base class for discrete ordinates problems in Cartesian geometry.
908

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

913
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
914
    )"
915
  );
1,111 ✔
916
  do_problem.def(
2,222 ✔
917
    py::init(
1,111 ✔
918
      [](py::kwargs& params)
1,584 ✔
919
      {
920
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
1,584 ✔
921
      }
922
    ),
923
    R"(
924
    Construct a discrete ordinates problem with Cartesian geometry.
925

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

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

1070
        These options are applied at problem creation.
1071
    sweep_type : str, default="AAH"
1072
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1073
        Both sweep types support time-dependent (transient) mode.
1074
    time_dependent : bool, default=False
1075
        If true, the problem starts in time-dependent mode. Otherwise it starts in
1076
        steady-state mode. Requires ``options.save_angular_flux=True``.
1077
        Both ``AAH`` and ``CBC`` sweep types support time-dependent mode.
1078
    uncollided_flux : str, default=""
1079
        HDF5 file generated by :class:`UncollidedSolver`. For steady-state
1080
        forward fixed-source calculations, OpenSn uses its moments to
1081
        construct the first-collision scattering and fission source, solves
1082
        for the collided component, and adds the uncollided moments back to
1083
        the converged state. The file must match the current group count, mesh
1084
        cell IDs, cell-node layout and coordinates, total cross sections, and
1085
        reflecting boundary set. Its maximum moment order must be at least
1086
        this problem's scattering order. The same serial file may be read by
1087
        any MPI partitioning of the matching mesh.
1088
    use_gpus : bool, default=False
1089
        A flag specifying whether GPU acceleration is used for the sweep. Both
1090
        ```AAH``` and ```CBC``` sweep types support GPU acceleration.
1091
    )"
1092
  );
1093
  do_problem.def(
1,111 ✔
1094
    "SetTimeDependentMode",
1095
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
1,111 ✔
1096
    R"(
1097
    Set the problem to time-dependent mode.
1098

1099
    Notes
1100
    -----
1101
    Switch problem from steady-state to time-dependent mode. This updates problem
1102
    internals (sweep chunk mode and source-function) while preserving user boundary
1103
    conditions and fixed sources.
1104

1105
    Requires ``options.save_angular_flux=True`` at problem creation.
1106
    )"
1107
  );
1108
  do_problem.def(
1,111 ✔
1109
    "SetSteadyStateMode",
1110
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
1,111 ✔
1111
    R"(
1112
    Set the problem to steady-state mode.
1113

1114
    Notes
1115
    -----
1116
    Switch problem from time-dependent to steady-state mode. This updates problem
1117
    internals (sweep chunk mode and source-function) while preserving user boundary
1118
    conditions and fixed sources.
1119
    )"
1120
  );
1121
  do_problem.def(
1,111 ✔
1122
    "IsTimeDependent",
1123
    &DiscreteOrdinatesProblem::IsTimeDependent,
1,111 ✔
1124
    R"(
1125
    Return ``True`` if the problem is currently in time-dependent mode.
1126
    )"
1127
  );
1128
  do_problem.def(
1,111 ✔
1129
    "SetBoundaryOptions",
1130
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
1,295 ✔
1131
    {
1132
      bool clear_boundary_conditions = false;
184 ✔
1133
      std::vector<InputParameters> boundary_params;
184 ✔
1134
      for (auto [key, value] : params)
728 ✔
1135
      {
1136
        auto c_key = key.cast<std::string>();
360 ✔
1137
        if (c_key == "clear_boundary_conditions")
360 ✔
1138
          clear_boundary_conditions = value.cast<bool>();
176 ✔
1139
        else if (c_key == "boundary_conditions")
184 ✔
1140
        {
1141
          auto boundaries = value.cast<py::list>();
184 ✔
1142
          for (auto boundary : boundaries)
988 ✔
1143
          {
1144
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
620 ✔
1145
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
620 ✔
1146
            boundary_params.push_back(std::move(input));
620 ✔
1147
          }
620 ✔
1148
        }
184 ✔
1149
        else
UNCOV
1150
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
1151
      }
360 ✔
1152
      if (clear_boundary_conditions or not boundary_params.empty())
184 ✔
1153
        self.SetBoundaryOptions(boundary_params, clear_boundary_conditions);
184 ✔
1154
    },
184 ✔
1155
    R"(
1156
    Set or clear boundary conditions.
1157

1158
    Parameters
1159
    ----------
1160
    clear_boundary_conditions: bool, default=False
1161
        If true, all current boundary conditions are deleted.
1162
    boundary_conditions: List[Dict]
1163
        A list of boundary condition dictionaries. Each dictionary supports:
1164
          - name: str (required)
1165
              Boundary name that identifies the specific boundary.
1166
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1167
              Boundary type specification.
1168
          - group_strength: List[float], optional
1169
              Required when ``type='isotropic'``. Isotropic strength per group.
1170
          - start_time: float, optional
1171
              Active start time for isotropic boundaries only. Defaults to -infinity.
1172
          - end_time: float, optional
1173
              Active end time for isotropic boundaries only. Defaults to infinity.
1174
          - function: AngularFluxFunction, optional
1175
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1176
              that returns incoming angular flux from group and direction.
1177
          - time_function: AngularFluxTimeFunction, optional
1178
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1179
              returns incoming angular flux from group, direction, and time.
1180

1181
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1182
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1183
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1184
        that callback.
1185

1186
    Notes
1187
    -----
1188
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
1189
    Reapply boundaries with this method before solving in the new mode.
1190
    )"
1191
  );
1192
  do_problem.def(
1,111 ✔
1193
    "ZeroPsi",
1194
    [](DiscreteOrdinatesProblem& self)
1,111 ✔
1195
    {
UNCOV
1196
      self.ZeroPsi();
×
1197
    },
1198
    R"(
1199
    Zero the angular-flux storage.
1200
    )"
1201
  );
1202
  do_problem.def(
1,111 ✔
1203
    "GetPsi",
1204
    [](DiscreteOrdinatesProblem& self)
1,199 ✔
1205
    {
1206
      const auto& psi = self.GetPsiNewLocal();
88 ✔
1207
      py::list psi_list;
88 ✔
1208
      for (const auto& vec : psi)
176 ✔
1209
      {
1210
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()));
88 ✔
1211
        std::copy(vec.begin(), vec.end(), static_cast<double*>(array.mutable_data()));
88 ✔
1212
        psi_list.append(array);
88 ✔
1213
      }
88 ✔
1214
      return psi_list;
88 ✔
UNCOV
1215
    },
×
1216
    R"(
1217
    Return psi as a list of NumPy arrays (float64).
1218

1219
    The arrays are copies of the current angular-flux state. Mutating them does not
1220
    mutate the problem.
1221
    )"
1222
  );
1223
  do_problem.def(
1,111 ✔
1224
    "GetAngularFieldFunctionList",
1225
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
1,111 ✔
1226
    {
UNCOV
1227
      std::vector<unsigned int> group_ids;
×
UNCOV
1228
      std::vector<size_t> angle_ids;
×
UNCOV
1229
      group_ids.reserve(groups.size());
×
UNCOV
1230
      angle_ids.reserve(angles.size());
×
1231

UNCOV
1232
      for (py::handle g : groups)
×
UNCOV
1233
        group_ids.push_back(g.cast<unsigned int>());
×
UNCOV
1234
      for (py::handle a : angles)
×
UNCOV
1235
        angle_ids.push_back(a.cast<size_t>());
×
1236

UNCOV
1237
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
UNCOV
1238
      py::list out;
×
UNCOV
1239
      for (const auto& ff : ff_list)
×
UNCOV
1240
        out.append(ff);
×
1241
      return out;
×
UNCOV
1242
    },
×
1243
    R"(
1244
    Create field functions for selected angular flux components.
1245

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

1250
    Example
1251
    -------
1252
    .. code-block:: python
1253

1254
       solver.Initialize()
1255
       solver.Execute()
1256
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1257

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

1263
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
1264

1265
    .. code-block:: python
1266

1267
       solver = TransientSolver(problem=phys)
1268
       solver.Initialize()
1269
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1270

1271
       def post_advance():
1272
           for ff in ang_ff:
1273
               ff.Update()
1274
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1275

1276
       solver.SetPostAdvanceCallback(post_advance)
1277
       solver.Execute()
1278

1279
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
1280

1281
    .. code-block:: python
1282

1283
       solver = TransientSolver(problem=phys)
1284
       solver.Initialize()
1285
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1286
       for _ in range(num_steps):
1287
           solver.Advance()
1288
           for ff in ang_ff:
1289
               ff.Update()
1290
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1291

1292
    Parameters
1293
    ----------
1294
    groups : List[int]
1295
        Global group indices to export.
1296
    angles : List[int]
1297
        Angle indices within the groupset quadrature for each group.
1298

1299
    Returns
1300
    -------
1301
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
1302
        Field functions for the requested ``(group, angle)`` pairs. Each returned field function
1303
        is a snapshot, but supports ``CanUpdate()`` and ``Update()`` while its owning problem is
1304
        alive.
1305
    )",
1306
    py::arg("groups"),
2,222 ✔
1307
    py::arg("angles")
1,111 ✔
1308
  );
1309
  do_problem.def(
1,111 ✔
1310
    "ComputeLeakage",
1311
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
1,544 ✔
1312
    {
1313
      auto grid = self.GetGrid();
433 ✔
1314
      // get the supported boundaries
1315
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
433 ✔
1316
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
433 ✔
1317
      const auto coord_sys = grid->GetCoordinateSystem();
433 ✔
1318
      const auto mesh_type = grid->GetType();
433 ✔
1319
      const auto dim = grid->GetDimension();
433 ✔
1320
      // get the boundaries to parse, preserving user order
1321
      std::vector<std::uint64_t> bndry_ids;
433 ✔
1322
      if (bnd_names.size() > 1)
433 ✔
1323
      {
1324
        for (py::handle name : bnd_names)
2,504 ✔
1325
        {
1326
          auto sname = name.cast<std::string>();
1,658 ✔
1327
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
1,658 ✔
1328
          {
1329
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24 ✔
UNCOV
1330
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
1331
                                       "' is invalid for cylindrical orthogonal meshes. "
UNCOV
1332
                                       "Use rmin, rmax, zmin, zmax.");
×
1333

1334
            if (mesh_type == MeshType::ORTHOGONAL)
24 ✔
1335
            {
1336
              if (sname == "rmin") sname = "xmin";
24 ✔
1337
              else if (sname == "rmax") sname = "xmax";
24 ✔
1338
              else if (sname == "zmin") sname = "ymin";
16 ✔
1339
              else if (sname == "zmax") sname = "ymax";
8 ✔
1340
            }
1341
          }
1342
          bndry_ids.push_back(allowed_bd_names.at(sname));
1,658 ✔
1343
        }
1,658 ✔
1344
      }
1345
      else
1346
      {
1347
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
30 ✔
1348
      }
1349
      // compute the leakage
1350
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
433 ✔
1351
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
866 ✔
1352
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
433 ✔
1353

1354
      auto ToRZName = [](const std::string& name)
457 ✔
1355
      {
1356
        if (name == "xmin") return std::string("rmin");
24 ✔
1357
        if (name == "xmax") return std::string("rmax");
24 ✔
1358
        if (name == "ymin") return std::string("zmin");
16 ✔
1359
        if (name == "ymax") return std::string("zmax");
8 ✔
UNCOV
1360
        return name;
×
1361
      };
1362

1363
      // convert result to native Python
1364
      py::dict result;
433 ✔
1365
      for (const auto& bndry_id : bndry_ids)
2,122 ✔
1366
      {
1367
        const auto it = leakage.find(bndry_id);
1,689 ✔
1368
        if (it == leakage.end())
1,689 ✔
UNCOV
1369
          continue;
×
1370
        // construct numpy array and copy contents
1371
        const auto& grp_wise_leakage = it->second;
1,689 ✔
1372
        py::array_t<double> np_vector(static_cast<py::ssize_t>(grp_wise_leakage.size()));
1,689 ✔
1373
        auto buffer = np_vector.request();
1,689 ✔
1374
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
1,689 ✔
1375
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
1,689 ✔
1376
        std::string name = allowed_bd_ids.at(bndry_id);
1,689 ✔
1377
        if (rz_ortho)
1,689 ✔
1378
          name = ToRZName(name);
24 ✔
1379
        result[py::str(name)] = std::move(np_vector);
3,378 ✔
1380
      }
1,689 ✔
1381

1382
      return result;
433 ✔
1383
    },
866 ✔
1384
    R"(
1385
    Compute leakage for the problem.
1386

1387
    Parameters
1388
    ----------
1389
    bnd_names : List[str]
1390
        A list of boundary names for which leakage should be computed.
1391

1392
    Returns
1393
    -------
1394
    Dict[str, numpy.ndarray]
1395
        A dictionary mapping boundary names to group-wise leakage vectors.
1396
        Each array contains the outgoing angular flux (per group) integrated over
1397
        the corresponding boundary surface.
1398

1399
    Raises
1400
    ------
1401
    RuntimeError
1402
        If `save_angular_flux` option was not enabled during problem setup.
1403

1404
    ValueError
1405
        If one or more boundary ids are not present on the current mesh.
1406
    )",
1407
    py::arg("bnd_names")
1,111 ✔
1408
  );
1409

1410
  // discrete ordinates curvilinear problem
1411
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
1,111 ✔
1412
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1413
                                           DiscreteOrdinatesProblem>(
1414
    slv,
1415
    "DiscreteOrdinatesCurvilinearProblem",
1416
    R"(
1417
    Base class for discrete ordinates problems in curvilinear geometry.
1418

1419
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1420
    )"
1421
  );
1,111 ✔
1422
  do_curvilinear_problem.def(
2,222 ✔
1423
    py::init(
1,111 ✔
1424
      [](py::kwargs& params)
86 ✔
1425
      {
1426
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
86 ✔
1427
      }
1428
    ),
1429
    R"(
1430
    Construct a discrete ordinates problem for curvilinear geometry.
1431

1432
    Warnings
1433
    --------
1434
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1435

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

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

1585
// Wrap uncollided solver
1586
void
1587
WrapUncollidedSolver(py::module& slv)
1,111 ✔
1588
{
1589
  // clang-format off
1590
  auto uncollided_solver =
1,111 ✔
1591
    py::class_<UncollidedSolver, std::shared_ptr<UncollidedSolver>, Solver>(
1592
      slv,
1593
      "UncollidedSolver",
1594
      R"(
1595
    Generate and write uncollided flux moments for first-collision transport.
1596

1597
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1598
    )"
1599
  );
1,111 ✔
1600
  uncollided_solver.def(
2,222 ✔
1601
    py::init(
1,111 ✔
1602
      [](py::kwargs& params)
23 ✔
1603
      {
1604
        return UncollidedSolver::Create(kwargs_to_param_block(params));
23 ✔
1605
      }
1606
    ),
1607
    R"(
1608
    Construct an uncollided transport solver.
1609

1610
    Parameters
1611
    ----------
1612
    problem : pyopensn.solver.UncollidedProblem
1613
        Existing uncollided problem instance.
1614
    file_name : str
1615
        Output HDF5 file containing uncollided flux moments, mesh and total
1616
        cross-section metadata, and production/removal/outflow balance terms.
1617
    progress_interval : int, default=5
1618
        Percentage interval for source-point progress reports. Reports include
1619
        completed source points, elapsed time, and estimated remaining time.
1620
        Set to zero to disable progress reporting. Valid values are integers in
1621
        ``[0, 100]``.
1622

1623
    Notes
1624
    -----
1625
    :meth:`Initialize` requires exactly one MPI rank. The generated HDF5 file
1626
    may then be reused by a serial or parallel collided calculation on the
1627
    same mesh.
1628

1629
    Internal threading in the current uncollided implementation is capped by
1630
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1631
    the variable is unset or invalid.
1632
    )"
1633
  );
1634
  // clang-format on
1635
}
1,111 ✔
1636

1637
// Wrap steady-state solver
1638
void
1639
WrapSteadyState(py::module& slv)
1,111 ✔
1640
{
1641
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,160 ✔
1642
  {
1643
    py::dict values;
49 ✔
1644
    values["absorption_rate"] = table.absorption_rate;
49 ✔
1645
    values["production_rate"] = table.production_rate;
49 ✔
1646
    values["inflow_rate"] = table.inflow_rate;
49 ✔
1647
    values["outflow_rate"] = table.outflow_rate;
49 ✔
1648
    values["balance"] = table.balance;
49 ✔
1649
    if (table.initial_inventory.has_value())
49 ✔
UNCOV
1650
      values["initial_inventory"] = table.initial_inventory.value();
×
1651
    if (table.final_inventory.has_value())
49 ✔
UNCOV
1652
      values["final_inventory"] = table.final_inventory.value();
×
1653
    if (table.predicted_inventory_change.has_value())
49 ✔
UNCOV
1654
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1655
    if (table.actual_inventory_change.has_value())
49 ✔
UNCOV
1656
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1657
    if (table.inventory_residual.has_value())
49 ✔
UNCOV
1658
      values["inventory_residual"] = table.inventory_residual.value();
×
1659
    return values;
49 ✔
UNCOV
1660
  };
×
1661

1662
  // clang-format off
1663
  // steady state solver
1664
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,111 ✔
1665
                                        Solver>(
1666
    slv,
1667
    "SteadyStateSourceSolver",
1668
    R"(
1669
    Steady state solver.
1670

1671
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1672
    )"
1673
  );
1,111 ✔
1674
  steady_state_solver.def(
2,222 ✔
1675
    py::init(
1,111 ✔
1676
      [](py::kwargs& params)
804 ✔
1677
      {
1678
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
804 ✔
1679
      }
1680
    ),
1681
    R"(
1682
    Construct a steady state solver.
1683

1684
    Parameters
1685
    ----------
1686
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1687
        Existing discrete ordinates problem instance.
1688

1689
    Notes
1690
    -----
1691
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1692
    restart data is read during :meth:`Initialize`. If it was constructed with
1693
    ``options={'restart_writes_enabled': True, ...}``, a restart dump is written
1694
    after :meth:`Execute` completes.
1695

1696
    Restart files are rank-layout specific. A restart written with ``N`` MPI
1697
    ranks must be read with the same rank count and a compatible problem
1698
    definition.
1699
    )"
1700
  );
1701
  steady_state_solver.def(
1,111 ✔
1702
    "ComputeBalanceTable",
1703
    [BalanceTableToDict](const SteadyStateSourceSolver& self)
1,111 ✔
1704
    {
1705
      return BalanceTableToDict(self.ComputeBalanceTable());
49 ✔
1706
    },
1707
    R"(
1708
    Compute and return the global balance table using the solver's normalization.
1709
    This is a collective operation and must be called on all ranks.
1710

1711
    Returns
1712
    -------
1713
    dict
1714
        Dictionary with the following entries:
1715

1716
        - ``absorption_rate``:
1717
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1718
          groups and the full domain.
1719
        - ``production_rate``:
1720
          Global volumetric production/source rate used by the solver,
1721
          approximately ``integral Q dV`` summed over groups and the full domain.
1722
        - ``inflow_rate``:
1723
          Global incoming boundary contribution integrated over incoming
1724
          angular flux on boundaries.
1725
        - ``outflow_rate``:
1726
          Global outgoing boundary contribution accumulated from face outflow
1727
          tallies.
1728
        - ``balance``:
1729
          Rate balance,
1730
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1731

1732
    Notes
1733
    -----
1734
    This solver applies no extra normalization to the balance table.
1735
    )"
1736
  );
1737
  // clang-format on
1738
}
1,111 ✔
1739

1740
// Wrap transient solver
1741
void
1742
WrapTransient(py::module& slv)
1,111 ✔
1743
{
1744
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,111 ✔
1745
  {
UNCOV
1746
    py::dict values;
×
UNCOV
1747
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1748
    values["production_rate"] = table.production_rate;
×
UNCOV
1749
    values["inflow_rate"] = table.inflow_rate;
×
UNCOV
1750
    values["outflow_rate"] = table.outflow_rate;
×
UNCOV
1751
    values["balance"] = table.balance;
×
UNCOV
1752
    if (table.initial_inventory.has_value())
×
UNCOV
1753
      values["initial_inventory"] = table.initial_inventory.value();
×
UNCOV
1754
    if (table.final_inventory.has_value())
×
UNCOV
1755
      values["final_inventory"] = table.final_inventory.value();
×
UNCOV
1756
    if (table.predicted_inventory_change.has_value())
×
UNCOV
1757
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
UNCOV
1758
    if (table.actual_inventory_change.has_value())
×
UNCOV
1759
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
UNCOV
1760
    if (table.inventory_residual.has_value())
×
UNCOV
1761
      values["inventory_residual"] = table.inventory_residual.value();
×
UNCOV
1762
    return values;
×
UNCOV
1763
  };
×
1764
  // clang-format off
1765
  auto transient_solver =
1,111 ✔
1766
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1767
      slv,
1768
      "TransientSolver",
1769
      R"(
1770
      Transient solver.
1771

1772
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1773
      )"
1774
    );
1,111 ✔
1775
  transient_solver.def(
2,222 ✔
1776
    py::init(
1,111 ✔
1777
      [](py::kwargs& params)
614 ✔
1778
      {
1779
        return TransientSolver::Create(kwargs_to_param_block(params));
614 ✔
1780
      }
1781
    ),
1782
    R"(
1783
    Construct a transient solver.
1784

1785
    Parameters
1786
    ----------
1787
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1788
        Existing discrete ordinates problem instance.
1789
    dt : float, optional, default=2.0e-3
1790
        Time step size used during the simulation.
1791
    stop_time : float, optional, default=0.1
1792
        Simulation end time.
1793
    theta : float, optional, default=0.5
1794
        Time differencing scheme parameter.
1795
    initial_state : str, optional, default="existing"
1796
        Initial state for the transient solve. Allowed values: existing, zero.
1797
        In "zero" mode, scalar flux vectors are reset to zero.
1798
    verbose : bool, optional, default=True
1799
        Enable verbose logging.
1800

1801
    Notes
1802
    -----
1803
    The associated problem must have ``save_angular_flux=True`` enabled. This
1804
    is required for transient problems.
1805

1806
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1807
    restart data is read during :meth:`Initialize`. If it was constructed with
1808
    ``options={'read_initial_condition_path': ...}``, restart data is read as a
1809
    transient initial condition during :meth:`Initialize`, then the problem is
1810
    switched to time-dependent mode; a steady-state restart may be used in this
1811
    mode. In this initial-condition path, stored angular fluxes and delayed
1812
    sweep angular-flux buffers are optional in the steady-state restart; the
1813
    transient solver reconstructs angular state from the loaded flux moments.
1814
    If it was constructed with
1815
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
1816
    be written during :meth:`Execute` and a final restart dump is written when
1817
    execution completes.
1818

1819
    A full transient continuation restart read with ``read_restart_path`` is
1820
    different from a steady-state initial-condition restart read with
1821
    ``read_initial_condition_path``. Full transient continuation restarts require
1822
    angular flux state in the restart file; if the problem has delayed sweep
1823
    angular state, including partitioned parallel, reflected-boundary, or
1824
    cyclic-sweep cases, continuation also requires delayed sweep angular-flux
1825
    buffers in the restart. Restart files are rank-layout specific: use the same MPI rank
1826
    count and compatible problem definition when reading files written by a
1827
    previous run.
1828
    )"
1829
  );
1830
  transient_solver.def(
1,111 ✔
1831
    "SetTimeStep",
1832
    &TransientSolver::SetTimeStep,
1,111 ✔
1833
    R"(
1834
    Set the timestep size used by :meth:`Advance`.
1835

1836
    Parameters
1837
    ----------
1838
    dt : float
1839
        New timestep size.
1840
    )");
1841
  transient_solver.def(
1,111 ✔
1842
    "SetTheta",
1843
    &TransientSolver::SetTheta,
1,111 ✔
1844
    R"(
1845
    Set the theta parameter used by :meth:`Advance`.
1846

1847
    Parameters
1848
    ----------
1849
    theta : float
1850
        Theta value between 1.0e-16 and 1.
1851
    )");
1852
  transient_solver.def(
1,111 ✔
1853
    "Advance",
1854
    &TransientSolver::Advance,
1,111 ✔
1855
    R"(
1856
    Advance the solver by a single timestep.
1857

1858
    Notes
1859
    -----
1860
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1861
    :meth:`Execute`.
1862
    )");
1863
  transient_solver.def(
1,111 ✔
1864
    "SetPreAdvanceCallback",
1865
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,111 ✔
1866
      &TransientSolver::SetPreAdvanceCallback),
1867
    R"(
1868
    Register a callback that runs before each advance within :meth:`Execute`.
1869

1870
    :param callback: Function invoked before the solver advances a timestep. Pass None
1871
        to clear. If the callback modifies the timestep, the new value is used for the
1872
        upcoming step.
1873
    :type callback: Optional[Callable[[], None]]
1874
    )");
1875
  transient_solver.def(
1,111 ✔
1876
    "SetPreAdvanceCallback",
1877
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPreAdvanceCallback),
1,111 ✔
1878
    "Clear the PreAdvance callback by passing None.");
1879
  transient_solver.def(
1,111 ✔
1880
    "SetPostAdvanceCallback",
1881
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,111 ✔
1882
      &TransientSolver::SetPostAdvanceCallback),
1883
    R"(
1884
    Register a callback that runs after each advance within :meth:`Execute`.
1885

1886
    :param callback: Function invoked after the solver advances a timestep. Pass None
1887
        to clear.
1888
    :type callback: Optional[Callable[[], None]]
1889
    )");
1890
  transient_solver.def(
1,111 ✔
1891
    "SetPostAdvanceCallback",
1892
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPostAdvanceCallback),
1,111 ✔
1893
    "Clear the PostAdvance callback by passing None.");
1894
  transient_solver.def(
1,111 ✔
1895
    "ComputeBalanceTable",
1896
    [BalanceTableToDict](const TransientSolver& self)
1,111 ✔
1897
    {
UNCOV
1898
      return BalanceTableToDict(self.ComputeBalanceTable());
×
1899
    },
1900
    R"(
1901
    Compute and return the global balance table using the solver's normalization.
1902
    This is a collective operation and must be called on all ranks.
1903

1904
    Returns
1905
    -------
1906
    dict
1907
        Dictionary with the following entries:
1908

1909
        - ``absorption_rate``:
1910
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1911
          groups and the full domain.
1912
        - ``production_rate``:
1913
          Global volumetric production/source rate used by the solver,
1914
          approximately ``integral Q dV`` summed over groups and the full domain.
1915
        - ``inflow_rate``:
1916
          Global incoming boundary contribution integrated over incoming
1917
          angular flux on boundaries.
1918
        - ``outflow_rate``:
1919
          Global outgoing boundary contribution accumulated from face outflow
1920
          tallies.
1921
        - ``balance``:
1922
          Rate balance,
1923
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1924
        - ``initial_inventory``:
1925
          Total particle inventory at the start of the timestep, computed as
1926
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
1927
        - ``final_inventory``:
1928
          Total particle inventory at the end of the timestep, computed as
1929
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
1930
        - ``predicted_inventory_change``:
1931
          Inventory change predicted by the current timestep balance, computed as
1932
          ``dt * balance``.
1933
        - ``actual_inventory_change``:
1934
          Measured change in total particle inventory over the timestep, computed as
1935
          ``final_inventory - initial_inventory``.
1936
        - ``inventory_residual``:
1937
          Mismatch between the measured and predicted timestep inventory
1938
          changes, computed as
1939
          ``actual_inventory_change - predicted_inventory_change``.
1940

1941
    Notes
1942
    -----
1943
    This solver applies no extra normalization to the balance table.
1944

1945
    The transient inventory terms currently use the end-of-step rate balance to
1946
    estimate the timestep inventory change.
1947
    )"
1948
  );
1949
  slv.attr("BackwardEuler") = 1.0;
1,111 ✔
1950
  slv.attr("CrankNicolson") = 0.5;
2,222 ✔
1951
  // clang-format on
1952
}
1,111 ✔
1953

1954
// Wrap non-linear k-eigen solver
1955
void
1956
WrapNLKEigen(py::module& slv)
1,111 ✔
1957
{
1958
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,111 ✔
1959
  {
UNCOV
1960
    py::dict values;
×
UNCOV
1961
    values["absorption_rate"] = table.absorption_rate;
×
UNCOV
1962
    values["production_rate"] = table.production_rate;
×
UNCOV
1963
    values["inflow_rate"] = table.inflow_rate;
×
UNCOV
1964
    values["outflow_rate"] = table.outflow_rate;
×
UNCOV
1965
    values["balance"] = table.balance;
×
UNCOV
1966
    if (table.initial_inventory.has_value())
×
UNCOV
1967
      values["initial_inventory"] = table.initial_inventory.value();
×
UNCOV
1968
    if (table.final_inventory.has_value())
×
UNCOV
1969
      values["final_inventory"] = table.final_inventory.value();
×
UNCOV
1970
    if (table.predicted_inventory_change.has_value())
×
UNCOV
1971
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
UNCOV
1972
    if (table.actual_inventory_change.has_value())
×
UNCOV
1973
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
UNCOV
1974
    if (table.inventory_residual.has_value())
×
UNCOV
1975
      values["inventory_residual"] = table.inventory_residual.value();
×
UNCOV
1976
    return values;
×
UNCOV
1977
  };
×
1978
  // clang-format off
1979
  // non-linear k-eigen solver
1980
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
1,111 ✔
1981
                                              Solver>(
1982
    slv,
1983
    "NonLinearKEigenSolver",
1984
    R"(
1985
    Non-linear k-eigenvalue solver.
1986

1987
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1988

1989
    Supports one or more groupsets when the groupsets run without WGDSA/TGDSA.
1990
    If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen solve
1991
    must use a single groupset.
1992
    )"
1993
  );
1,111 ✔
1994
  non_linear_k_eigen_solver.def(
2,222 ✔
1995
    py::init(
1,111 ✔
1996
      [](py::kwargs& params)
47 ✔
1997
      {
1998
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
47 ✔
1999
      }
2000
        ),
2001
    R"(
2002
    Construct a non-linear k-eigenvalue solver.
2003

2004
    Parameters
2005
    ----------
2006
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2007
        Existing discrete ordinates problem instance.
2008
        Multiple groupsets are supported when groupset WGDSA/TGDSA is disabled.
2009
        If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen
2010
        solve must use a single groupset.
2011
    nl_abs_tol: float, default=1.0e-8
2012
        Non-linear absolute tolerance.
2013
    nl_rel_tol: float, default=1.0e-8
2014
        Non-linear relative tolerance.
2015
    nl_sol_tol: float, default=1.0e-50
2016
        Non-linear solution tolerance.
2017
    nl_max_its: int, default=50
2018
        Non-linear algorithm maximum iterations.
2019
    l_abs_tol: float, default=1.0e-8
2020
        Linear absolute tolerance.
2021
    l_rel_tol: float, default=1.0e-8
2022
        Linear relative tolerance.
2023
    l_div_tol: float, default=1.0e6
2024
        Linear divergence tolerance.
2025
    l_max_its: int, default=50
2026
        Linear algorithm maximum iterations.
2027
    l_gmres_restart_intvl: int, default=30
2028
        GMRES restart interval.
2029
    l_gmres_breakdown_tol: float, default=0.1
2030
        GMRES breakdown tolerance.
2031
    reset_phi0: bool, default=True
2032
        If true, reinitializes scalar fluxes to 1.0.
2033
    num_initial_power_iterations: int, default=0
2034
        Number of initial power iterations before the non-linear solve.
2035

2036
    Notes
2037
    -----
2038
    PETSc convergence failures and iteration limits are reported through the
2039
    solver status and log output, consistent with the other transport solvers.
2040
    Invalid residual states are reported to PETSc as function-domain
2041
    errors so SNES can backtrack or terminate with the appropriate reason.
2042
    )"
2043
  );
2044
  non_linear_k_eigen_solver.def(
1,111 ✔
2045
    "GetEigenvalue",
2046
    &NonLinearKEigenSolver::GetEigenvalue,
1,111 ✔
2047
    R"(
2048
    Return the current k-eigenvalue.
2049
    )"
2050
  );
2051
  non_linear_k_eigen_solver.def(
1,111 ✔
2052
    "ComputeBalanceTable",
2053
    [BalanceTableToDict](const NonLinearKEigenSolver& self)
1,111 ✔
2054
    {
UNCOV
2055
      return BalanceTableToDict(self.ComputeBalanceTable());
×
2056
    },
2057
    R"(
2058
    Compute and return the global balance table using the solver's normalization.
2059
    This is a collective operation and must be called on all ranks.
2060

2061
    Returns
2062
    -------
2063
    dict
2064
        Dictionary with the following entries:
2065

2066
        - ``absorption_rate``:
2067
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2068
          groups and the full domain.
2069
        - ``production_rate``:
2070
          Global volumetric production/source rate used by the solver,
2071
          approximately ``integral Q dV`` summed over groups and the full domain.
2072
        - ``inflow_rate``:
2073
          Global incoming boundary contribution integrated over incoming
2074
          angular flux on boundaries.
2075
        - ``outflow_rate``:
2076
          Global outgoing boundary contribution accumulated from face outflow
2077
          tallies.
2078
        - ``balance``:
2079
          Rate balance,
2080
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2081

2082
    Notes
2083
    -----
2084
    For k-eigenvalue balance reporting, this solver scales the production term by
2085
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2086
    )"
2087
  );
2088
  // clang-format on
2089
}
1,111 ✔
2090

2091
// Wrap power iteration solvers
2092
void
2093
WrapPIteration(py::module& slv)
1,111 ✔
2094
{
2095
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,124 ✔
2096
  {
2097
    py::dict values;
13 ✔
2098
    values["absorption_rate"] = table.absorption_rate;
13 ✔
2099
    values["production_rate"] = table.production_rate;
13 ✔
2100
    values["inflow_rate"] = table.inflow_rate;
13 ✔
2101
    values["outflow_rate"] = table.outflow_rate;
13 ✔
2102
    values["balance"] = table.balance;
13 ✔
2103
    if (table.initial_inventory.has_value())
13 ✔
UNCOV
2104
      values["initial_inventory"] = table.initial_inventory.value();
×
2105
    if (table.final_inventory.has_value())
13 ✔
UNCOV
2106
      values["final_inventory"] = table.final_inventory.value();
×
2107
    if (table.predicted_inventory_change.has_value())
13 ✔
UNCOV
2108
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
2109
    if (table.actual_inventory_change.has_value())
13 ✔
UNCOV
2110
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
2111
    if (table.inventory_residual.has_value())
13 ✔
UNCOV
2112
      values["inventory_residual"] = table.inventory_residual.value();
×
2113
    return values;
13 ✔
UNCOV
2114
  };
×
2115
  // clang-format off
2116
  // power iteration k-eigen solver
2117
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
1,111 ✔
2118
                                      Solver>(
2119
    slv,
2120
    "PowerIterationKEigenSolver",
2121
    R"(
2122
    Power iteration k-eigenvalue solver.
2123

2124
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
2125
    )"
2126
  );
1,111 ✔
2127
  pi_k_eigen_solver.def(
2,222 ✔
2128
    py::init(
1,111 ✔
2129
      [](py::kwargs& params)
442 ✔
2130
      {
2131
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
442 ✔
2132
      }
2133
    ),
2134
    R"(
2135
    Construct a power iteration k-eigen solver.
2136

2137
    Parameters
2138
    ----------
2139
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2140
        Existing DiscreteOrdinatesProblem instance.
2141
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
2142
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
2143
    max_iters: int, default = 1000
2144
        Maximum power iterations allowed.
2145
    k_tol: float, default = 1.0e-10
2146
        Tolerance on the k-eigenvalue.
2147
    reset_solution: bool, default=True
2148
        If true, initialize flux moments to 1.0.
2149
    reset_phi0: bool, default=True
2150
        If true, reinitializes scalar fluxes to 1.0.
2151

2152
    Notes
2153
    -----
2154
    If the problem was constructed with ``options={'read_restart_path': ...}``,
2155
    restart data is read during :meth:`Initialize`. If it was constructed with
2156
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
2157
    be written during the outer iteration loop and a final restart dump is
2158
    written when execution completes.
2159
    )"
2160
  );
2161
  pi_k_eigen_solver.def(
1,111 ✔
2162
    "GetEigenvalue",
2163
    &PowerIterationKEigenSolver::GetEigenvalue,
1,111 ✔
2164
    R"(
2165
    Return the current k-eigenvalue.
2166
    )"
2167
  );
2168
  pi_k_eigen_solver.def(
1,111 ✔
2169
    "GetNumPowerIterations",
2170
    &PowerIterationKEigenSolver::GetNumPowerIterations,
1,111 ✔
2171
    R"(
2172
    Return the number of completed power iterations.
2173
    )"
2174
  );
2175
  pi_k_eigen_solver.def(
1,111 ✔
2176
    "GetNumSweeps",
2177
    &PowerIterationKEigenSolver::GetNumSweeps,
1,111 ✔
2178
    R"(
2179
    Return the total number of transport sweeps applied by all WGS solvers.
2180
    )"
2181
  );
2182
  pi_k_eigen_solver.def(
1,111 ✔
2183
    "ComputeBalanceTable",
2184
    [BalanceTableToDict](const PowerIterationKEigenSolver& self)
1,111 ✔
2185
    {
2186
      return BalanceTableToDict(self.ComputeBalanceTable());
13 ✔
2187
    },
2188
    R"(
2189
    Compute and return the global balance table using the solver's normalization.
2190
    This is a collective operation and must be called on all ranks.
2191

2192
    Returns
2193
    -------
2194
    dict
2195
        Dictionary with the following entries:
2196

2197
        - ``absorption_rate``:
2198
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2199
          groups and the full domain.
2200
        - ``production_rate``:
2201
          Global volumetric production/source rate used by the solver,
2202
          approximately ``integral Q dV`` summed over groups and the full domain.
2203
        - ``inflow_rate``:
2204
          Global incoming boundary contribution integrated over incoming
2205
          angular flux on boundaries.
2206
        - ``outflow_rate``:
2207
          Global outgoing boundary contribution accumulated from face outflow
2208
          tallies.
2209
        - ``balance``:
2210
          Rate balance,
2211
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2212

2213
    Notes
2214
    -----
2215
    For k-eigenvalue balance reporting, this solver scales the production term by
2216
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2217
    )"
2218
  );
2219
  // clang-format on
2220
}
1,111 ✔
2221

2222
// Wrap LBS solver
2223
void
2224
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
1,111 ✔
2225
{
2226
  // clang-format off
2227
  // discrete ordinates k-eigen acceleration base
2228
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
1,111 ✔
2229
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
2230
    slv,
2231
    "DiscreteOrdinatesKEigenAcceleration",
2232
    R"(
2233
    Base class for discrete ordinates k-eigenvalue acceleration methods.
2234

2235
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
2236
    )"
2237
  );
1,111 ✔
2238
  // SCDSA acceleration
2239
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
1,111 ✔
2240
                                       std::shared_ptr<SCDSAAcceleration>,
2241
                                       DiscreteOrdinatesKEigenAcceleration>(
2242
    slv,
2243
    "SCDSAAcceleration",
2244
    R"(
2245
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
2246

2247
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
2248
    )"
2249
  );
1,111 ✔
2250
  scdsa_acceleration.def(
2,222 ✔
2251
    py::init(
1,111 ✔
2252
      [](py::kwargs& params)
8 ✔
2253
      {
2254
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8 ✔
2255
      }
2256
    ),
2257
    R"(
2258
    SCDSA acceleration for the power iteration k-eigenvalue solver.
2259

2260
    Parameters
2261
    ----------
2262
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2263
        Existing DiscreteOrdinatesProblem instance.
2264
    l_abs_tol: float, default=1.0e-10
2265
        Absolute residual tolerance.
2266
    max_iters: int, default=100
2267
        Maximum allowable iterations.
2268
    verbose: bool, default=False
2269
        If true, enables verbose output.
2270
    petsc_options: str, default="ssss"
2271
        Additional PETSc options.
2272
    pi_max_its: int, default=50
2273
        Maximum allowable iterations for inner power iterations.
2274
    pi_k_tol: float, default=1.0e-10
2275
        k-eigenvalue tolerance for the inner power iterations.
2276
    sdm: str, default="pwld"
2277
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2278
            - 'pwld' : Piecewise Linear Discontinuous
2279
            - 'pwlc' : Piecewise Linear Continuous
2280
    )"
2281
  );
2282
  // SMM acceleration
2283
  auto smm_acceleration = py::class_<SMMAcceleration,
1,111 ✔
2284
                                     std::shared_ptr<SMMAcceleration>,
2285
                                     DiscreteOrdinatesKEigenAcceleration>(
2286
    slv,
2287
    "SMMAcceleration",
2288
    R"(
2289
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
2290

2291
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
2292
    )"
2293
  );
1,111 ✔
2294
  smm_acceleration.def(
2,222 ✔
2295
    py::init(
1,111 ✔
2296
      [](py::kwargs& params)
4 ✔
2297
      {
2298
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4 ✔
2299
      }
2300
    ),
2301
    R"(
2302
    SMM acceleration for the power iteration k-eigenvalue solver.
2303

2304
    Warnings
2305
    --------
2306
       SMM acceleration is **experimental** and should be used with caution!
2307
       SMM acceleration only supports problems with isotropic scattering.
2308

2309
    Parameters
2310
    ----------
2311
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2312
        Existing DiscreteOrdinatesProblem instance.
2313
    l_abs_tol: float, default=1.0e-10
2314
        Absolute residual tolerance.
2315
    max_iters: int, default=100
2316
        Maximum allowable iterations.
2317
    verbose: bool, default=False
2318
        If true, enables verbose output.
2319
    petsc_options: str, default="ssss"
2320
        Additional PETSc options.
2321
    pi_max_its: int, default=50
2322
        Maximum allowable iterations for inner power iterations.
2323
    pi_k_tol: float, default=1.0e-10
2324
        k-eigenvalue tolerance for the inner power iterations.
2325
    sdm: str, default="pwld"
2326
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2327
            - 'pwld' : Piecewise Linear Discontinuous
2328
            - 'pwlc' : Piecewise Linear Continuous
2329
    )"
2330
  );
2331
  // CMFD acceleration
2332
  auto cmfd_acceleration = py::class_<CMFDAcceleration,
1,111 ✔
2333
                                      std::shared_ptr<CMFDAcceleration>,
2334
                                      DiscreteOrdinatesKEigenAcceleration>(
2335
    slv,
2336
    "CMFDAcceleration",
2337
    R"(
2338
    Construct a CMFD accelerator for the power iteration k-eigenvalue solver.
2339

2340
    Wrapper of :cpp:class:`opensn::CMFDAcceleration`.
2341
    )"
2342
  );
1,111 ✔
2343
  cmfd_acceleration.def(
2,222 ✔
2344
    py::init(
1,111 ✔
2345
      [](py::kwargs& params)
92 ✔
2346
      {
2347
        return CMFDAcceleration::Create(kwargs_to_param_block(params));
92 ✔
2348
      }
2349
    ),
2350
    R"(
2351
    CMFD acceleration for the power iteration k-eigenvalue solver.
2352

2353
    With CMFD, each power iteration performs a configured number of high-order WGS
2354
    transport update iterations, then applies a bounded low-order scalar-flux correction.
2355
    The defaults use one WGS update iteration, automatic current closure, fixed
2356
    correction relaxation, and a transport-current balance gate before outer power
2357
    iteration is allowed to converge.
2358

2359
    Most users should tune only the common controls: ``coarse_mesh``, ``current_closure``,
2360
    ``aggregation_size``, ``group_aggregation_size``, ``relaxation``,
2361
    ``update_wgs_max_its``, ``update_wgs_abs_tol``, and
2362
    ``balance_residual_tolerance``. Parameters marked "developer/debug" are exposed for
2363
    investigations and regression testing, but normally should be left at their defaults.
2364

2365
    Parameters
2366
    ----------
2367
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2368
        Existing DiscreteOrdinatesProblem instance.
2369
    coarse_mesh: str, default="local_aggregation"
2370
        Common option. Coarse-mesh construction method. Valid choices are:
2371
            - 'identity' : one CMFD coarse cell per transport cell
2372
            - 'local_aggregation' : connected same-block fine cells aggregated locally
2373
            - 'global_aggregation' : connected same-block fine cells aggregated across MPI ranks
2374

2375
        ``"local_aggregation"`` is the conservative default. ``"global_aggregation"`` can
2376
        reduce the coarse problem size on larger distributed meshes. Global aggregation is a
2377
        logical CMFD coarse-space construction: it does not repartition the transport mesh or
2378
        change fine-cell ownership. Each global coarse cell has one owning rank, while ranks
2379
        owning member fine cells keep membership records for restriction and prolongation.
2380
        Aggregates are connected by face adjacency, remain within one mesh block, and may be
2381
        smaller than ``aggregation_size`` near boundaries or disconnected regions. ``"identity"``
2382
        is primarily for debugging and method comparisons.
2383
    current_closure: str, default="auto"
2384
        Common option. CMFD face-current closure. Valid choices are:
2385
            - 'auto' : choose net, partial, or a blend from early coarse-balance behavior
2386
            - 'net' : match the signed transport current across each coarse face
2387
            - 'partial' : build face coupling from outgoing partial currents on both sides
2388

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

2475
        CMFD corrections from unconverged coarse linear solves are always skipped.
2476
        The skipped correction uses the unaccelerated transport update for that power
2477
        iteration, including the raw transport k update so that power iteration can continue
2478
        to move.
2479
    coarse_direct_solve_threshold: int, default=20000
2480
        Developer/debug. Maximum global CMFD unknown count for automatic direct coarse
2481
        solves when ``coarse_solver_policy="auto"``. Larger values make ``"auto"`` use
2482
        direct solves for larger coarse systems; choose values based on available host
2483
        memory and acceptable factorization cost.
2484
    )"
2485
  );
2486
  // clang-format on
2487
}
1,111 ✔
2488

2489
// Wrap the solver components of OpenSn
2490
void
2491
py_solver(py::module& pyopensn)
105 ✔
2492
{
2493
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
105 ✔
2494
  WrapProblem(slv);
105 ✔
2495
  WrapSolver(slv);
105 ✔
2496
  WrapLBS(slv);
105 ✔
2497
  WrapUncollidedSolver(slv);
105 ✔
2498
  WrapSteadyState(slv);
105 ✔
2499
  WrapTransient(slv);
105 ✔
2500
  WrapNLKEigen(slv);
105 ✔
2501
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
105 ✔
2502
  WrapPIteration(slv);
105 ✔
2503
}
105 ✔
2504

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