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

Open-Sn / opensn / 28425945164

29 Jun 2026 07:06PM UTC coverage: 78.575% (-0.009%) from 78.584%
28425945164

push

github

web-flow
Merge pull request #1111 from wdhawkins/angle_subset_fixes

Updating uncollided regession/tutorials to remove angle_aggregation_num_subsets

26267 of 33429 relevant lines covered (78.58%)

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

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

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

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

89
// Wrap LBS solver
90
void
91
WrapLBS(py::module& slv)
1,004✔
92
{
93
  // clang-format off
94
  // LBS problem
95
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
1,004✔
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,004✔
104
  lbs_problem.def(
1,004✔
105
    "GetScalarFluxFieldFunction",
106
    [](LBSProblem& self, bool only_scalar_flux)
1,004✔
107
    {
108
      py::list field_function_list_per_group;
701✔
109
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
25,374✔
110
      {
111
        if (only_scalar_flux)
24,673✔
112
        {
113
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
31,382✔
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;
701✔
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,004✔
163
  );
164
  lbs_problem.def(
2,008✔
165
    "CreateFieldFunction",
166
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
2,008✔
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,008✔
202
    py::arg("xs_name"),
1,004✔
203
    py::arg("power_normalization_target") = -1.0
1,004✔
204
  );
205
  lbs_problem.def(
1,004✔
206
    "GetTime",
207
    &LBSProblem::GetTime,
1,004✔
208
    R"(
209
    Get the current simulation time in seconds.
210
    )"
211
  );
212
  lbs_problem.def(
1,004✔
213
    "GetTimeStep",
214
    &LBSProblem::GetTimeStep,
1,004✔
215
    R"(
216
    Get the current timestep size.
217
    )"
218
  );
219
  lbs_problem.def(
1,004✔
220
    "ComputeFissionRate",
221
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,004✔
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,004✔
259
  );
260
  lbs_problem.def(
1,004✔
261
    "ComputeFissionProduction",
262
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
2,544✔
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,004✔
300
  );
301
  lbs_problem.def(
1,004✔
302
    "GetPhiOldLocal",
303
    [](LBSProblem& self)
1,100✔
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,004✔
317
    "GetPhiNewLocal",
318
    [](LBSProblem& self)
1,624✔
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,004✔
332
    "WriteFluxMoments",
333
    [](LBSProblem& self, const std::string& file_base)
1,036✔
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,004✔
346
  );
347
  lbs_problem.def(
1,004✔
348
    "CreateAndWriteSourceMoments",
349
    [](LBSProblem& self, const std::string& file_base)
1,008✔
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,004✔
363
  );
364
  lbs_problem.def(
1,004✔
365
    "ReadFluxMomentsAndMakeSourceMoments",
366
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,004✔
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,008✔
388
    py::arg("single_file_flag")
1,004✔
389
  );
390
  lbs_problem.def(
1,004✔
391
    "ReadSourceMoments",
392
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,008✔
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,008✔
409
    py::arg("single_file_flag")
1,004✔
410
  );
411
  lbs_problem.def(
1,004✔
412
    "ReadFluxMoments",
413
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,004✔
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,008✔
428
    py::arg("single_file_flag")
1,004✔
429
  );
430
  lbs_problem.def(
1,004✔
431
    "WriteAngularFluxes",
432
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,008✔
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,004✔
445
  );
446
  lbs_problem.def(
1,004✔
447
    "ReadAngularFluxes",
448
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,008✔
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,004✔
461
  );
462
  lbs_problem.def(
1,004✔
463
    "SetPointSources",
464
    [](LBSProblem& self, py::kwargs& params)
1,004✔
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,004✔
495
    "SetVolumetricSources",
496
    [](LBSProblem& self, py::kwargs& params)
1,052✔
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,004✔
527
    "SetXSMap",
528
    [](LBSProblem& self, py::kwargs& params)
1,356✔
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,004✔
594
    "ZeroPhi",
595
    [](LBSProblem& self)
1,196✔
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,004✔
604
    "SetAdjoint",
605
    [](LBSProblem& self, bool adjoint)
1,004✔
606
    {
607
      self.SetAdjoint(adjoint);
24✔
608
    },
609
    py::arg("adjoint") = true,
1,004✔
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,004✔
646
    "SetForward",
647
    &LBSProblem::SetForward,
1,004✔
648
    R"(
649
    Set forward transport mode.
650

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

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

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

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

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

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

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

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

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

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

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

1089
    Example
1090
    -------
1091
    .. code-block:: python
1092

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

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

1102
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
1103

1104
    .. code-block:: python
1105

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

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

1115
       solver.SetPostAdvanceCallback(post_advance)
1116
       solver.Execute()
1117

1118
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
1119

1120
    .. code-block:: python
1121

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

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

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

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

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

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

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

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

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

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

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

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

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

1271
    Warnings
1272
    --------
1273
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1274

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

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

1434
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1435
    )"
1436
  );
1,004✔
1437
  uncollided_solver.def(
2,008✔
1438
    py::init(
1,004✔
1439
      [](py::kwargs& params)
21✔
1440
      {
1441
        return UncollidedSolver::Create(kwargs_to_param_block(params));
21✔
1442
      }
1443
    ),
1444
    R"(
1445
    Construct an uncollided transport solver.
1446

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

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

1466
    Internal threading in the current uncollided implementation is capped by
1467
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1468
    the variable is unset or invalid.
1469
    )"
1470
  );
1471
  // clang-format on
1472
}
1,004✔
1473

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

1499
  // clang-format off
1500
  // steady state solver
1501
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,004✔
1502
                                        Solver>(
1503
    slv,
1504
    "SteadyStateSourceSolver",
1505
    R"(
1506
    Steady state solver.
1507

1508
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1509
    )"
1510
  );
1,004✔
1511
  steady_state_solver.def(
2,008✔
1512
    py::init(
1,004✔
1513
      [](py::kwargs& params)
708✔
1514
      {
1515
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
708✔
1516
      }
1517
    ),
1518
    R"(
1519
    Construct a steady state solver.
1520

1521
    Parameters
1522
    ----------
1523
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1524
        Existing discrete ordinates problem instance.
1525

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

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

1548
    Returns
1549
    -------
1550
    dict
1551
        Dictionary with the following entries:
1552

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

1569
    Notes
1570
    -----
1571
    This solver applies no extra normalization to the balance table.
1572
    )"
1573
  );
1574
  // clang-format on
1575
}
1,004✔
1576

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

1609
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1610
      )"
1611
    );
1,004✔
1612
  transient_solver.def(
2,008✔
1613
    py::init(
1,004✔
1614
      [](py::kwargs& params)
460✔
1615
      {
1616
        return TransientSolver::Create(kwargs_to_param_block(params));
460✔
1617
      }
1618
    ),
1619
    R"(
1620
    Construct a transient solver.
1621

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

1638
    Notes
1639
    -----
1640
    The associated problem must have ``save_angular_flux=True`` enabled. This
1641
    is required for transient problems.
1642

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

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

1673
    Parameters
1674
    ----------
1675
    dt : float
1676
        New timestep size.
1677
    )");
1678
  transient_solver.def(
1,004✔
1679
    "SetTheta",
1680
    &TransientSolver::SetTheta,
1,004✔
1681
    R"(
1682
    Set the theta parameter used by :meth:`Advance`.
1683

1684
    Parameters
1685
    ----------
1686
    theta : float
1687
        Theta value between 1.0e-16 and 1.
1688
    )");
1689
  transient_solver.def(
1,004✔
1690
    "Advance",
1691
    &TransientSolver::Advance,
1,004✔
1692
    R"(
1693
    Advance the solver by a single timestep.
1694

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

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

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

1746
    Returns
1747
    -------
1748
    dict
1749
        Dictionary with the following entries:
1750

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

1783
    Notes
1784
    -----
1785
    This solver applies no extra normalization to the balance table.
1786

1787
    The transient inventory terms currently use the end-of-step rate balance to
1788
    estimate the timestep inventory change.
1789
    )"
1790
  );
1791
  slv.attr("BackwardEuler") = 1.0;
1,004✔
1792
  slv.attr("CrankNicolson") = 0.5;
2,008✔
1793
  // clang-format on
1794
}
1,004✔
1795

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

1829
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1830

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

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

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

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

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

1924
    Notes
1925
    -----
1926
    For k-eigenvalue balance reporting, this solver scales the production term by
1927
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
1928
    )"
1929
  );
1930
  // clang-format on
1931
}
1,004✔
1932

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

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

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

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

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

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

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

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

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

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

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

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

2146
    Warnings
2147
    --------
2148
       SMM acceleration is **experimental** and should be used with caution!
2149
       SMM acceleration only supports problems with isotropic scattering.
2150

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

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

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

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

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

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

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