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

Open-Sn / opensn / 27922676724

21 Jun 2026 09:16PM UTC coverage: 78.584% (+1.1%) from 77.467%
27922676724

push

github

web-flow
Merge pull request #1108 from wdhawkins/disable_hip_tests

Temporarily disable HIP tests

26280 of 33442 relevant lines covered (78.58%)

86617107.22 hits per line

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

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

35
namespace opensn
36
{
37

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

248
    Returns
249
    -------
250
    float
251
        The total fission rate.
252

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

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

289
    Returns
290
    -------
291
    float
292
        The total fission production.
293

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

310
    Returns
311
    -------
312
    memoryview
313
        Memory view of the local old scalar flux vector.
314
    )"
315
  );
316
  lbs_problem.def(
1,013✔
317
    "GetPhiNewLocal",
318
    [](LBSProblem& self)
1,633✔
319
    {
320
      return convert_vector(self.GetPhiNewLocal());
620✔
321
    },
322
    R"(
323
    Get the current scalar flux iterate (local vector).
324

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

613
    Parameters
614
    ----------
615
    adjoint: bool, default=True
616
        ``True`` enables adjoint mode and ``False`` enables forward mode.
617

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

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

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

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

636
    After a mode change, reapply the desired driving terms before solving, typically:
637
      - :meth:`LBSProblem.SetPointSources`
638
      - :meth:`LBSProblem.SetVolumetricSources`
639
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
640

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

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

669
  auto unc_problem =
1,013✔
670
    py::class_<UncollidedProblem, std::shared_ptr<UncollidedProblem>, LBSProblem>(
671
      slv,
672
      "UncollidedProblem",
673
      R"(
674
    Define an uncollided transport problem for a first-collision calculation.
675

676
    The problem stores the mesh, materials, point sources, boundaries, and
677
    near-source regions used by :class:`UncollidedSolver`.
678

679
    Wrapper of :cpp:class:`opensn::UncollidedProblem`.
680
    )");
1,013✔
681
  unc_problem.def(
2,026✔
682
    py::init(
1,013✔
683
      [](py::kwargs& params)
29✔
684
      {
685
        return UncollidedProblem::Create(kwargs_to_param_block(params));
29✔
686
      }),
687
    R"(
688
    Construct an uncollided transport problem.
689

690
    Parameters
691
    ----------
692
    mesh : MeshContinuum
693
        Cartesian two- or three-dimensional spatial mesh.
694
    num_groups : int
695
        Number of energy groups.
696
    groupsets : List[Dict], default=[]
697
        Groupset definitions required by the common LBS problem interface. The
698
        uncollided calculation itself does not use an angular quadrature.
699
    xs_map : List[Dict], default=[]
700
        Block-to-cross-section mappings. Total cross sections are used for
701
        attenuation and are recorded in the output file for compatibility
702
        checking by the collided problem.
703
    boundary_conditions : List[Dict], default=[]
704
        Vacuum or reflecting boundary conditions, using the same ``name`` and
705
        ``type`` entries as ``DiscreteOrdinatesProblem``. Reflecting boundaries
706
        are represented with directly projected image sources and must be
707
        planar, mutually orthogonal symmetry planes without an opposing
708
        reflecting plane.
709
    point_sources : List[pyopensn.source.PointSource], default=[]
710
        Explicit point sources. At least one point source is required. For now,
711
        each source must lie strictly inside a single cell; sources exactly on
712
        faces, edges, or vertices are rejected.
713
    near_source : List[pyopensn.logvol.LogicalVolume]
714
        Near-source ray-traced region for each point source, in the same order
715
        as ``point_sources``. This list is required whenever point sources are
716
        provided. Its length must equal the number of point sources, and every
717
        point source must lie in its corresponding region.
718
    scattering_order : int, default=0
719
        Maximum spherical-harmonic order written to the HDF5 file. It must be
720
        at least the scattering order of the collided problem that consumes
721
        the file.
722
    Notes
723
    -----
724
    Only explicit point sources are supported by the uncollided generator.
725
    Each point source must lie strictly inside a single cell. Uncollided
726
    generation must run on a Cartesian two- or three-dimensional mesh with
727
    exactly one MPI rank.
728
    A volumetric source may be approximated externally by multiple weighted
729
    point sources, as in the Kobayashi benchmark example.
730

731
    The ``options`` and ``use_gpus`` parameters are inherited from the base
732
    class interface but have no effect on uncollided generation and should be
733
    omitted.
734

735
    The uncollided file generated by :class:`UncollidedSolver` is
736
    partition-independent and may be consumed by a serial or parallel collided
737
    calculation on the same mesh. The collided problem must use the same
738
    reflecting boundaries recorded in the file.
739
    Each reflected image source is ray traced to every finite-element volume
740
    quadrature point and projected directly into the spatial discretization.
741
    )");
742

743
  // discrete ordinate solver
744
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
1,013✔
745
                               LBSProblem>(
746
    slv,
747
    "DiscreteOrdinatesProblem",
748
    R"(
749
    Base class for discrete ordinates problems in Cartesian geometry.
750

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

755
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
756
    )"
757
  );
1,013✔
758
  do_problem.def(
2,026✔
759
    py::init(
1,013✔
760
      [](py::kwargs& params)
1,316✔
761
      {
762
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
1,316✔
763
      }
764
    ),
765
    R"(
766
    Construct a discrete ordinates problem with Cartesian geometry.
767

768
    Parameters
769
    ----------
770
    mesh : MeshContinuum
771
        The spatial mesh.
772
    num_groups : int
773
        The total number of energy groups.
774
    groupsets : List[Dict], default=[]
775
        A list of input parameter blocks, each block provides the iterative properties for a
776
        groupset. Each dictionary supports:
777
          - groups_from_to: List[int] (required)
778
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
779
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
780
              Handle to an angular quadrature.
781
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
782
              Angle aggregation method to use during sweeping.
783
          - angle_aggregation_num_subsets: int, default=1
784
              Number of angle subsets used for aggregation.
785
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
786
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
787
              Iterative method used for inner linear solves.
788
          - l_abs_tol: float, default=1.0e-6
789
              Inner linear solver absolute residual tolerance.
790
          - l_max_its: int, default=200
791
              Inner linear solver maximum iterations.
792
          - gmres_restart_interval: int, default=30
793
              GMRES restart interval, if GMRES is used.
794
          - allow_cycles: bool, default=True
795
              Whether cyclic dependencies are allowed in sweeps.
796
          - apply_wgdsa: bool, default=False
797
              Enable within-group DSA for this groupset.
798
          - wgdsa_l_abs_tol: float, default=1.0e-4
799
              WGDSA linear absolute tolerance.
800
          - wgdsa_l_max_its: int, default=30
801
              WGDSA maximum iterations.
802
          - wgdsa_verbose: bool, default=False
803
              Verbose WGDSA output.
804
          - wgdsa_petsc_options: str, default=''
805
              PETSc options string for the WGDSA solver.
806
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
807
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
808
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
809
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
810
              PETSc KSP/PC setup.
811
          - wgdsa_direct_solve_threshold: int, default=20000
812
              Maximum global WGDSA diffusion unknown count for the automatic direct solve.
813
          - apply_tgdsa: bool, default=False
814
              Enable two-grid DSA for this groupset.
815
          - tgdsa_l_abs_tol: float, default=1.0e-4
816
              TGDSA linear absolute tolerance.
817
          - tgdsa_l_max_its: int, default=30
818
              TGDSA maximum iterations.
819
          - tgdsa_verbose: bool, default=False
820
              Verbose TGDSA output.
821
          - tgdsa_petsc_options: str, default=''
822
              PETSc options string for the TGDSA solver.
823
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
824
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
825
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
826
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
827
              PETSc KSP/PC setup.
828
          - tgdsa_direct_solve_threshold: int, default=20000
829
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
830
    xs_map : List[Dict], default=[]
831
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
832
          - block_ids: List[int] (required)
833
              Mesh block IDs to associate with the cross section.
834
          - xs: pyopensn.xs.MultiGroupXS (required)
835
              Cross-section object to assign to the specified blocks.
836
    boundary_conditions: List[Dict], default=[]
837
        A list containing tables for each boundary specification. Each dictionary supports:
838
          - name: str (required)
839
              Boundary name that identifies the specific boundary.
840
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
841
              Boundary type specification.
842
          - group_strength: List[float], optional
843
              Required when ``type='isotropic'``. Isotropic strength per group.
844
          - start_time: float, optional
845
              Active start time for isotropic boundaries only. Defaults to -infinity.
846
          - end_time: float, optional
847
              Active end time for isotropic boundaries only. Defaults to infinity.
848
          - function: AngularFluxFunction, optional
849
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
850
              that returns incoming angular flux from group and direction.
851
          - time_function: AngularFluxTimeFunction, optional
852
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
853
              returns incoming angular flux from group, direction, and time.
854
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
855
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
856
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
857
        that callback.
858
    point_sources: List[pyopensn.source.PointSource], default=[]
859
        A list of point sources.
860
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
861
        A list of volumetric sources.
862
    options : Dict, default={}
863
        A block of optional configuration parameters, including:
864
          - max_mpi_message_size: int, default=32768
865
          - restart_writes_enabled: bool, default=False
866
              Enable restart dump writes for solvers that support restart output.
867
          - write_delayed_psi_to_restart: bool, default=True
868
              Include delayed sweep angular-flux buffers. Full continuation restarts require
869
              these buffers whenever the problem has delayed sweep angular state, including
870
              partitioned parallel, reflected-boundary, and cyclic-sweep cases. These buffers
871
              are optional when a steady-state restart is used only as a transient initial
872
              condition because the transient initialization can reconstruct angular state
873
              from the flux moments.
874
          - write_angular_flux_to_restart: bool, default=True
875
              Include stored angular fluxes in restart dumps when ``save_angular_flux=True``.
876
              This is required for continuing a time-dependent restart, but optional when a
877
              steady-state restart is used only as a transient initial condition.
878
          - read_restart_path: str, default=''
879
              File stem for reading a full restart. The number of MPI ranks and partitioned
880
              state layout must match the run that wrote the restart files.
881
          - read_initial_condition_path: str, default=''
882
              File stem for reading restart data as an initial condition. A steady-state
883
              restart may be used by ``TransientSolver`` through this option.
884
          - write_restart_path: str, default=''
885
              File stem for restart dump writes. OpenSn appends the MPI rank and
886
              ``.restart.h5`` to this stem.
887
          - write_restart_time_interval: int, default=0
888
            (must be 0 or >=30)
889
          - use_precursors: bool, default=True
890
            Enable delayed-neutron precursor treatment. This is treated as user intent and remains
891
            active across later ``SetXSMap`` calls, even if the current XS map temporarily contains
892
            no precursor-bearing material. When XS maps are swapped, existing precursor
893
            concentrations are remapped by cell and precursor-family index; new families start at
894
            zero and removed families are discarded. If any fissionable material in the active map
895
            has precursor data and ``use_precursors=True``, all fissionable materials in that map
896
            must have precursor data. Non-fissionable materials may have zero precursors.
897
          - use_source_moments: bool, default=False
898
          - save_angular_flux: bool, default=False
899
            Store angular flux state (`psi`) for transient mode, angular-flux
900
            field functions, and angular-flux I/O.
901
          - adjoint: bool, default=False
902
          - verbose_inner_iterations: bool, default=True
903
            Print inner iteration details, including WGS and AGS iterations.
904
          - verbose_outer_iterations: bool, default=True
905
            Print outer solver progress, including PI/NLKE iterations and transient steps.
906
          - max_ags_iterations: int, default=100
907
          - ags_tolerance: float, default=1.0e-6
908
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
909
          - power_default_kappa: float, default=3.20435e-11
910
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
911
          - field_function_prefix: str, default=''
912
        These options are applied at problem creation.
913
    sweep_type : str, default="AAH"
914
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
915
        Both sweep types support time-dependent (transient) mode.
916
    time_dependent : bool, default=False
917
        If true, the problem starts in time-dependent mode. Otherwise it starts in
918
        steady-state mode. Requires ``options.save_angular_flux=True``.
919
        Both ``AAH`` and ``CBC`` sweep types support time-dependent mode.
920
    uncollided_flux : str, default=""
921
        HDF5 file generated by :class:`UncollidedSolver`. For steady-state
922
        forward fixed-source calculations, OpenSn uses its moments to
923
        construct the first-collision scattering and fission source, solves
924
        for the collided component, and adds the uncollided moments back to
925
        the converged state. The file must match the current group count, mesh
926
        cell IDs, cell-node layout and coordinates, total cross sections, and
927
        reflecting boundary set. Its maximum moment order must be at least
928
        this problem's scattering order. The same serial file may be read by
929
        any MPI partitioning of the matching mesh.
930
    use_gpus : bool, default=False
931
        A flag specifying whether GPU acceleration is used for the sweep. Both
932
        ```AAH``` and ```CBC``` sweep types support GPU acceleration.
933
    )"
934
  );
935
  do_problem.def(
1,013✔
936
    "SetTimeDependentMode",
937
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
1,013✔
938
    R"(
939
    Set the problem to time-dependent mode.
940

941
    Notes
942
    -----
943
    Switch problem from steady-state to time-dependent mode. This updates problem
944
    internals (sweep chunk mode and source-function) while preserving user boundary
945
    conditions and fixed sources.
946

947
    Requires ``options.save_angular_flux=True`` at problem creation.
948
    )"
949
  );
950
  do_problem.def(
1,013✔
951
    "SetSteadyStateMode",
952
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
1,013✔
953
    R"(
954
    Set the problem to steady-state mode.
955

956
    Notes
957
    -----
958
    Switch problem from time-dependent to steady-state mode. This updates problem
959
    internals (sweep chunk mode and source-function) while preserving user boundary
960
    conditions and fixed sources.
961
    )"
962
  );
963
  do_problem.def(
1,013✔
964
    "IsTimeDependent",
965
    &DiscreteOrdinatesProblem::IsTimeDependent,
1,013✔
966
    R"(
967
    Return ``True`` if the problem is currently in time-dependent mode.
968
    )"
969
  );
970
  do_problem.def(
1,013✔
971
    "SetBoundaryOptions",
972
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
1,193✔
973
    {
974
      bool clear_boundary_conditions = false;
180✔
975
      std::vector<InputParameters> boundary_params;
180✔
976
      for (auto [key, value] : params)
716✔
977
      {
978
        auto c_key = key.cast<std::string>();
356✔
979
        if (c_key == "clear_boundary_conditions")
356✔
980
          clear_boundary_conditions = value.cast<bool>();
176✔
981
        else if (c_key == "boundary_conditions")
180✔
982
        {
983
          auto boundaries = value.cast<py::list>();
180✔
984
          for (auto boundary : boundaries)
968✔
985
          {
986
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
608✔
987
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
608✔
988
            boundary_params.push_back(std::move(input));
608✔
989
          }
608✔
990
        }
180✔
991
        else
992
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
993
      }
356✔
994
      if (clear_boundary_conditions or not boundary_params.empty())
180✔
995
        self.SetBoundaryOptions(boundary_params, clear_boundary_conditions);
180✔
996
    },
180✔
997
    R"(
998
    Set or clear boundary conditions.
999

1000
    Parameters
1001
    ----------
1002
    clear_boundary_conditions: bool, default=False
1003
        If true, all current boundary conditions are deleted.
1004
    boundary_conditions: List[Dict]
1005
        A list of boundary condition dictionaries. Each dictionary supports:
1006
          - name: str (required)
1007
              Boundary name that identifies the specific boundary.
1008
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1009
              Boundary type specification.
1010
          - group_strength: List[float], optional
1011
              Required when ``type='isotropic'``. Isotropic strength per group.
1012
          - start_time: float, optional
1013
              Active start time for isotropic boundaries only. Defaults to -infinity.
1014
          - end_time: float, optional
1015
              Active end time for isotropic boundaries only. Defaults to infinity.
1016
          - function: AngularFluxFunction, optional
1017
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1018
              that returns incoming angular flux from group and direction.
1019
          - time_function: AngularFluxTimeFunction, optional
1020
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1021
              returns incoming angular flux from group, direction, and time.
1022
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1023
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1024
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1025
        that callback.
1026

1027
    Notes
1028
    -----
1029
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
1030
    Reapply boundaries with this method before solving in the new mode.
1031
    )"
1032
  );
1033
  do_problem.def(
1,013✔
1034
    "ZeroPsi",
1035
    [](DiscreteOrdinatesProblem& self)
1,013✔
1036
    {
1037
      self.ZeroPsi();
×
1038
    },
1039
    R"(
1040
    Zero the angular-flux storage.
1041
    )"
1042
  );
1043
  do_problem.def(
1,013✔
1044
    "GetPsi",
1045
    [](DiscreteOrdinatesProblem& self)
1,061✔
1046
    {
1047
      const auto& psi = self.GetPsiNewLocal();
48✔
1048
      py::list psi_list;
48✔
1049
      for (const auto& vec : psi)
96✔
1050
      {
1051
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()));
48✔
1052
        std::copy(vec.begin(), vec.end(), static_cast<double*>(array.mutable_data()));
48✔
1053
        psi_list.append(array);
48✔
1054
      }
48✔
1055
      return psi_list;
48✔
1056
    },
×
1057
    R"(
1058
    Return psi as a list of NumPy arrays (float64).
1059

1060
    The arrays are copies of the current angular-flux state. Mutating them does not
1061
    mutate the problem.
1062
    )"
1063
  );
1064
  do_problem.def(
1,013✔
1065
    "GetAngularFieldFunctionList",
1066
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
1,013✔
1067
    {
1068
      std::vector<unsigned int> group_ids;
×
1069
      std::vector<size_t> angle_ids;
×
1070
      group_ids.reserve(groups.size());
×
1071
      angle_ids.reserve(angles.size());
×
1072

1073
      for (py::handle g : groups)
×
1074
        group_ids.push_back(g.cast<unsigned int>());
×
1075
      for (py::handle a : angles)
×
1076
        angle_ids.push_back(a.cast<size_t>());
×
1077

1078
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
1079
      py::list out;
×
1080
      for (const auto& ff : ff_list)
×
1081
        out.append(ff);
×
1082
      return out;
×
1083
    },
×
1084
    R"(
1085
    Create field functions for selected angular flux components.
1086

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

1091
    Example
1092
    -------
1093
    .. code-block:: python
1094

1095
       solver.Initialize()
1096
       solver.Execute()
1097
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1098

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

1104
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
1105

1106
    .. code-block:: python
1107

1108
       solver = TransientSolver(problem=phys)
1109
       solver.Initialize()
1110
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1111

1112
       def post_advance():
1113
           for ff in ang_ff:
1114
               ff.Update()
1115
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1116

1117
       solver.SetPostAdvanceCallback(post_advance)
1118
       solver.Execute()
1119

1120
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
1121

1122
    .. code-block:: python
1123

1124
       solver = TransientSolver(problem=phys)
1125
       solver.Initialize()
1126
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1127
       for _ in range(num_steps):
1128
           solver.Advance()
1129
           for ff in ang_ff:
1130
               ff.Update()
1131
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1132

1133
    Parameters
1134
    ----------
1135
    groups : List[int]
1136
        Global group indices to export.
1137
    angles : List[int]
1138
        Angle indices within the groupset quadrature for each group.
1139

1140
    Returns
1141
    -------
1142
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
1143
        Field functions for the requested ``(group, angle)`` pairs. Each returned field function
1144
        is a snapshot, but supports ``CanUpdate()`` and ``Update()`` while its owning problem is
1145
        alive.
1146
    )",
1147
    py::arg("groups"),
2,026✔
1148
    py::arg("angles")
1,013✔
1149
  );
1150
  do_problem.def(
1,013✔
1151
    "ComputeLeakage",
1152
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
1,436✔
1153
    {
1154
      auto grid = self.GetGrid();
423✔
1155
      // get the supported boundaries
1156
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
423✔
1157
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
423✔
1158
      const auto coord_sys = grid->GetCoordinateSystem();
423✔
1159
      const auto mesh_type = grid->GetType();
423✔
1160
      const auto dim = grid->GetDimension();
423✔
1161
      // get the boundaries to parse, preserving user order
1162
      std::vector<std::uint64_t> bndry_ids;
423✔
1163
      if (bnd_names.size() > 1)
423✔
1164
      {
1165
        for (py::handle name : bnd_names)
2,488✔
1166
        {
1167
          auto sname = name.cast<std::string>();
1,648✔
1168
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
1,648✔
1169
          {
1170
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24✔
1171
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
1172
                                       "' is invalid for cylindrical orthogonal meshes. "
1173
                                       "Use rmin, rmax, zmin, zmax.");
×
1174

1175
            if (mesh_type == MeshType::ORTHOGONAL)
24✔
1176
            {
1177
              if (sname == "rmin") sname = "xmin";
24✔
1178
              else if (sname == "rmax") sname = "xmax";
24✔
1179
              else if (sname == "zmin") sname = "ymin";
16✔
1180
              else if (sname == "zmax") sname = "ymax";
8✔
1181
            }
1182
          }
1183
          bndry_ids.push_back(allowed_bd_names.at(sname));
1,648✔
1184
        }
1,648✔
1185
      }
1186
      else
1187
      {
1188
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
9✔
1189
      }
1190
      // compute the leakage
1191
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
423✔
1192
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
846✔
1193
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
423✔
1194

1195
      auto ToRZName = [](const std::string& name)
447✔
1196
      {
1197
        if (name == "xmin") return std::string("rmin");
24✔
1198
        if (name == "xmax") return std::string("rmax");
24✔
1199
        if (name == "ymin") return std::string("zmin");
16✔
1200
        if (name == "ymax") return std::string("zmax");
8✔
1201
        return name;
×
1202
      };
1203

1204
      // convert result to native Python
1205
      py::dict result;
423✔
1206
      for (const auto& bndry_id : bndry_ids)
2,077✔
1207
      {
1208
        const auto it = leakage.find(bndry_id);
1,654✔
1209
        if (it == leakage.end())
1,654✔
1210
          continue;
×
1211
        // construct numpy array and copy contents
1212
        const auto& grp_wise_leakage = it->second;
1,654✔
1213
        py::array_t<double> np_vector(py::ssize_t(grp_wise_leakage.size()));
1,654✔
1214
        auto buffer = np_vector.request();
1,654✔
1215
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
1,654✔
1216
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
1,654✔
1217
        std::string name = allowed_bd_ids.at(bndry_id);
1,654✔
1218
        if (rz_ortho)
1,654✔
1219
          name = ToRZName(name);
24✔
1220
        result[py::str(name)] = std::move(np_vector);
3,308✔
1221
      }
1,654✔
1222

1223
      return result;
423✔
1224
    },
846✔
1225
    R"(
1226
    Compute leakage for the problem.
1227

1228
    Parameters
1229
    ----------
1230
    bnd_names : List[str]
1231
        A list of boundary names for which leakage should be computed.
1232

1233
    Returns
1234
    -------
1235
    Dict[str, numpy.ndarray]
1236
        A dictionary mapping boundary names to group-wise leakage vectors.
1237
        Each array contains the outgoing angular flux (per group) integrated over
1238
        the corresponding boundary surface.
1239

1240
    Raises
1241
    ------
1242
    RuntimeError
1243
        If `save_angular_flux` option was not enabled during problem setup.
1244

1245
    ValueError
1246
        If one or more boundary ids are not present on the current mesh.
1247
    )",
1248
    py::arg("bnd_names")
1,013✔
1249
  );
1250

1251
  // discrete ordinates curvilinear problem
1252
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
1,013✔
1253
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1254
                                           DiscreteOrdinatesProblem>(
1255
    slv,
1256
    "DiscreteOrdinatesCurvilinearProblem",
1257
    R"(
1258
    Base class for discrete ordinates problems in curvilinear geometry.
1259

1260
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1261
    )"
1262
  );
1,013✔
1263
  do_curvilinear_problem.def(
2,026✔
1264
    py::init(
1,013✔
1265
      [](py::kwargs& params)
80✔
1266
      {
1267
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
80✔
1268
      }
1269
    ),
1270
    R"(
1271
    Construct a discrete ordinates problem for curvilinear geometry.
1272

1273
    Warnings
1274
    --------
1275
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1276

1277
    Parameters
1278
    ----------
1279
    mesh : MeshContinuum
1280
        The spatial mesh.
1281
    coord_system : int
1282
        Coordinate system to use. Must be set to 2 (cylindrical coordinates).
1283
    num_groups : int
1284
        The total number of energy groups.
1285
    groupsets : list of dict
1286
        A list of input parameter blocks, each block provides the iterative properties for a
1287
        groupset. Each dictionary supports:
1288
          - groups_from_to: List[int] (required)
1289
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1290
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1291
              Handle to an angular quadrature.
1292
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
1293
              Angle aggregation method to use during sweeping.
1294
          - angle_aggregation_num_subsets: int, default=1
1295
              Number of angle subsets used for aggregation.
1296
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1297
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
1298
              Iterative method used for inner linear solves.
1299
          - l_abs_tol: float, default=1.0e-6
1300
              Inner linear solver absolute residual tolerance.
1301
          - l_max_its: int, default=200
1302
              Inner linear solver maximum iterations.
1303
          - gmres_restart_interval: int, default=30
1304
              GMRES restart interval, if GMRES is used.
1305
          - allow_cycles: bool, default=True
1306
              Whether cyclic dependencies are allowed in sweeps.
1307
          - apply_wgdsa: bool, default=False
1308
              Enable within-group DSA for this groupset.
1309
          - wgdsa_l_abs_tol: float, default=1.0e-4
1310
              WGDSA linear absolute tolerance.
1311
          - wgdsa_l_max_its: int, default=30
1312
              WGDSA maximum iterations.
1313
          - wgdsa_verbose: bool, default=False
1314
              Verbose WGDSA output.
1315
          - wgdsa_petsc_options: str, default=''
1316
              PETSc options string for the WGDSA solver.
1317
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1318
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1319
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1320
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
1321
              PETSc KSP/PC setup.
1322
          - wgdsa_direct_solve_threshold: int, default=20000
1323
              Maximum global WGDSA diffusion unknown count for the automatic direct solve.
1324
          - apply_tgdsa: bool, default=False
1325
              Enable two-grid DSA for this groupset.
1326
          - tgdsa_l_abs_tol: float, default=1.0e-4
1327
              TGDSA linear absolute tolerance.
1328
          - tgdsa_l_max_its: int, default=30
1329
              TGDSA maximum iterations.
1330
          - tgdsa_verbose: bool, default=False
1331
              Verbose TGDSA output.
1332
          - tgdsa_petsc_options: str, default=''
1333
              PETSc options string for the TGDSA solver.
1334
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1335
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1336
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1337
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
1338
              PETSc KSP/PC setup.
1339
          - tgdsa_direct_solve_threshold: int, default=20000
1340
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
1341
    xs_map : list of dict
1342
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1343
          - block_ids: List[int] (required)
1344
              Mesh block IDs to associate with the cross section.
1345
          - xs: pyopensn.xs.MultiGroupXS (required)
1346
              Cross-section object to assign to the specified blocks.
1347
    boundary_conditions: List[Dict], default=[]
1348
        A list containing tables for each boundary specification. Each dictionary supports:
1349
          - name: str (required)
1350
              Boundary name that identifies the specific boundary.
1351
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1352
              Boundary type specification.
1353
          - group_strength: List[float], optional
1354
              Required when ``type='isotropic'``. Isotropic strength per group.
1355
          - start_time: float, optional
1356
              Active start time for isotropic boundaries only. Defaults to -infinity.
1357
          - end_time: float, optional
1358
              Active end time for isotropic boundaries only. Defaults to infinity.
1359
          - function: AngularFluxFunction, optional
1360
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1361
              that returns incoming angular flux from group and direction.
1362
          - time_function: AngularFluxTimeFunction, optional
1363
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1364
              returns incoming angular flux from group, direction, and time.
1365
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1366
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1367
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1368
        that callback.
1369
    point_sources: List[pyopensn.source.PointSource], default=[]
1370
        A list of point sources.
1371
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1372
        A list of volumetric sources.
1373
    options : dict, optional
1374
        A block of optional configuration parameters applied at problem creation, including:
1375
          - max_mpi_message_size: int, default=32768
1376
          - restart_writes_enabled: bool, default=False
1377
              Enable restart dump writes for solvers that support restart output.
1378
          - write_delayed_psi_to_restart: bool, default=True
1379
              Include delayed sweep angular-flux buffers. Full continuation restarts require
1380
              these buffers whenever the problem has delayed sweep angular state, including
1381
              partitioned parallel, reflected-boundary, and cyclic-sweep cases. These buffers
1382
              are optional when a steady-state restart is used only as a transient initial
1383
              condition because the transient initialization can reconstruct angular state
1384
              from the flux moments.
1385
          - write_angular_flux_to_restart: bool, default=True
1386
              Include stored angular fluxes in restart dumps when ``save_angular_flux=True``.
1387
              This is required for continuing a time-dependent restart, but optional when a
1388
              steady-state restart is used only as a transient initial condition.
1389
          - read_restart_path: str, default=''
1390
              File stem for reading a full restart. The number of MPI ranks and partitioned
1391
              state layout must match the run that wrote the restart files.
1392
          - read_initial_condition_path: str, default=''
1393
              File stem for reading restart data as an initial condition. A steady-state
1394
              restart may be used by ``TransientSolver`` through this option.
1395
          - write_restart_path: str, default=''
1396
              File stem for restart dump writes. OpenSn appends the MPI rank and
1397
              ``.restart.h5`` to this stem.
1398
          - write_restart_time_interval: int, default=0
1399
          - use_precursors: bool, default=True
1400
            Enable delayed-neutron precursor treatment. This remains active across later
1401
            ``SetXSMap`` calls, even if the current map temporarily has no precursor-bearing
1402
            material. When XS maps change, precursor concentrations are remapped by cell and
1403
            precursor-family index; newly introduced families start at zero and removed families
1404
            are discarded.
1405
          - use_source_moments: bool, default=False
1406
          - save_angular_flux: bool, default=False
1407
            Store angular flux state (`psi`) for transient mode, angular-flux
1408
            field functions, and angular-flux I/O.
1409
          - verbose_inner_iterations: bool, default=True
1410
            Print inner iteration details, including WGS and AGS iterations.
1411
          - verbose_outer_iterations: bool, default=True
1412
            Print outer solver progress, including PI/NLKE iterations and transient steps.
1413
          - max_ags_iterations: int, default=100
1414
          - ags_tolerance: float, default=1.0e-6
1415
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1416
          - power_default_kappa: float, default=3.20435e-11
1417
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1418
          - field_function_prefix: str, default=''
1419
    sweep_type : str, optional
1420
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1421
        If ``time_dependent=True``, ``options.save_angular_flux=True`` is required.
1422
    )"
1423
  );
1424
}
1,013✔
1425

1426
// Wrap uncollided solver
1427
void
1428
WrapUncollidedSolver(py::module& slv)
1,013✔
1429
{
1430
  // clang-format off
1431
  auto uncollided_solver =
1,013✔
1432
    py::class_<UncollidedSolver, std::shared_ptr<UncollidedSolver>, Solver>(
1433
      slv,
1434
      "UncollidedSolver",
1435
      R"(
1436
    Generate and write uncollided flux moments for first-collision transport.
1437

1438
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1439
    )"
1440
  );
1,013✔
1441
  uncollided_solver.def(
2,026✔
1442
    py::init(
1,013✔
1443
      [](py::kwargs& params)
22✔
1444
      {
1445
        return UncollidedSolver::Create(kwargs_to_param_block(params));
22✔
1446
      }
1447
    ),
1448
    R"(
1449
    Construct an uncollided transport solver.
1450

1451
    Parameters
1452
    ----------
1453
    problem : pyopensn.solver.UncollidedProblem
1454
        Existing uncollided problem instance.
1455
    file_name : str
1456
        Output HDF5 file containing uncollided flux moments, mesh and total
1457
        cross-section metadata, and production/removal/outflow balance terms.
1458
    progress_interval : int, default=5
1459
        Percentage interval for source-point progress reports. Reports include
1460
        completed source points, elapsed time, and estimated remaining time.
1461
        Set to zero to disable progress reporting. Valid values are integers in
1462
        ``[0, 100]``.
1463

1464
    Notes
1465
    -----
1466
    :meth:`Initialize` requires exactly one MPI rank. The generated HDF5 file
1467
    may then be reused by a serial or parallel collided calculation on the
1468
    same mesh.
1469

1470
    Internal threading in the current uncollided implementation is capped by
1471
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1472
    the variable is unset or invalid.
1473
    )"
1474
  );
1475
  // clang-format on
1476
}
1,013✔
1477

1478
// Wrap steady-state solver
1479
void
1480
WrapSteadyState(py::module& slv)
1,013✔
1481
{
1482
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,043✔
1483
  {
1484
    py::dict values;
30✔
1485
    values["absorption_rate"] = table.absorption_rate;
30✔
1486
    values["production_rate"] = table.production_rate;
30✔
1487
    values["inflow_rate"] = table.inflow_rate;
30✔
1488
    values["outflow_rate"] = table.outflow_rate;
30✔
1489
    values["balance"] = table.balance;
30✔
1490
    if (table.initial_inventory.has_value())
30✔
1491
      values["initial_inventory"] = table.initial_inventory.value();
×
1492
    if (table.final_inventory.has_value())
30✔
1493
      values["final_inventory"] = table.final_inventory.value();
×
1494
    if (table.predicted_inventory_change.has_value())
30✔
1495
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1496
    if (table.actual_inventory_change.has_value())
30✔
1497
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1498
    if (table.inventory_residual.has_value())
30✔
1499
      values["inventory_residual"] = table.inventory_residual.value();
×
1500
    return values;
30✔
1501
  };
×
1502

1503
  // clang-format off
1504
  // steady state solver
1505
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,013✔
1506
                                        Solver>(
1507
    slv,
1508
    "SteadyStateSourceSolver",
1509
    R"(
1510
    Steady state solver.
1511

1512
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1513
    )"
1514
  );
1,013✔
1515
  steady_state_solver.def(
2,026✔
1516
    py::init(
1,013✔
1517
      [](py::kwargs& params)
716✔
1518
      {
1519
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
716✔
1520
      }
1521
    ),
1522
    R"(
1523
    Construct a steady state solver.
1524

1525
    Parameters
1526
    ----------
1527
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1528
        Existing discrete ordinates problem instance.
1529

1530
    Notes
1531
    -----
1532
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1533
    restart data is read during :meth:`Initialize`. If it was constructed with
1534
    ``options={'restart_writes_enabled': True, ...}``, a restart dump is written
1535
    after :meth:`Execute` completes.
1536

1537
    Restart files are rank-layout specific. A restart written with ``N`` MPI
1538
    ranks must be read with the same rank count and a compatible problem
1539
    definition.
1540
    )"
1541
  );
1542
  steady_state_solver.def(
1,013✔
1543
    "ComputeBalanceTable",
1544
    [BalanceTableToDict](const SteadyStateSourceSolver& self)
1,013✔
1545
    {
1546
      return BalanceTableToDict(self.ComputeBalanceTable());
30✔
1547
    },
1548
    R"(
1549
    Compute and return the global balance table using the solver's normalization.
1550
    This is a collective operation and must be called on all ranks.
1551

1552
    Returns
1553
    -------
1554
    dict
1555
        Dictionary with the following entries:
1556

1557
        - ``absorption_rate``:
1558
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1559
          groups and the full domain.
1560
        - ``production_rate``:
1561
          Global volumetric production/source rate used by the solver,
1562
          approximately ``integral Q dV`` summed over groups and the full domain.
1563
        - ``inflow_rate``:
1564
          Global incoming boundary contribution integrated over incoming
1565
          angular flux on boundaries.
1566
        - ``outflow_rate``:
1567
          Global outgoing boundary contribution accumulated from face outflow
1568
          tallies.
1569
        - ``balance``:
1570
          Rate balance,
1571
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1572

1573
    Notes
1574
    -----
1575
    This solver applies no extra normalization to the balance table.
1576
    )"
1577
  );
1578
  // clang-format on
1579
}
1,013✔
1580

1581
// Wrap transient solver
1582
void
1583
WrapTransient(py::module& slv)
1,013✔
1584
{
1585
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,013✔
1586
  {
1587
    py::dict values;
×
1588
    values["absorption_rate"] = table.absorption_rate;
×
1589
    values["production_rate"] = table.production_rate;
×
1590
    values["inflow_rate"] = table.inflow_rate;
×
1591
    values["outflow_rate"] = table.outflow_rate;
×
1592
    values["balance"] = table.balance;
×
1593
    if (table.initial_inventory.has_value())
×
1594
      values["initial_inventory"] = table.initial_inventory.value();
×
1595
    if (table.final_inventory.has_value())
×
1596
      values["final_inventory"] = table.final_inventory.value();
×
1597
    if (table.predicted_inventory_change.has_value())
×
1598
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1599
    if (table.actual_inventory_change.has_value())
×
1600
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1601
    if (table.inventory_residual.has_value())
×
1602
      values["inventory_residual"] = table.inventory_residual.value();
×
1603
    return values;
×
1604
  };
×
1605
  // clang-format off
1606
  auto transient_solver =
1,013✔
1607
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1608
      slv,
1609
      "TransientSolver",
1610
      R"(
1611
      Transient solver.
1612

1613
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1614
      )"
1615
    );
1,013✔
1616
  transient_solver.def(
2,026✔
1617
    py::init(
1,013✔
1618
      [](py::kwargs& params)
460✔
1619
      {
1620
        return TransientSolver::Create(kwargs_to_param_block(params));
460✔
1621
      }
1622
    ),
1623
    R"(
1624
    Construct a transient solver.
1625

1626
    Parameters
1627
    ----------
1628
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1629
        Existing discrete ordinates problem instance.
1630
    dt : float, optional, default=2.0e-3
1631
        Time step size used during the simulation.
1632
    stop_time : float, optional, default=0.1
1633
        Simulation end time.
1634
    theta : float, optional, default=0.5
1635
        Time differencing scheme parameter.
1636
    initial_state : str, optional, default="existing"
1637
        Initial state for the transient solve. Allowed values: existing, zero.
1638
        In "zero" mode, scalar flux vectors are reset to zero.
1639
    verbose : bool, optional, default=True
1640
        Enable verbose logging.
1641

1642
    Notes
1643
    -----
1644
    The associated problem must have ``save_angular_flux=True`` enabled. This
1645
    is required for transient problems.
1646

1647
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1648
    restart data is read during :meth:`Initialize`. If it was constructed with
1649
    ``options={'read_initial_condition_path': ...}``, restart data is read as a
1650
    transient initial condition during :meth:`Initialize`, then the problem is
1651
    switched to time-dependent mode; a steady-state restart may be used in this
1652
    mode. In this initial-condition path, stored angular fluxes and delayed
1653
    sweep angular-flux buffers are optional in the steady-state restart; the
1654
    transient solver reconstructs angular state from the loaded flux moments.
1655
    If it was constructed with
1656
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
1657
    be written during :meth:`Execute` and a final restart dump is written when
1658
    execution completes.
1659

1660
    A full transient continuation restart read with ``read_restart_path`` is
1661
    different from a steady-state initial-condition restart read with
1662
    ``read_initial_condition_path``. Full transient continuation restarts require
1663
    angular flux state in the restart file; if the problem has delayed sweep
1664
    angular state, including partitioned parallel, reflected-boundary, or
1665
    cyclic-sweep cases, continuation also requires delayed sweep angular-flux
1666
    buffers in the restart. Restart files are rank-layout specific: use the same MPI rank
1667
    count and compatible problem definition when reading files written by a
1668
    previous run.
1669
    )"
1670
  );
1671
  transient_solver.def(
1,013✔
1672
    "SetTimeStep",
1673
    &TransientSolver::SetTimeStep,
1,013✔
1674
    R"(
1675
    Set the timestep size used by :meth:`Advance`.
1676

1677
    Parameters
1678
    ----------
1679
    dt : float
1680
        New timestep size.
1681
    )");
1682
  transient_solver.def(
1,013✔
1683
    "SetTheta",
1684
    &TransientSolver::SetTheta,
1,013✔
1685
    R"(
1686
    Set the theta parameter used by :meth:`Advance`.
1687

1688
    Parameters
1689
    ----------
1690
    theta : float
1691
        Theta value between 1.0e-16 and 1.
1692
    )");
1693
  transient_solver.def(
1,013✔
1694
    "Advance",
1695
    &TransientSolver::Advance,
1,013✔
1696
    R"(
1697
    Advance the solver by a single timestep.
1698

1699
    Notes
1700
    -----
1701
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1702
    :meth:`Execute`.
1703
    )");
1704
  transient_solver.def(
1,013✔
1705
    "SetPreAdvanceCallback",
1706
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,013✔
1707
      &TransientSolver::SetPreAdvanceCallback),
1708
    R"(
1709
    Register a callback that runs before each advance within :meth:`Execute`.
1710

1711
    Parameters
1712
    ----------
1713
    callback : Optional[Callable[[], None]]
1714
        Function invoked before the solver advances a timestep. Pass None to clear.
1715
        If the callback modifies the timestep, the new value is used for the
1716
        upcoming step.
1717
    )");
1718
  transient_solver.def(
1,013✔
1719
    "SetPreAdvanceCallback",
1720
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
1,013✔
1721
      &TransientSolver::SetPreAdvanceCallback),
1722
    "Clear the PreAdvance callback by passing None.");
1723
  transient_solver.def(
1,013✔
1724
    "SetPostAdvanceCallback",
1725
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,013✔
1726
      &TransientSolver::SetPostAdvanceCallback),
1727
    R"(
1728
    Register a callback that runs after each advance within :meth:`Execute`.
1729

1730
    Parameters
1731
    ----------
1732
    callback : Optional[Callable[[], None]]
1733
        Function invoked after the solver advances a timestep. Pass None to clear.
1734
    )");
1735
  transient_solver.def(
1,013✔
1736
    "SetPostAdvanceCallback",
1737
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
1,013✔
1738
      &TransientSolver::SetPostAdvanceCallback),
1739
    "Clear the PostAdvance callback by passing None.");
1740
  transient_solver.def(
1,013✔
1741
    "ComputeBalanceTable",
1742
    [BalanceTableToDict](const TransientSolver& self)
1,013✔
1743
    {
1744
      return BalanceTableToDict(self.ComputeBalanceTable());
×
1745
    },
1746
    R"(
1747
    Compute and return the global balance table using the solver's normalization.
1748
    This is a collective operation and must be called on all ranks.
1749

1750
    Returns
1751
    -------
1752
    dict
1753
        Dictionary with the following entries:
1754

1755
        - ``absorption_rate``:
1756
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1757
          groups and the full domain.
1758
        - ``production_rate``:
1759
          Global volumetric production/source rate used by the solver,
1760
          approximately ``integral Q dV`` summed over groups and the full domain.
1761
        - ``inflow_rate``:
1762
          Global incoming boundary contribution integrated over incoming
1763
          angular flux on boundaries.
1764
        - ``outflow_rate``:
1765
          Global outgoing boundary contribution accumulated from face outflow
1766
          tallies.
1767
        - ``balance``:
1768
          Rate balance,
1769
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1770
        - ``initial_inventory``:
1771
          Total particle inventory at the start of the timestep, computed as
1772
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
1773
        - ``final_inventory``:
1774
          Total particle inventory at the end of the timestep, computed as
1775
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
1776
        - ``predicted_inventory_change``:
1777
          Inventory change predicted by the current timestep balance, computed as
1778
          ``dt * balance``.
1779
        - ``actual_inventory_change``:
1780
          Measured change in total particle inventory over the timestep, computed as
1781
          ``final_inventory - initial_inventory``.
1782
        - ``inventory_residual``:
1783
          Mismatch between the measured and predicted timestep inventory
1784
          changes, computed as
1785
          ``actual_inventory_change - predicted_inventory_change``.
1786

1787
    Notes
1788
    -----
1789
    This solver applies no extra normalization to the balance table.
1790

1791
    The transient inventory terms currently use the end-of-step rate balance to
1792
    estimate the timestep inventory change.
1793
    )"
1794
  );
1795
  slv.attr("BackwardEuler") = 1.0;
1,013✔
1796
  slv.attr("CrankNicolson") = 0.5;
2,026✔
1797
  // clang-format on
1798
}
1,013✔
1799

1800
// Wrap non-linear k-eigen solver
1801
void
1802
WrapNLKEigen(py::module& slv)
1,013✔
1803
{
1804
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,013✔
1805
  {
1806
    py::dict values;
×
1807
    values["absorption_rate"] = table.absorption_rate;
×
1808
    values["production_rate"] = table.production_rate;
×
1809
    values["inflow_rate"] = table.inflow_rate;
×
1810
    values["outflow_rate"] = table.outflow_rate;
×
1811
    values["balance"] = table.balance;
×
1812
    if (table.initial_inventory.has_value())
×
1813
      values["initial_inventory"] = table.initial_inventory.value();
×
1814
    if (table.final_inventory.has_value())
×
1815
      values["final_inventory"] = table.final_inventory.value();
×
1816
    if (table.predicted_inventory_change.has_value())
×
1817
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1818
    if (table.actual_inventory_change.has_value())
×
1819
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1820
    if (table.inventory_residual.has_value())
×
1821
      values["inventory_residual"] = table.inventory_residual.value();
×
1822
    return values;
×
1823
  };
×
1824
  // clang-format off
1825
  // non-linear k-eigen solver
1826
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
1,013✔
1827
                                              Solver>(
1828
    slv,
1829
    "NonLinearKEigenSolver",
1830
    R"(
1831
    Non-linear k-eigenvalue solver.
1832

1833
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1834

1835
    Supports one or more groupsets when the groupsets run without WGDSA/TGDSA.
1836
    If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen solve
1837
    must use a single groupset.
1838
    )"
1839
  );
1,013✔
1840
  non_linear_k_eigen_solver.def(
2,026✔
1841
    py::init(
1,013✔
1842
      [](py::kwargs& params)
45✔
1843
      {
1844
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
45✔
1845
      }
1846
        ),
1847
    R"(
1848
    Construct a non-linear k-eigenvalue solver.
1849

1850
    Parameters
1851
    ----------
1852
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1853
        Existing discrete ordinates problem instance.
1854
        Multiple groupsets are supported when groupset WGDSA/TGDSA is disabled.
1855
        If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen
1856
        solve must use a single groupset.
1857
    nl_abs_tol: float, default=1.0e-8
1858
        Non-linear absolute tolerance.
1859
    nl_rel_tol: float, default=1.0e-8
1860
        Non-linear relative tolerance.
1861
    nl_sol_tol: float, default=1.0e-50
1862
        Non-linear solution tolerance.
1863
    nl_max_its: int, default=50
1864
        Non-linear algorithm maximum iterations.
1865
    l_abs_tol: float, default=1.0e-8
1866
        Linear absolute tolerance.
1867
    l_rel_tol: float, default=1.0e-8
1868
        Linear relative tolerance.
1869
    l_div_tol: float, default=1.0e6
1870
        Linear divergence tolerance.
1871
    l_max_its: int, default=50
1872
        Linear algorithm maximum iterations.
1873
    l_gmres_restart_intvl: int, default=30
1874
        GMRES restart interval.
1875
    l_gmres_breakdown_tol: float, default=0.1
1876
        GMRES breakdown tolerance.
1877
    reset_phi0: bool, default=True
1878
        If true, reinitializes scalar fluxes to 1.0.
1879
    num_initial_power_iterations: int, default=0
1880
        Number of initial power iterations before the non-linear solve.
1881

1882
    Notes
1883
    -----
1884
    PETSc convergence failures and iteration limits are reported through the
1885
    solver status and log output, consistent with the other transport solvers.
1886
    Invalid residual states are reported to PETSc as function-domain
1887
    errors so SNES can backtrack or terminate with the appropriate reason.
1888
    )"
1889
  );
1890
  non_linear_k_eigen_solver.def(
1,013✔
1891
    "GetEigenvalue",
1892
    &NonLinearKEigenSolver::GetEigenvalue,
1,013✔
1893
    R"(
1894
    Return the current k-eigenvalue.
1895
    )"
1896
  );
1897
  non_linear_k_eigen_solver.def(
1,013✔
1898
    "ComputeBalanceTable",
1899
    [BalanceTableToDict](const NonLinearKEigenSolver& self)
1,013✔
1900
    {
1901
      return BalanceTableToDict(self.ComputeBalanceTable());
×
1902
    },
1903
    R"(
1904
    Compute and return the global balance table using the solver's normalization.
1905
    This is a collective operation and must be called on all ranks.
1906

1907
    Returns
1908
    -------
1909
    dict
1910
        Dictionary with the following entries:
1911

1912
        - ``absorption_rate``:
1913
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
1914
          groups and the full domain.
1915
        - ``production_rate``:
1916
          Global volumetric production/source rate used by the solver,
1917
          approximately ``integral Q dV`` summed over groups and the full domain.
1918
        - ``inflow_rate``:
1919
          Global incoming boundary contribution integrated over incoming
1920
          angular flux on boundaries.
1921
        - ``outflow_rate``:
1922
          Global outgoing boundary contribution accumulated from face outflow
1923
          tallies.
1924
        - ``balance``:
1925
          Rate balance,
1926
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
1927

1928
    Notes
1929
    -----
1930
    For k-eigenvalue balance reporting, this solver scales the production term by
1931
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1932
    )"
1933
  );
1934
  // clang-format on
1935
}
1,013✔
1936

1937
// Wrap power iteration solvers
1938
void
1939
WrapPIteration(py::module& slv)
1,013✔
1940
{
1941
  const auto BalanceTableToDict = [](const BalanceTable& table)
1,025✔
1942
  {
1943
    py::dict values;
12✔
1944
    values["absorption_rate"] = table.absorption_rate;
12✔
1945
    values["production_rate"] = table.production_rate;
12✔
1946
    values["inflow_rate"] = table.inflow_rate;
12✔
1947
    values["outflow_rate"] = table.outflow_rate;
12✔
1948
    values["balance"] = table.balance;
12✔
1949
    if (table.initial_inventory.has_value())
12✔
1950
      values["initial_inventory"] = table.initial_inventory.value();
×
1951
    if (table.final_inventory.has_value())
12✔
1952
      values["final_inventory"] = table.final_inventory.value();
×
1953
    if (table.predicted_inventory_change.has_value())
12✔
1954
      values["predicted_inventory_change"] = table.predicted_inventory_change.value();
×
1955
    if (table.actual_inventory_change.has_value())
12✔
1956
      values["actual_inventory_change"] = table.actual_inventory_change.value();
×
1957
    if (table.inventory_residual.has_value())
12✔
1958
      values["inventory_residual"] = table.inventory_residual.value();
×
1959
    return values;
12✔
1960
  };
×
1961
  // clang-format off
1962
  // power iteration k-eigen solver
1963
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
1,013✔
1964
                                      Solver>(
1965
    slv,
1966
    "PowerIterationKEigenSolver",
1967
    R"(
1968
    Power iteration k-eigenvalue solver.
1969

1970
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
1971
    )"
1972
  );
1,013✔
1973
  pi_k_eigen_solver.def(
2,026✔
1974
    py::init(
1,013✔
1975
      [](py::kwargs& params)
398✔
1976
      {
1977
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
398✔
1978
      }
1979
    ),
1980
    R"(
1981
    Construct a power iteration k-eigen solver.
1982

1983
    Parameters
1984
    ----------
1985
    problem: pyopensn.solver.DiscreteOrdinatesProblem
1986
        Existing DiscreteOrdinatesProblem instance.
1987
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
1988
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
1989
    max_iters: int, default = 1000
1990
        Maximum power iterations allowed.
1991
    k_tol: float, default = 1.0e-10
1992
        Tolerance on the k-eigenvalue.
1993
    reset_solution: bool, default=True
1994
        If true, initialize flux moments to 1.0.
1995
    reset_phi0: bool, default=True
1996
        If true, reinitializes scalar fluxes to 1.0.
1997

1998
    Notes
1999
    -----
2000
    If the problem was constructed with ``options={'read_restart_path': ...}``,
2001
    restart data is read during :meth:`Initialize`. If it was constructed with
2002
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
2003
    be written during the outer iteration loop and a final restart dump is
2004
    written when execution completes.
2005
    )"
2006
  );
2007
  pi_k_eigen_solver.def(
1,013✔
2008
    "GetEigenvalue",
2009
    &PowerIterationKEigenSolver::GetEigenvalue,
1,013✔
2010
    R"(
2011
    Return the current k-eigenvalue.
2012
    )"
2013
  );
2014
  pi_k_eigen_solver.def(
1,013✔
2015
    "GetNumPowerIterations",
2016
    &PowerIterationKEigenSolver::GetNumPowerIterations,
1,013✔
2017
    R"(
2018
    Return the number of completed power iterations.
2019
    )"
2020
  );
2021
  pi_k_eigen_solver.def(
1,013✔
2022
    "GetNumSweeps",
2023
    &PowerIterationKEigenSolver::GetNumSweeps,
1,013✔
2024
    R"(
2025
    Return the total number of transport sweeps applied by all WGS solvers.
2026
    )"
2027
  );
2028
  pi_k_eigen_solver.def(
1,013✔
2029
    "ComputeBalanceTable",
2030
    [BalanceTableToDict](const PowerIterationKEigenSolver& self)
1,013✔
2031
    {
2032
      return BalanceTableToDict(self.ComputeBalanceTable());
12✔
2033
    },
2034
    R"(
2035
    Compute and return the global balance table using the solver's normalization.
2036
    This is a collective operation and must be called on all ranks.
2037

2038
    Returns
2039
    -------
2040
    dict
2041
        Dictionary with the following entries:
2042

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

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

2068
// Wrap LBS solver
2069
void
2070
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
1,013✔
2071
{
2072
  // clang-format off
2073
  // discrete ordinates k-eigen acceleration base
2074
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
1,013✔
2075
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
2076
    slv,
2077
    "DiscreteOrdinatesKEigenAcceleration",
2078
    R"(
2079
    Base class for discrete ordinates k-eigenvalue acceleration methods.
2080

2081
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
2082
    )"
2083
  );
1,013✔
2084
  // SCDSA acceleration
2085
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
1,013✔
2086
                                       std::shared_ptr<SCDSAAcceleration>,
2087
                                       DiscreteOrdinatesKEigenAcceleration>(
2088
    slv,
2089
    "SCDSAAcceleration",
2090
    R"(
2091
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
2092

2093
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
2094
    )"
2095
  );
1,013✔
2096
  scdsa_acceleration.def(
2,026✔
2097
    py::init(
1,013✔
2098
      [](py::kwargs& params)
8✔
2099
      {
2100
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8✔
2101
      }
2102
    ),
2103
    R"(
2104
    SCDSA acceleration for the power iteration k-eigenvalue solver.
2105

2106
    Parameters
2107
    ----------
2108
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2109
        Existing DiscreteOrdinatesProblem instance.
2110
    l_abs_tol: float, default=1.0e-10
2111
        Absolute residual tolerance.
2112
    max_iters: int, default=100
2113
        Maximum allowable iterations.
2114
    verbose: bool, default=False
2115
        If true, enables verbose output.
2116
    petsc_options: str, default="ssss"
2117
        Additional PETSc options.
2118
    pi_max_its: int, default=50
2119
        Maximum allowable iterations for inner power iterations.
2120
    pi_k_tol: float, default=1.0e-10
2121
        k-eigenvalue tolerance for the inner power iterations.
2122
    sdm: str, default="pwld"
2123
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2124
            - 'pwld' : Piecewise Linear Discontinuous
2125
            - 'pwlc' : Piecewise Linear Continuous
2126
    )"
2127
  );
2128
  // SMM acceleration
2129
  auto smm_acceleration = py::class_<SMMAcceleration,
1,013✔
2130
                                     std::shared_ptr<SMMAcceleration>,
2131
                                     DiscreteOrdinatesKEigenAcceleration>(
2132
    slv,
2133
    "SMMAcceleration",
2134
    R"(
2135
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
2136

2137
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
2138
    )"
2139
  );
1,013✔
2140
  smm_acceleration.def(
2,026✔
2141
    py::init(
1,013✔
2142
      [](py::kwargs& params)
4✔
2143
      {
2144
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4✔
2145
      }
2146
    ),
2147
    R"(
2148
    SMM acceleration for the power iteration k-eigenvalue solver.
2149

2150
    Warnings
2151
    --------
2152
       SMM acceleration is **experimental** and should be used with caution!
2153
       SMM acceleration only supports problems with isotropic scattering.
2154

2155
    Parameters
2156
    ----------
2157
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2158
        Existing DiscreteOrdinatesProblem instance.
2159
    l_abs_tol: float, default=1.0e-10
2160
        Absolute residual tolerance.
2161
    max_iters: int, default=100
2162
        Maximum allowable iterations.
2163
    verbose: bool, default=False
2164
        If true, enables verbose output.
2165
    petsc_options: str, default="ssss"
2166
        Additional PETSc options.
2167
    pi_max_its: int, default=50
2168
        Maximum allowable iterations for inner power iterations.
2169
    pi_k_tol: float, default=1.0e-10
2170
        k-eigenvalue tolerance for the inner power iterations.
2171
    sdm: str, default="pwld"
2172
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2173
            - 'pwld' : Piecewise Linear Discontinuous
2174
            - 'pwlc' : Piecewise Linear Continuous
2175
    )"
2176
  );
2177
  // CMFD acceleration
2178
  auto cmfd_acceleration = py::class_<CMFDAcceleration,
1,013✔
2179
                                      std::shared_ptr<CMFDAcceleration>,
2180
                                      DiscreteOrdinatesKEigenAcceleration>(
2181
    slv,
2182
    "CMFDAcceleration",
2183
    R"(
2184
    Construct a CMFD accelerator for the power iteration k-eigenvalue solver.
2185

2186
    Wrapper of :cpp:class:`opensn::CMFDAcceleration`.
2187
    )"
2188
  );
1,013✔
2189
  cmfd_acceleration.def(
2,026✔
2190
    py::init(
1,013✔
2191
      [](py::kwargs& params)
86✔
2192
      {
2193
        return CMFDAcceleration::Create(kwargs_to_param_block(params));
86✔
2194
      }
2195
    ),
2196
    R"(
2197
    CMFD acceleration for the power iteration k-eigenvalue solver.
2198

2199
    With CMFD, each power iteration performs a configured number of high-order WGS
2200
    transport update iterations, then applies a bounded low-order scalar-flux correction.
2201
    The defaults use one WGS update iteration, automatic current closure, fixed
2202
    correction relaxation, and a transport-current balance gate before outer power
2203
    iteration is allowed to converge.
2204

2205
    Most users should tune only the common controls: ``coarse_mesh``, ``current_closure``,
2206
    ``aggregation_size``, ``group_aggregation_size``, ``relaxation``,
2207
    ``update_wgs_max_its``, ``update_wgs_abs_tol``, and
2208
    ``balance_residual_tolerance``. Parameters marked "developer/debug" are exposed for
2209
    investigations and regression testing, but normally should be left at their defaults.
2210

2211
    Parameters
2212
    ----------
2213
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2214
        Existing DiscreteOrdinatesProblem instance.
2215
    coarse_mesh: str, default="local_aggregation"
2216
        Common option. Coarse-mesh construction method. Valid choices are:
2217
            - 'identity' : one CMFD coarse cell per transport cell
2218
            - 'local_aggregation' : connected same-block fine cells aggregated locally
2219
            - 'global_aggregation' : connected same-block fine cells aggregated across MPI ranks
2220
        ``"local_aggregation"`` is the conservative default. ``"global_aggregation"`` can
2221
        reduce the coarse problem size on larger distributed meshes. Global aggregation is a
2222
        logical CMFD coarse-space construction: it does not repartition the transport mesh or
2223
        change fine-cell ownership. Each global coarse cell has one owning rank, while ranks
2224
        owning member fine cells keep membership records for restriction and prolongation.
2225
        Aggregates are connected by face adjacency, remain within one mesh block, and may be
2226
        smaller than ``aggregation_size`` near boundaries or disconnected regions. ``"identity"``
2227
        is primarily for debugging and method comparisons.
2228
    current_closure: str, default="auto"
2229
        Common option. CMFD face-current closure. Valid choices are:
2230
            - 'auto' : choose net, partial, or a blend from early coarse-balance behavior
2231
            - 'net' : match the signed transport current across each coarse face
2232
            - 'partial' : build face coupling from outgoing partial currents on both sides
2233
        ``"auto"`` is recommended for production use. A fixed closure is useful when
2234
        comparing methods, reproducing a benchmark setting, or diagnosing a case where
2235
        automatic selection is not robust.
2236
    aggregation_size: int, default=32
2237
        Common option. Target number of fine cells per aggregated coarse cell for
2238
        ``coarse_mesh="local_aggregation"`` or ``coarse_mesh="global_aggregation"``.
2239
        Larger values reduce CMFD matrix size and
2240
        setup/solve cost, but make the spatial correction less detailed. Smaller values
2241
        increase the coarse-system cost but may improve robustness.
2242
    group_aggregation_size: int, default=1
2243
        Common option. Number of transport energy groups per CMFD coarse group, not the
2244
        final number of coarse groups. A value of 1 preserves the full transport group
2245
        structure in the low-order system. To target ``N`` total coarse groups, use
2246
        ``(num_groups + N - 1) // N``.
2247
    relaxation: float, default=0.5
2248
        Common option. Relaxation factor applied to the CMFD scalar-flux correction.
2249
        This is the requested correction strength. The correction limiter may damp or
2250
        skip an individual correction if the requested update would produce an invalid
2251
        k-eigenvalue, non-finite flux, or excessive negative scalar flux.
2252
    update_wgs_max_its: int, default=1
2253
        Common option. Maximum WGS iterations used before each CMFD correction. The
2254
        default performs one transport update iteration per power iteration; larger
2255
        values make each transport update more accurate but more expensive.
2256
    update_wgs_abs_tol: float, default=1.0e-12
2257
        Common option. WGS absolute tolerance used before each CMFD correction. When
2258
        ``update_wgs_max_its=1``, this tolerance is normally not the stopping criterion
2259
        because only one WGS iteration is allowed. It matters when more WGS iterations
2260
        are allowed.
2261
    balance_residual_tolerance: float, default=1.0e-6
2262
        Common option. Restricted transport-current balance residual tolerance required
2263
        before CMFD permits outer power-iteration convergence. This prevents false
2264
        convergence when ``k_eff_change`` is small but the CMFD-restricted transport
2265
        balance is still inconsistent. This is a consistency guard, not an estimate of
2266
        the k-eigenvalue error. For large 3D cases it may need to be looser than the
2267
        outer ``k_tol``; choose it tight enough to prevent false convergence but not so
2268
        tight that it forces unnecessary asymptotic balance iterations.
2269
    l_abs_tol: float, default=1.0e-7
2270
        Developer/debug. Absolute residual tolerance for each CMFD coarse linear solve.
2271
        This affects the low-order linear solve, not the outer transport convergence.
2272
    max_iters: int, default=100
2273
        Developer/debug. Maximum iterations for each CMFD coarse linear solve. Increase
2274
        only if the coarse KSP solve is reaching its iteration limit. CMFD skips a
2275
        correction when any coarse linear solve fails to converge within this limit. If
2276
        the log reports ``correction = skipped (coarse_linear_solve_not_converged)``,
2277
        use a direct coarse solve for modest coarse systems, increase this limit for
2278
        iterative solves, or provide stronger ``petsc_options``.
2279
    verbose: bool, default=False
2280
        Developer/debug. If true, prints CMFD diagnostic and timing metrics. These are
2281
        useful for regression tests and performance studies but can be noisy in production.
2282
    petsc_options: str, default=""
2283
        Developer/debug. Additional PETSc options for the CMFD coarse solver. Used only
2284
        for solver experiments, especially with ``coarse_solver_policy="petsc_options"``.
2285
    pi_max_its: int, default=50
2286
        Developer/debug. Maximum inner power iterations for the CMFD coarse k solve.
2287
        Increase only if the coarse k solve is not converging enough to give useful
2288
        corrections.
2289
    pi_k_tol: float, default=1.0e-8
2290
        Developer/debug. k-eigenvalue tolerance for CMFD coarse power iterations. This
2291
        is separate from the outer ``PowerIterationKEigenSolver`` k tolerance.
2292
    correction_max_attempts: int, default=10
2293
        Developer/debug. Maximum CMFD correction damping attempts before skipping the
2294
        correction for the current transport update. Each failed attempt halves the
2295
        damping. If the log reports ``correction = skipped (negative_flux_guard)``,
2296
        first reduce ``relaxation`` or use finer spatial/energy aggregation; changing
2297
        this option is mainly for diagnostics.
2298
    correction_min_damping: float, default=1.0e-4
2299
        Developer/debug. Minimum CMFD correction damping factor considered during correction
2300
        limiting. If no admissible correction is found above this damping, CMFD skips
2301
        the correction for that transport update.
2302
    negative_flux_tolerance: float, default=1.0e-6
2303
        Developer/debug. Allowed scalar-flux undershoot for accepting a CMFD correction.
2304
        Corrections producing scalar flux below this guard are damped or skipped. Raising
2305
        this value can hide unstable corrections and should be accompanied by final-k and
2306
        scalar-flux checks.
2307
    inactive_iterations: int, default=0
2308
        Developer/debug. Number of initial power iterations before applying CMFD
2309
        corrections. Transport update controls are still used during these
2310
        iterations.
2311
    coarse_solver_policy: str, default="auto"
2312
        Developer/debug. Coarse solver policy. Valid choices are:
2313
            - 'auto' : PETSc preonly+LU below ``coarse_direct_solve_threshold``
2314
              global unknowns, GMRES+Jacobi otherwise
2315
            - 'direct' : PETSc preonly+LU
2316
            - 'iterative' : GMRES+Jacobi
2317
            - 'petsc_options' : allow ``petsc_options`` to override the PETSc KSP/PC setup
2318
        CMFD corrections from unconverged coarse linear solves are always skipped.
2319
        The skipped correction uses the unaccelerated transport update for that power
2320
        iteration; after repeated skipped corrections, CMFD returns the raw transport
2321
        k update so that power iteration can continue to move.
2322
    coarse_direct_solve_threshold: int, default=20000
2323
        Developer/debug. Maximum global CMFD unknown count for automatic direct coarse
2324
        solves when ``coarse_solver_policy="auto"``. Larger values make ``"auto"`` use
2325
        direct solves for larger coarse systems; choose values based on available host
2326
        memory and acceptable factorization cost.
2327
    )"
2328
  );
2329
  // clang-format on
2330
}
1,013✔
2331

2332
// Wrap the solver components of OpenSn
2333
void
2334
py_solver(py::module& pyopensn)
74✔
2335
{
2336
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
74✔
2337
  WrapProblem(slv);
74✔
2338
  WrapSolver(slv);
74✔
2339
  WrapLBS(slv);
74✔
2340
  WrapUncollidedSolver(slv);
74✔
2341
  WrapSteadyState(slv);
74✔
2342
  WrapTransient(slv);
74✔
2343
  WrapNLKEigen(slv);
74✔
2344
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
74✔
2345
  WrapPIteration(slv);
74✔
2346
}
74✔
2347

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

© 2026 Coveralls, Inc