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

Open-Sn / opensn / 37601727069

03 Oct 2026 10:44PM UTC coverage: 78.764%. Remained the same
37601727069

push

github

web-flow
Merge pull request #1162 from wdhawkins/transport_fixes

Various transport solver fixes

285 of 331 new or added lines in 24 files covered. (86.1%)

379 existing lines in 19 files now uncovered.

28070 of 35638 relevant lines covered (78.76%)

85999282.44 hits per line

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

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

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

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

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

90
// Wrap LBS solver
91
void
92
WrapLBS(py::module& slv)
1,140 ✔
93
{
94
  // clang-format off
95
  // LBS problem
96
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
1,140 ✔
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,140 ✔
105
  lbs_problem.def(
1,140 ✔
106
    "GetScalarFluxFieldFunction",
107
    [](LBSProblem& self, bool only_scalar_flux)
1,140 ✔
108
    {
109
      py::list field_function_list_per_group;
15,401 ✔
110
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
54,774 ✔
111
      {
112
        if (only_scalar_flux)
39,373 ✔
113
        {
114
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
60,782 ✔
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,401 ✔
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,140 ✔
164
  );
165
  lbs_problem.def(
2,280 ✔
166
    "CreateFieldFunction",
167
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
2,280 ✔
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,280 ✔
203
    py::arg("xs_name"),
1,140 ✔
204
    py::arg("power_normalization_target") = -1.0
1,140 ✔
205
  );
206
  lbs_problem.def(
1,140 ✔
207
    "ComputeFieldFunctionPowerScaleFactor",
208
    &LBSProblem::ComputeFieldFunctionPowerScaleFactor,
2,280 ✔
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,140 ✔
231
  );
232
  lbs_problem.def(
1,140 ✔
233
    "GetTime",
234
    &LBSProblem::GetTime,
1,140 ✔
235
    R"(
236
    Get the current simulation time in seconds.
237
    )"
238
  );
239
  lbs_problem.def(
1,140 ✔
240
    "GetTimeStep",
241
    &LBSProblem::GetTimeStep,
1,140 ✔
242
    R"(
243
    Get the current timestep size.
244
    )"
245
  );
246
  lbs_problem.def(
1,140 ✔
247
    "ComputeFissionRate",
248
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,141 ✔
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,140 ✔
286
  );
287
  lbs_problem.def(
1,140 ✔
288
    "ComputeFissionProduction",
289
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
2,681 ✔
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,140 ✔
327
  );
328
  lbs_problem.def(
1,140 ✔
329
    "GetPhiOldLocal",
330
    [](LBSProblem& self)
1,236 ✔
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,140 ✔
344
    "GetPhiNewLocal",
345
    [](LBSProblem& self)
2,168 ✔
346
    {
347
      return convert_vector(self.GetPhiNewLocal());
1,028 ✔
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,140 ✔
359
    "WriteFluxMoments",
360
    [](LBSProblem& self, const std::string& file_base)
1,189 ✔
361
    {
362
      LBSSolverIO::WriteFluxMoments(self, file_base);
49 ✔
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,140 ✔
373
  );
374
  lbs_problem.def(
1,140 ✔
375
    "CreateAndWriteSourceMoments",
376
    [](LBSProblem& self, const std::string& file_base)
1,144 ✔
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,140 ✔
390
  );
391
  lbs_problem.def(
1,140 ✔
392
    "ReadFluxMomentsAndMakeSourceMoments",
393
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,140 ✔
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,280 ✔
415
    py::arg("single_file_flag")
1,140 ✔
416
  );
417
  lbs_problem.def(
1,140 ✔
418
    "ReadSourceMoments",
419
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,144 ✔
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,280 ✔
436
    py::arg("single_file_flag")
1,140 ✔
437
  );
438
  lbs_problem.def(
1,140 ✔
439
    "ReadFluxMoments",
440
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,140 ✔
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,280 ✔
455
    py::arg("single_file_flag")
1,140 ✔
456
  );
457
  lbs_problem.def(
1,140 ✔
458
    "WriteAngularFluxes",
459
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,155 ✔
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,140 ✔
472
  );
473
  lbs_problem.def(
1,140 ✔
474
    "ReadAngularFluxes",
475
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,144 ✔
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,140 ✔
488
  );
489
  lbs_problem.def(
2,280 ✔
490
    "WriteSurfaceAngularFluxes",
491
    [](DiscreteOrdinatesProblem& self,
1,166 ✔
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(
26 ✔
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,140 ✔
526
    py::arg("boundary_surfaces") = std::vector<std::string>{},
2,280 ✔
527
    py::arg("interior_surfaces") = std::map<std::string, std::pair<std::string, double>>{}
2,280 ✔
528
  );
529
  lbs_problem.def(
1,140 ✔
530
    "ReadSurfaceAngularFluxes",
531
    [](DiscreteOrdinatesProblem& self, const std::string& file_base, py::list surfaces)
1,154 ✔
532
    {
533
      std::vector<std::string> surface_ids;
14 ✔
534
      for (py::handle surface : surfaces)
50 ✔
535
        surface_ids.push_back(surface.cast<std::string>());
44 ✔
536

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

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

563
        py::dict entry;
22 ✔
564
        entry["groupset_id"] = surface_flux.groupset_id;
22 ✔
565
        entry["adjoint"] = surface_flux.adjoint;
22 ✔
566
        entry["surface_name"] = surface_flux.surface_name;
22 ✔
567
        entry["mapping"] = std::move(mapping);
22 ✔
568
        entry["data"] = std::move(data);
22 ✔
569
        result.append(std::move(entry));
22 ✔
570
      }
22 ✔
571
      return result;
14 ✔
572
    },
14 ✔
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,280 ✔
602
    py::arg("surfaces")
1,140 ✔
603
  );
604
  lbs_problem.def(
1,140 ✔
605
    "SetPointSources",
606
    [](LBSProblem& self, py::kwargs& params)
1,140 ✔
607
    {
608
      for (auto [key, value] : params)
×
609
      {
610
        auto c_key = key.cast<std::string>();
×
611
        if (c_key == "clear_point_sources")
×
612
          self.ClearPointSources();
×
613
        else if (c_key == "point_sources")
×
614
        {
615
          auto sources = value.cast<py::list>();
×
616
          for (auto source : sources)
×
617
          {
618
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
619
          }
620
        }
×
621
        else
622
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
623
      }
×
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,140 ✔
637
    "SetVolumetricSources",
638
    [](LBSProblem& self, py::kwargs& params)
1,242 ✔
639
    {
640
      for (auto [key, value] : params)
306 ✔
641
      {
642
        auto c_key = key.cast<std::string>();
102 ✔
643
        if (c_key == "clear_volumetric_sources")
102 ✔
644
          self.ClearVolumetricSources();
65 ✔
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
654
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
655
      }
102 ✔
656
    },
102 ✔
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,140 ✔
669
    "SetXSMap",
670
    [](LBSProblem& self, py::kwargs& params)
1,516 ✔
671
    {
672
      BlockID2XSMap xs_map;
376 ✔
673
      for (auto [key, value] : params)
1,128 ✔
674
      {
675
        auto c_key = key.cast<std::string>();
376 ✔
676
        if (c_key == "xs_map")
376 ✔
677
        {
678
          auto xs_entries = value.cast<py::list>();
376 ✔
679
          for (auto entry : xs_entries)
1,128 ✔
680
          {
681
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
376 ✔
682
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
376 ✔
683
            const auto& block_ids =
376 ✔
684
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
376 ✔
685
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
376 ✔
686
            for (const auto& block_id : block_ids)
754 ✔
687
              xs_map[block_id] = xs;
378 ✔
688
          }
376 ✔
689
        }
376 ✔
690
        else
691
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
692
      }
376 ✔
693
      self.SetBlockID2XSMap(xs_map);
376 ✔
694
    },
374 ✔
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,140 ✔
736
    "ZeroPhi",
737
    [](LBSProblem& self)
1,332 ✔
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,140 ✔
746
    "SetAdjoint",
747
    [](LBSProblem& self, bool adjoint)
1,140 ✔
748
    {
749
      self.SetAdjoint(adjoint);
37 ✔
750
    },
751
    py::arg("adjoint") = true,
1,140 ✔
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,140 ✔
790
    "SetForward",
791
    &LBSProblem::SetForward,
1,140 ✔
792
    R"(
793
    Set forward transport mode.
794

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

813
  auto unc_problem =
1,140 ✔
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,140 ✔
829
  unc_problem.def(
2,280 ✔
830
    py::init(
1,140 ✔
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,140 ✔
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,140 ✔
916
  do_problem.def(
2,280 ✔
917
    py::init(
1,140 ✔
918
      [](py::kwargs& params)
1,754 ✔
919
      {
920
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
1,754 ✔
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,140 ✔
1094
    "SetTimeDependentMode",
1095
    py::overload_cast<>(&DiscreteOrdinatesProblem::SetTimeDependentMode),
1,140 ✔
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,140 ✔
1109
    "SetSteadyStateMode",
1110
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
1,140 ✔
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,140 ✔
1122
    "IsTimeDependent",
1123
    &DiscreteOrdinatesProblem::IsTimeDependent,
1,140 ✔
1124
    R"(
1125
    Return ``True`` if the problem is currently in time-dependent mode.
1126
    )"
1127
  );
1128
  do_problem.def(
1,140 ✔
1129
    "SetBoundaryOptions",
1130
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
1,324 ✔
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
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,140 ✔
1193
    "ZeroPsi",
1194
    [](DiscreteOrdinatesProblem& self)
1,140 ✔
1195
    {
1196
      self.ZeroPsi();
×
1197
    },
1198
    R"(
1199
    Zero the angular-flux storage.
1200
    )"
1201
  );
1202
  do_problem.def(
1,140 ✔
1203
    "GetPsi",
1204
    [](DiscreteOrdinatesProblem& self)
1,308 ✔
1205
    {
1206
      const auto& psi = self.GetPsiNewLocal();
168 ✔
1207
      py::list psi_list;
168 ✔
1208
      for (const auto& vec : psi)
336 ✔
1209
      {
1210
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()));
168 ✔
1211
        std::copy(vec.begin(), vec.end(), static_cast<double*>(array.mutable_data()));
168 ✔
1212
        psi_list.append(array);
168 ✔
1213
      }
168 ✔
1214
      return psi_list;
168 ✔
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,140 ✔
1224
    "GetAngularFieldFunctionList",
1225
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
1,140 ✔
1226
    {
1227
      std::vector<unsigned int> group_ids;
×
1228
      std::vector<size_t> angle_ids;
×
1229
      group_ids.reserve(groups.size());
×
1230
      angle_ids.reserve(angles.size());
×
1231

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

1237
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
1238
      py::list out;
×
1239
      for (const auto& ff : ff_list)
×
1240
        out.append(ff);
×
1241
      return out;
×
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,280 ✔
1307
    py::arg("angles")
1,140 ✔
1308
  );
1309
  do_problem.def(
1,140 ✔
1310
    "ComputeLeakage",
1311
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
1,581 ✔
1312
    {
1313
      auto grid = self.GetGrid();
441 ✔
1314
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
441 ✔
1315
      const auto coord_sys = grid->GetCoordinateSystem();
441 ✔
1316
      const auto mesh_type = grid->GetType();
441 ✔
1317
      const auto dim = grid->GetDimension();
441 ✔
1318
      // Resolve the requested boundaries, preserving user order. An empty list selects all.
1319
      std::vector<std::uint64_t> bndry_ids;
441 ✔
1320
      if (not bnd_names.empty())
441 ✔
1321
      {
1322
        for (py::handle name : bnd_names)
2,547 ✔
1323
        {
1324
          const auto sname = name.cast<std::string>();
1,673 ✔
1325
          const auto bid = self.FindBoundaryID(sname);
1,673 ✔
1326
          if (not bid.has_value())
1,673 ✔
1327
            throw std::invalid_argument("ComputeLeakage: Boundary name '" + sname +
4 ✔
1328
                                        "' is not valid for this problem.");
6 ✔
1329
          bndry_ids.push_back(*bid);
1,671 ✔
1330
        }
1,673 ✔
1331
      }
1332
      else
1333
      {
1334
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
11 ✔
1335
      }
1336
      // compute the leakage
1337
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
439 ✔
1338
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
878 ✔
1339
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
439 ✔
1340

1341
      auto ToRZName = [](const std::string& name)
469 ✔
1342
      {
1343
        if (name == "xmin") return std::string("rmin");
30 ✔
1344
        if (name == "xmax") return std::string("rmax");
30 ✔
1345
        if (name == "ymin") return std::string("zmin");
20 ✔
1346
        if (name == "ymax") return std::string("zmax");
10 ✔
UNCOV
1347
        return name;
×
1348
      };
1349

1350
      // convert result to native Python
1351
      py::dict result;
439 ✔
1352
      for (const auto& bndry_id : bndry_ids)
2,116 ✔
1353
      {
1354
        const auto it = leakage.find(bndry_id);
1,677 ✔
1355
        if (it == leakage.end())
1,677 ✔
UNCOV
1356
          continue;
×
1357
        // construct numpy array and copy contents
1358
        const auto& grp_wise_leakage = it->second;
1,677 ✔
1359
        py::array_t<double> np_vector(static_cast<py::ssize_t>(grp_wise_leakage.size()));
1,677 ✔
1360
        auto buffer = np_vector.request();
1,677 ✔
1361
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
1,677 ✔
1362
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
1,677 ✔
1363
        std::string name = allowed_bd_ids.at(bndry_id);
1,677 ✔
1364
        if (rz_ortho)
1,677 ✔
1365
          name = ToRZName(name);
30 ✔
1366
        result[py::str(name)] = std::move(np_vector);
3,354 ✔
1367
      }
1,677 ✔
1368

1369
      return result;
439 ✔
1370
    },
882 ✔
1371
    R"(
1372
    Compute leakage for the problem.
1373

1374
    Parameters
1375
    ----------
1376
    bnd_names : List[str]
1377
        A list of boundary names for which leakage should be computed. An empty list selects all
1378
        boundaries. RZ problems use rmin, rmax, zmin, and zmax.
1379

1380
    Returns
1381
    -------
1382
    Dict[str, numpy.ndarray]
1383
        A dictionary mapping boundary names to group-wise leakage vectors.
1384
        Each array contains the outflow (the outgoing partial current, per group) integrated over
1385
        the corresponding boundary surface. Incoming flux is not subtracted, so on boundaries
1386
        with inflow (sources or reflection) this is not the net leakage. In RZ geometry the
1387
        surface integral covers the full revolution.
1388

1389
    Raises
1390
    ------
1391
    RuntimeError
1392
        If `save_angular_flux` option was not enabled during problem setup.
1393

1394
    ValueError
1395
        If one or more boundary ids are not present on the current mesh.
1396
    )",
1397
    py::arg("bnd_names")
1,140 ✔
1398
  );
1399

1400
  // discrete ordinates curvilinear problem
1401
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
1,140 ✔
1402
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1403
                                           DiscreteOrdinatesProblem>(
1404
    slv,
1405
    "DiscreteOrdinatesCurvilinearProblem",
1406
    R"(
1407
    Base class for discrete ordinates problems in curvilinear geometry.
1408

1409
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1410
    )"
1411
  );
1,140 ✔
1412
  do_curvilinear_problem.def(
2,280 ✔
1413
    py::init(
1,140 ✔
1414
      [](py::kwargs& params)
90 ✔
1415
      {
1416
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
90 ✔
1417
      }
1418
    ),
1419
    R"(
1420
    Construct a discrete ordinates problem for curvilinear geometry.
1421

1422
    Warnings
1423
    --------
1424
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1425

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

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

1575
// Wrap uncollided solver
1576
void
1577
WrapUncollidedSolver(py::module& slv)
1,140 ✔
1578
{
1579
  // clang-format off
1580
  auto uncollided_solver =
1,140 ✔
1581
    py::class_<UncollidedSolver, std::shared_ptr<UncollidedSolver>, Solver>(
1582
      slv,
1583
      "UncollidedSolver",
1584
      R"(
1585
    Generate and write uncollided flux moments for first-collision transport.
1586

1587
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1588
    )"
1589
  );
1,140 ✔
1590
  uncollided_solver.def(
2,280 ✔
1591
    py::init(
1,140 ✔
1592
      [](py::kwargs& params)
23 ✔
1593
      {
1594
        return UncollidedSolver::Create(kwargs_to_param_block(params));
23 ✔
1595
      }
1596
    ),
1597
    R"(
1598
    Construct an uncollided transport solver.
1599

1600
    Parameters
1601
    ----------
1602
    problem : pyopensn.solver.UncollidedProblem
1603
        Existing uncollided problem instance.
1604
    file_name : str
1605
        Output HDF5 file containing uncollided flux moments, mesh and total
1606
        cross-section metadata, and production/removal/outflow balance terms.
1607
    progress_interval : int, default=5
1608
        Percentage interval for source-point progress reports. Reports include
1609
        completed source points, elapsed time, and estimated remaining time.
1610
        Set to zero to disable progress reporting. Valid values are integers in
1611
        ``[0, 100]``.
1612

1613
    Notes
1614
    -----
1615
    :meth:`Initialize` requires exactly one MPI rank. The generated HDF5 file
1616
    may then be reused by a serial or parallel collided calculation on the
1617
    same mesh.
1618

1619
    Internal threading in the current uncollided implementation is capped by
1620
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1621
    the variable is unset or invalid.
1622
    )"
1623
  );
1624
  // clang-format on
1625
}
1,140 ✔
1626

1627
// Wrap steady-state solver
1628
void
1629
WrapSteadyState(py::module& slv)
1,140 ✔
1630
{
1631
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,197 ✔
1632
  {
1633
    py::dict values;
57 ✔
1634
    values["absorption_rate"] = table.absorption_rate;
57 ✔
1635
    values["production_rate"] = table.production_rate;
57 ✔
1636
    values["inflow_rate"] = table.inflow_rate;
57 ✔
1637
    values["outflow_rate"] = table.outflow_rate;
57 ✔
1638
    values["balance"] = table.balance;
57 ✔
1639
    if (table.initial_inventory.has_value())
57 ✔
1640
      values["initial_inventory"] = table.initial_inventory.value();
×
1641
    if (table.final_inventory.has_value())
57 ✔
UNCOV
1642
      values["final_inventory"] = table.final_inventory.value();
×
1643
    if (table.predicted_inventory_change.has_value())
57 ✔
UNCOV
1644
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1645
    if (table.actual_inventory_change.has_value())
57 ✔
UNCOV
1646
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1647
    if (table.inventory_residual.has_value())
57 ✔
UNCOV
1648
      values["inventory_residual"] = table.inventory_residual.value();
×
1649
    return values;
57 ✔
UNCOV
1650
  };
×
1651

1652
  // clang-format off
1653
  // steady state solver
1654
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,140 ✔
1655
                                        Solver>(
1656
    slv,
1657
    "SteadyStateSourceSolver",
1658
    R"(
1659
    Steady state solver.
1660

1661
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1662
    )"
1663
  );
1,140 ✔
1664
  steady_state_solver.def(
2,280 ✔
1665
    py::init(
1,140 ✔
1666
      [](py::kwargs& params)
874 ✔
1667
      {
1668
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
874 ✔
1669
      }
1670
    ),
1671
    R"(
1672
    Construct a steady state solver.
1673

1674
    Parameters
1675
    ----------
1676
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1677
        Existing discrete ordinates problem instance.
1678

1679
    Notes
1680
    -----
1681
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1682
    restart data is read during :meth:`Initialize`. If it was constructed with
1683
    ``options={'restart_writes_enabled': True, ...}``, a restart dump is written
1684
    after :meth:`Execute` completes.
1685

1686
    Restart files are rank-layout specific. A restart written with ``N`` MPI
1687
    ranks must be read with the same rank count and a compatible problem
1688
    definition.
1689
    )"
1690
  );
1691
  steady_state_solver.def(
1,140 ✔
1692
    "ComputeBalanceTable",
1693
    [BalanceTableToDict](const SteadyStateSourceSolver& self)
1,140 ✔
1694
    {
1695
      return BalanceTableToDict(self.ComputeBalanceTable());
57 ✔
1696
    },
1697
    R"(
1698
    Compute and return the global balance table using the solver's normalization.
1699
    This is a collective operation and must be called on all ranks.
1700

1701
    Returns
1702
    -------
1703
    dict
1704
        Dictionary with the following entries:
1705

1706
        - ``absorption_rate``:
1707
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1708
          groups and the full domain.
1709
        - ``production_rate``:
1710
          Global volumetric production/source rate used by the solver,
1711
          approximately ``integral Q dV`` summed over groups and the full domain.
1712
        - ``inflow_rate``:
1713
          Global incoming boundary contribution integrated over incoming
1714
          angular flux on boundaries.
1715
        - ``outflow_rate``:
1716
          Global outgoing boundary contribution accumulated from face outflow
1717
          tallies.
1718
        - ``balance``:
1719
          Rate balance,
1720
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1721

1722
    Notes
1723
    -----
1724
    This solver applies no extra normalization to the balance table.
1725
    )"
1726
  );
1727
  // clang-format on
1728
}
1,140 ✔
1729

1730
// Wrap transient solver
1731
void
1732
WrapTransient(py::module& slv)
1,140 ✔
1733
{
1734
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,332 ✔
1735
  {
1736
    py::dict values;
192 ✔
1737
    values["absorption_rate"] = table.absorption_rate;
192 ✔
1738
    values["production_rate"] = table.production_rate;
192 ✔
1739
    values["inflow_rate"] = table.inflow_rate;
192 ✔
1740
    values["outflow_rate"] = table.outflow_rate;
192 ✔
1741
    values["balance"] = table.balance;
192 ✔
1742
    if (table.initial_inventory.has_value())
192 ✔
1743
      values["initial_inventory"] = table.initial_inventory.value();
384 ✔
1744
    if (table.final_inventory.has_value())
192 ✔
1745
      values["final_inventory"] = table.final_inventory.value();
384 ✔
1746
    if (table.predicted_inventory_change.has_value())
192 ✔
1747
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
384 ✔
1748
    if (table.actual_inventory_change.has_value())
192 ✔
1749
      values["actual_inventory_change"] = table.actual_inventory_change.value();
384 ✔
1750
    if (table.inventory_residual.has_value())
192 ✔
1751
      values["inventory_residual"] = table.inventory_residual.value();
384 ✔
1752
    return values;
192 ✔
UNCOV
1753
  };
×
1754
  // clang-format off
1755
  auto transient_solver =
1,140 ✔
1756
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1757
      slv,
1758
      "TransientSolver",
1759
      R"(
1760
      Transient solver.
1761

1762
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1763
      )"
1764
    );
1,140 ✔
1765
  transient_solver.def(
2,280 ✔
1766
    py::init(
1,140 ✔
1767
      [](py::kwargs& params)
708 ✔
1768
      {
1769
        return TransientSolver::Create(kwargs_to_param_block(params));
708 ✔
1770
      }
1771
    ),
1772
    R"(
1773
    Construct a transient solver.
1774

1775
    Parameters
1776
    ----------
1777
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1778
        Existing discrete ordinates problem instance.
1779
    dt : float, optional, default=2.0e-3
1780
        Time step size used during the simulation.
1781
    stop_time : float, optional, default=0.1
1782
        Simulation end time.
1783
    theta : float, optional, default=0.5
1784
        Time differencing scheme parameter.
1785
    initial_state : str, optional, default="existing"
1786
        Initial state for the transient solve. Allowed values: existing, zero.
1787
        In "zero" mode, scalar flux vectors are reset to zero.
1788
    verbose : bool, optional, default=True
1789
        Enable verbose logging.
1790

1791
    Notes
1792
    -----
1793
    The associated problem must have ``save_angular_flux=True`` enabled. This
1794
    is required for transient problems.
1795

1796
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1797
    restart data is read during :meth:`Initialize`. If it was constructed with
1798
    ``options={'read_initial_condition_path': ...}``, restart data is read as a
1799
    transient initial condition during :meth:`Initialize`, then the problem is
1800
    switched to time-dependent mode; a steady-state restart may be used in this
1801
    mode. In this initial-condition path, stored angular fluxes and delayed
1802
    sweep angular-flux buffers are optional in the steady-state restart; the
1803
    transient solver reconstructs angular state from the loaded flux moments.
1804
    If it was constructed with
1805
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
1806
    be written during :meth:`Execute` and a final restart dump is written when
1807
    execution completes.
1808

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

1826
    Parameters
1827
    ----------
1828
    dt : float
1829
        New timestep size.
1830
    )");
1831
  transient_solver.def(
1,140 ✔
1832
    "SetTheta",
1833
    &TransientSolver::SetTheta,
1,140 ✔
1834
    R"(
1835
    Set the theta parameter used by :meth:`Advance`.
1836

1837
    Parameters
1838
    ----------
1839
    theta : float
1840
        Theta value between 1.0e-16 and 1.
1841
    )");
1842
  transient_solver.def(
1,140 ✔
1843
    "Advance",
1844
    &TransientSolver::Advance,
1,140 ✔
1845
    R"(
1846
    Advance the solver by a single timestep.
1847

1848
    Notes
1849
    -----
1850
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1851
    :meth:`Execute`.
1852
    )");
1853
  transient_solver.def(
1,140 ✔
1854
    "SetPreAdvanceCallback",
1855
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,140 ✔
1856
      &TransientSolver::SetPreAdvanceCallback),
1857
    R"(
1858
    Register a callback that runs before each advance within :meth:`Execute`.
1859

1860
    :param callback: Function invoked before the solver advances a timestep. Pass None
1861
        to clear. If the callback modifies the timestep, the new value is used for the
1862
        upcoming step.
1863
    :type callback: Optional[Callable[[], None]]
1864
    )");
1865
  transient_solver.def(
1,140 ✔
1866
    "SetPreAdvanceCallback",
1867
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPreAdvanceCallback),
1,140 ✔
1868
    "Clear the PreAdvance callback by passing None.");
1869
  transient_solver.def(
1,140 ✔
1870
    "SetPostAdvanceCallback",
1871
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,140 ✔
1872
      &TransientSolver::SetPostAdvanceCallback),
1873
    R"(
1874
    Register a callback that runs after each advance within :meth:`Execute`.
1875

1876
    :param callback: Function invoked after the solver advances a timestep. Pass None
1877
        to clear.
1878
    :type callback: Optional[Callable[[], None]]
1879
    )");
1880
  transient_solver.def(
1,140 ✔
1881
    "SetPostAdvanceCallback",
1882
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPostAdvanceCallback),
1,140 ✔
1883
    "Clear the PostAdvance callback by passing None.");
1884
  transient_solver.def(
1,140 ✔
1885
    "ComputeBalanceTable",
1886
    [BalanceTableToDict](const TransientSolver& self)
1,332 ✔
1887
    {
1888
      return BalanceTableToDict(self.ComputeBalanceTable());
192 ✔
1889
    },
1890
    R"(
1891
    Compute and return the global balance table using the solver's normalization.
1892
    This is a collective operation and must be called on all ranks.
1893

1894
    Returns
1895
    -------
1896
    dict
1897
        Dictionary with the following entries:
1898

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

1931
    Notes
1932
    -----
1933
    This solver applies no extra normalization to the balance table.
1934

1935
    The transient inventory terms currently use the end-of-step rate balance to
1936
    estimate the timestep inventory change.
1937
    )"
1938
  );
1939
  slv.attr("BackwardEuler") = 1.0;
1,140 ✔
1940
  slv.attr("CrankNicolson") = 0.5;
2,280 ✔
1941
  // clang-format on
1942
}
1,140 ✔
1943

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

1977
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1978

1979
    Supports one or more groupsets when the groupsets run without WGDSA/TGDSA.
1980
    If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen solve
1981
    must use a single groupset.
1982
    )"
1983
  );
1,140 ✔
1984
  non_linear_k_eigen_solver.def(
2,280 ✔
1985
    py::init(
1,140 ✔
1986
      [](py::kwargs& params)
47 ✔
1987
      {
1988
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
47 ✔
1989
      }
1990
        ),
1991
    R"(
1992
    Construct a non-linear k-eigenvalue solver.
1993

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

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

2051
    Returns
2052
    -------
2053
    dict
2054
        Dictionary with the following entries:
2055

2056
        - ``absorption_rate``:
2057
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2058
          groups and the full domain.
2059
        - ``production_rate``:
2060
          Global volumetric production/source rate used by the solver,
2061
          approximately ``integral Q dV`` summed over groups and the full domain.
2062
        - ``inflow_rate``:
2063
          Global incoming boundary contribution integrated over incoming
2064
          angular flux on boundaries.
2065
        - ``outflow_rate``:
2066
          Global outgoing boundary contribution accumulated from face outflow
2067
          tallies.
2068
        - ``balance``:
2069
          Rate balance,
2070
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2071

2072
    Notes
2073
    -----
2074
    For k-eigenvalue balance reporting, this solver scales the production term by
2075
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2076
    )"
2077
  );
2078
  // clang-format on
2079
}
1,140 ✔
2080

2081
// Wrap power iteration solvers
2082
void
2083
WrapPIteration(py::module& slv)
1,140 ✔
2084
{
2085
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,153 ✔
2086
  {
2087
    py::dict values;
13 ✔
2088
    values["absorption_rate"] = table.absorption_rate;
13 ✔
2089
    values["production_rate"] = table.production_rate;
13 ✔
2090
    values["inflow_rate"] = table.inflow_rate;
13 ✔
2091
    values["outflow_rate"] = table.outflow_rate;
13 ✔
2092
    values["balance"] = table.balance;
13 ✔
2093
    if (table.initial_inventory.has_value())
13 ✔
2094
      values["initial_inventory"] = table.initial_inventory.value();
×
2095
    if (table.final_inventory.has_value())
13 ✔
UNCOV
2096
      values["final_inventory"] = table.final_inventory.value();
×
2097
    if (table.predicted_inventory_change.has_value())
13 ✔
UNCOV
2098
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
2099
    if (table.actual_inventory_change.has_value())
13 ✔
UNCOV
2100
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
2101
    if (table.inventory_residual.has_value())
13 ✔
UNCOV
2102
      values["inventory_residual"] = table.inventory_residual.value();
×
2103
    return values;
13 ✔
UNCOV
2104
  };
×
2105
  // clang-format off
2106
  // power iteration k-eigen solver
2107
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
1,140 ✔
2108
                                      Solver>(
2109
    slv,
2110
    "PowerIterationKEigenSolver",
2111
    R"(
2112
    Power iteration k-eigenvalue solver.
2113

2114
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
2115
    )"
2116
  );
1,140 ✔
2117
  pi_k_eigen_solver.def(
2,280 ✔
2118
    py::init(
1,140 ✔
2119
      [](py::kwargs& params)
466 ✔
2120
      {
2121
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
466 ✔
2122
      }
2123
    ),
2124
    R"(
2125
    Construct a power iteration k-eigen solver.
2126

2127
    Parameters
2128
    ----------
2129
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2130
        Existing DiscreteOrdinatesProblem instance.
2131
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
2132
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
2133
    max_iters: int, default = 1000
2134
        Maximum power iterations allowed.
2135
    k_tol: float, default = 1.0e-10
2136
        Tolerance on the k-eigenvalue.
2137
    reset_solution: bool, default=True
2138
        If true, initialize flux moments to 1.0.
2139
    reset_phi0: bool, default=True
2140
        If true, reinitializes scalar fluxes to 1.0.
2141

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

2182
    Returns
2183
    -------
2184
    dict
2185
        Dictionary with the following entries:
2186

2187
        - ``absorption_rate``:
2188
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2189
          groups and the full domain.
2190
        - ``production_rate``:
2191
          Global volumetric production/source rate used by the solver,
2192
          approximately ``integral Q dV`` summed over groups and the full domain.
2193
        - ``inflow_rate``:
2194
          Global incoming boundary contribution integrated over incoming
2195
          angular flux on boundaries.
2196
        - ``outflow_rate``:
2197
          Global outgoing boundary contribution accumulated from face outflow
2198
          tallies.
2199
        - ``balance``:
2200
          Rate balance,
2201
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2202

2203
    Notes
2204
    -----
2205
    For k-eigenvalue balance reporting, this solver scales the production term by
2206
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2207
    )"
2208
  );
2209
  // clang-format on
2210
}
1,140 ✔
2211

2212
// Wrap LBS solver
2213
void
2214
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
1,140 ✔
2215
{
2216
  // clang-format off
2217
  // discrete ordinates k-eigen acceleration base
2218
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
1,140 ✔
2219
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
2220
    slv,
2221
    "DiscreteOrdinatesKEigenAcceleration",
2222
    R"(
2223
    Base class for discrete ordinates k-eigenvalue acceleration methods.
2224

2225
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
2226
    )"
2227
  );
1,140 ✔
2228
  // SCDSA acceleration
2229
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
1,140 ✔
2230
                                       std::shared_ptr<SCDSAAcceleration>,
2231
                                       DiscreteOrdinatesKEigenAcceleration>(
2232
    slv,
2233
    "SCDSAAcceleration",
2234
    R"(
2235
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
2236

2237
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
2238
    )"
2239
  );
1,140 ✔
2240
  scdsa_acceleration.def(
2,280 ✔
2241
    py::init(
1,140 ✔
2242
      [](py::kwargs& params)
8 ✔
2243
      {
2244
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8 ✔
2245
      }
2246
    ),
2247
    R"(
2248
    SCDSA acceleration for the power iteration k-eigenvalue solver.
2249

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

2281
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
2282
    )"
2283
  );
1,140 ✔
2284
  smm_acceleration.def(
2,280 ✔
2285
    py::init(
1,140 ✔
2286
      [](py::kwargs& params)
4 ✔
2287
      {
2288
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4 ✔
2289
      }
2290
    ),
2291
    R"(
2292
    SMM acceleration for the power iteration k-eigenvalue solver.
2293

2294
    Warnings
2295
    --------
2296
       SMM acceleration is **experimental** and should be used with caution!
2297
       SMM acceleration only supports problems with isotropic scattering.
2298

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

2330
    Wrapper of :cpp:class:`opensn::CMFDAcceleration`.
2331
    )"
2332
  );
1,140 ✔
2333
  cmfd_acceleration.def(
2,280 ✔
2334
    py::init(
1,140 ✔
2335
      [](py::kwargs& params)
92 ✔
2336
      {
2337
        return CMFDAcceleration::Create(kwargs_to_param_block(params));
92 ✔
2338
      }
2339
    ),
2340
    R"(
2341
    CMFD acceleration for the power iteration k-eigenvalue solver.
2342

2343
    With CMFD, each power iteration performs a configured number of high-order WGS
2344
    transport update iterations, then applies a bounded low-order scalar-flux correction.
2345
    The defaults use one WGS update iteration, automatic current closure, fixed
2346
    correction relaxation, and a transport-current balance gate before outer power
2347
    iteration is allowed to converge.
2348

2349
    Most users should tune only the common controls: ``coarse_mesh``, ``current_closure``,
2350
    ``aggregation_size``, ``group_aggregation_size``, ``relaxation``,
2351
    ``update_wgs_max_its``, ``update_wgs_abs_tol``, and
2352
    ``balance_residual_tolerance``. Parameters marked "developer/debug" are exposed for
2353
    investigations and regression testing, but normally should be left at their defaults.
2354

2355
    Parameters
2356
    ----------
2357
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2358
        Existing DiscreteOrdinatesProblem instance.
2359
    coarse_mesh: str, default="local_aggregation"
2360
        Common option. Coarse-mesh construction method. Valid choices are:
2361
            - 'identity' : one CMFD coarse cell per transport cell
2362
            - 'local_aggregation' : connected same-block fine cells aggregated locally
2363
            - 'global_aggregation' : connected same-block fine cells aggregated across MPI ranks
2364

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

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

2465
        CMFD corrections from unconverged coarse linear solves are always skipped.
2466
        The skipped correction uses the unaccelerated transport update for that power
2467
        iteration, including the raw transport k update so that power iteration can continue
2468
        to move.
2469
    coarse_direct_solve_threshold: int, default=20000
2470
        Developer/debug. Maximum global CMFD unknown count for automatic direct coarse
2471
        solves when ``coarse_solver_policy="auto"``. Larger values make ``"auto"`` use
2472
        direct solves for larger coarse systems; choose values based on available host
2473
        memory and acceptable factorization cost.
2474
    )"
2475
  );
2476
  // clang-format on
2477
}
1,140 ✔
2478

2479
// Wrap the solver components of OpenSn
2480
void
2481
py_solver(py::module& pyopensn)
105 ✔
2482
{
2483
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
105 ✔
2484
  WrapProblem(slv);
105 ✔
2485
  WrapSolver(slv);
105 ✔
2486
  WrapLBS(slv);
105 ✔
2487
  WrapUncollidedSolver(slv);
105 ✔
2488
  WrapSteadyState(slv);
105 ✔
2489
  WrapTransient(slv);
105 ✔
2490
  WrapNLKEigen(slv);
105 ✔
2491
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
105 ✔
2492
  WrapPIteration(slv);
105 ✔
2493
}
105 ✔
2494

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