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

Open-Sn / opensn / 42359882336

06 Nov 2026 11:57PM UTC coverage: 82.498% (+0.1%) from 82.355%
42359882336

push

github

web-flow
Merge pull request #1220 from wdhawkins/csda_wgdsa

Add support for WGDSA to CSDA problems.

129 of 131 new or added lines in 4 files covered. (98.47%)

155 existing lines in 7 files now uncovered.

32247 of 39088 relevant lines covered (82.5%)

82093451.16 hits per line

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

93.65
/python/lib/solver.cc
1
// SPDX-FileCopyrightText: 2025 The OpenSn Authors <https://open-sn.github.io/opensn/>
2
// SPDX-License-Identifier: MIT
3

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

36
namespace opensn
37
{
38

39
namespace
40
{
41

42
/// Converts a balance table to the dictionary returned by ComputeBalanceTable.
43
py::dict
44
BalanceTableToDict(const BalanceTable& table)
749 ✔
45
{
46
  py::dict values;
749 ✔
47
  values["absorption_rate"] = table.absorption_rate;
749 ✔
48
  values["production_rate"] = table.production_rate;
749 ✔
49
  values["inflow_rate"] = table.inflow_rate;
749 ✔
50
  values["outflow_rate"] = table.outflow_rate;
749 ✔
51
  // The standard balance omits CSDA deposition, so it is misleading for CSDA runs.
52
  if (not table.csda_particle_balance.has_value())
749 ✔
53
    values["balance"] = table.balance;
1,156 ✔
54
  if (table.csda_particle_deposition_rate.has_value())
749 ✔
55
    values["csda_particle_deposition_rate"] = table.csda_particle_deposition_rate.value();
342 ✔
56
  if (table.csda_particle_balance.has_value())
749 ✔
57
    values["csda_particle_balance"] = table.csda_particle_balance.value();
342 ✔
58
  if (table.csda_energy_production_rate.has_value())
749 ✔
59
    values["csda_energy_production_rate"] = table.csda_energy_production_rate.value();
342 ✔
60
  if (table.csda_energy_inflow_rate.has_value())
749 ✔
61
    values["csda_energy_inflow_rate"] = table.csda_energy_inflow_rate.value();
342 ✔
62
  if (table.csda_energy_outflow_rate.has_value())
749 ✔
63
    values["csda_energy_outflow_rate"] = table.csda_energy_outflow_rate.value();
342 ✔
64
  if (table.csda_energy_balance.has_value())
749 ✔
65
    values["csda_energy_balance"] = table.csda_energy_balance.value();
342 ✔
66
  if (table.initial_inventory.has_value())
749 ✔
67
    values["initial_inventory"] = table.initial_inventory.value();
432 ✔
68
  if (table.final_inventory.has_value())
749 ✔
69
    values["final_inventory"] = table.final_inventory.value();
432 ✔
70
  if (table.predicted_inventory_change.has_value())
749 ✔
71
    values["predicted_inventory_change"] = table.predicted_inventory_change.value();
432 ✔
72
  if (table.actual_inventory_change.has_value())
749 ✔
73
    values["actual_inventory_change"] = table.actual_inventory_change.value();
432 ✔
74
  if (table.inventory_residual.has_value())
749 ✔
75
    values["inventory_residual"] = table.inventory_residual.value();
432 ✔
76
  return values;
749 ✔
77
}
×
78

79
} // namespace
80

81
// Wrap problem
82
void
83
WrapProblem(py::module& slv)
1,523 ✔
84
{
85
  // clang-format off
86
  // problem base
87
  auto problem = py::class_<Problem, std::shared_ptr<Problem>>(
1,523 ✔
88
    slv,
89
    "Problem",
90
    R"(
91
    Base class for all problems.
92

93
    Wrapper of :cpp:class:`opensn::Problem`.
94
    )"
95
  );
1,523 ✔
96
  // clang-format on
97
}
1,523 ✔
98

99
// Wrap solver
100
void
101
WrapSolver(py::module& slv)
1,523 ✔
102
{
103
  // clang-format off
104
  // solver base
105
  auto solver = py::class_<Solver, std::shared_ptr<Solver> >(
1,523 ✔
106
    slv,
107
    "Solver",
108
    R"(
109
    Base class for all solvers.
110

111
    Wrapper of :cpp:class:`opensn::Solver`.
112
    )"
113
  );
1,523 ✔
114
  solver.def(
1,523 ✔
115
    "Initialize",
116
    &Solver::Initialize,
1,523 ✔
117
    R"(
118
    Initialize the solver.
119

120
    Solvers check that the problem is in a supported state (for example, steady-state mode
121
    without external sources for the k-eigenvalue solvers) and raise ``ValueError``
122
    otherwise. Setup and post-setup validation must both succeed. Calling ``Initialize``
123
    again clears the previous initialized state first.
124

125
    For MPI transport solvers, this is a collective operation and must be called on all ranks.
126
    )"
127
  );
128
  solver.def(
1,523 ✔
129
    "Execute",
130
    &Solver::Execute,
1,523 ✔
131
    R"(
132
    Execute the solver.
133

134
    The problem's current state and the solver's requirements are checked first, so changes
135
    made to the problem after ``Initialize`` (for example a mode switch or ``SetXSMap``) are
136
    validated before they are used. ``Initialize`` must be called before ``Execute``.
137

138
    For MPI transport solvers, this is a collective operation and must be called on all ranks.
139
    )"
140
  );
141
  solver.def(
1,523 ✔
142
    "Advance",
143
    &Solver::Advance,
1,523 ✔
144
    R"(
145
    Advance the solver by one step.
146

147
    Changes made to the problem since the last step are checked as in ``Execute``.
148
    ``Initialize`` must be called first. Solver classes that do not implement stepping
149
    leave the state unchanged and log that ``Advance`` is unsupported.
150

151
    For MPI transport solvers, this is a collective operation and must be called on all ranks.
152
    )"
153
  );
154
  // clang-format on
155
}
1,523 ✔
156

157
// Wrap LBS solver
158
void
159
WrapLBS(py::module& slv)
1,523 ✔
160
{
161
  // clang-format off
162
  // LBS problem
163
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
1,523 ✔
164
    slv,
165
    "LBSProblem",
166
    R"(
167
    Base class for all linear Boltzmann problems.
168

169
    Wrapper of :cpp:class:`opensn::LBSProblem`.
170
    )"
171
  );
1,523 ✔
172
  lbs_problem.def(
1,523 ✔
173
    "GetScalarFluxFieldFunction",
174
    [](LBSProblem& self, bool only_scalar_flux)
1,523 ✔
175
    {
176
      py::list field_function_list_per_group;
15,831 ✔
177
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
59,952 ✔
178
      {
179
        if (only_scalar_flux)
44,121 ✔
180
        {
181
          field_function_list_per_group.append(self.CreateScalarFluxFieldFunction(group, 0));
70,096 ✔
182
        }
183
        else
184
        {
185
          py::list field_function_list_per_moment;
9,073 ✔
186
          for (unsigned int moment = 0; moment < self.GetNumMoments(); moment++)
37,354 ✔
187
          {
188
            field_function_list_per_moment.append(self.CreateScalarFluxFieldFunction(group, moment));
56,562 ✔
189
          }
190
          field_function_list_per_group.append(field_function_list_per_moment);
9,073 ✔
191
        }
9,073 ✔
192
      }
193
      return field_function_list_per_group;
15,831 ✔
194
    },
×
195
    R"(
196
    Return scalar-flux or flux-moment field functions grouped by energy group.
197

198
    Parameters
199
    ----------
200
    only_scalar_flux : bool, default=True
201
        If True, returns only the zeroth moment (scalar flux) field function for each group.
202
        If False, returns all moment field functions for each group.
203

204
    Returns
205
    -------
206
    Union[List[pyopensn.fieldfunc.FieldFunctionGridBased], List[List[pyopensn.fieldfunc.FieldFunctionGridBased]]]
207
        If ``only_scalar_flux=True``:
208
        ``result[g]`` is the scalar-flux field function for group ``g``.
209

210
        If ``only_scalar_flux=False``:
211
        ``result[g][m]`` is the field function for group ``g`` and moment ``m``.
212

213
    Notes
214
    -----
215
    Field functions are created on demand from the current solver state.
216

217
    The returned field functions are snapshots of the solver state at creation time; they are not
218
    refreshed automatically if the solver state changes.
219

220
    They support ``CanUpdate()`` and ``Update()`` while their owning problem is still alive.
221
    Calling ``Update()`` explicitly refreshes the same field-function object from the solver's
222
    latest state.
223

224
    Calling ``GetScalarFluxFieldFunction(only_scalar_flux=False)`` creates all requested
225
    moments from the current ``phi`` iterate at the time of the call.
226

227
    In the nested form (``only_scalar_flux=False``), the moment index varies fastest
228
    within each group (inner index = moment, outer index = group).
229
    )",
230
    py::arg("only_scalar_flux") = true
1,523 ✔
231
  );
232
  lbs_problem.def(
3,046 ✔
233
    "CreateFieldFunction",
234
    static_cast<std::shared_ptr<FieldFunctionGridBased> (LBSProblem::*)(
3,046 ✔
235
      const std::string&, const std::string&, double)>(&LBSProblem::CreateFieldFunction),
236
    R"(
237
    Create a named scalar field function derived from a 1D XS or ``power``.
238

239
    Parameters
240
    ----------
241
    name : str
242
        Name to assign to the returned field function.
243
    xs_name : str
244
        Built-in 1D XS name, custom XS name, or the special value ``power``.
245
        CSDA-enabled discrete-ordinates problems also support
246
        ``csda_energy_deposition``, ``cepxs_energy_deposition``,
247
        ``csda_charge_deposition``,
248
        ``csda_charge_deposition_term``, and
249
        ``csda_charge_deposition_term_cellavg``. When CSDA is enabled,
250
        ``energy_deposition`` is an alias for the conservative
251
        ``csda_energy_deposition``. ``cepxs_energy_deposition`` preserves the
252
        imported CEPXS response plus the CSDA energy-space contribution.
253
    power_normalization_target : float, default=-1.0
254
        If positive, scale the derived field function so that the raw power field would
255
        integrate to this total power.
256

257
    Notes
258
    -----
259
    The returned field function is created on demand from the current scalar-flux iterate.
260
    For ordinary XS names this computes ``sum_g xs[g] * phi_g`` at each node.
261

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

267
    If ``xs_name == "power"``, the same power-generation formula used elsewhere by the solver
268
    is applied on demand.
269

270
    CSDA deposition fields are derived from the current scalar flux and terminal
271
    charged-particle current information. They should normally be created after the
272
    CSDA solve has converged.
273

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

278
    The returned field function is a fresh object created for this call. It is not
279
    automatically updated by later solves or timesteps.
280
    )",
281
    py::arg("name"),
3,046 ✔
282
    py::arg("xs_name"),
1,523 ✔
283
    py::arg("power_normalization_target") = -1.0
1,523 ✔
284
  );
285
  lbs_problem.def(
1,523 ✔
286
    "ComputeFieldFunctionPowerScaleFactor",
287
    &LBSProblem::ComputeFieldFunctionPowerScaleFactor,
3,046 ✔
288
    R"(
289
    Compute the scale factor that would normalize the current scalar flux's total power to a
290
    target value.
291

292
    This is a collective operation and must be called on all MPI ranks.
293

294
    Parameters
295
    ----------
296
    power_normalization_target : float
297
        Target total power (must be positive). The returned factor is
298
        ``power_normalization_target / global_total_power``, where ``global_total_power`` is the
299
        same MPI-reduced, kappa-weighted total power ``CreateFieldFunction(..., "power", ...)``
300
        and the ``power`` field function use.
301

302
    Notes
303
    -----
304
    This does not mutate any solver state. It is the same scale factor
305
    ``CreateFieldFunction(..., "power", power_normalization_target)`` applies internally, exposed
306
    directly so callers can apply it to their own derived quantities (e.g. postprocessor output)
307
    without needing a field function.
308
    )",
309
    py::arg("power_normalization_target")
1,523 ✔
310
  );
311
  lbs_problem.def(
1,523 ✔
312
    "GetTime",
313
    &LBSProblem::GetTime,
1,523 ✔
314
    R"(
315
    Get the current simulation time in seconds.
316
    )"
317
  );
318
  lbs_problem.def(
1,523 ✔
319
    "GetTimeStep",
320
    &LBSProblem::GetTimeStep,
1,523 ✔
321
    R"(
322
    Get the current timestep size.
323
    )"
324
  );
325
  lbs_problem.def(
1,523 ✔
326
    "ComputeFissionRate",
327
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,588 ✔
328
    {
329
      const std::vector<double>* phi_ptr = nullptr;
65 ✔
330
      if (scalar_flux_iterate == "old")
65 ✔
331
      {
332
        phi_ptr = &self.GetPhiOldLocal();
×
333
      }
334
      else if (scalar_flux_iterate == "new")
65 ✔
335
      {
336
        phi_ptr = &self.GetPhiNewLocal();
65 ✔
337
      }
338
      else
339
      {
340
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
341
      }
342
      return ComputeFissionRate(self, *phi_ptr);
65 ✔
343
    },
344
    R"(
345
    Computes the total fission rate.
346

347
    Parameters
348
    ----------
349
    scalar_flux_iterate : {'old', 'new'}
350
        Specifies which scalar flux vector to use in the calculation.
351
            - 'old': Use the previous scalar flux iterate.
352
            - 'new': Use the current scalar flux iterate.
353

354
    Returns
355
    -------
356
    float
357
        The total fission rate, integrated over the physical volume. In RZ and 1D spherical
358
        geometry the integral covers the full revolution (factors of 2*pi and 4*pi).
359

360
    Raises
361
    ------
362
    ValueError
363
        If `scalar_flux_iterate` is not 'old' or 'new'.
364
    )",
365
    py::arg("scalar_flux_iterate")
1,523 ✔
366
  );
367
  lbs_problem.def(
1,523 ✔
368
    "ComputeFissionProduction",
369
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
3,222 ✔
370
    {
371
      const std::vector<double>* phi_ptr = nullptr;
1,699 ✔
372
      if (scalar_flux_iterate == "old")
1,699 ✔
373
      {
374
        phi_ptr = &self.GetPhiOldLocal();
88 ✔
375
      }
376
      else if (scalar_flux_iterate == "new")
1,611 ✔
377
      {
378
        phi_ptr = &self.GetPhiNewLocal();
1,611 ✔
379
      }
380
      else
381
      {
382
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
383
      }
384
      return ComputeFissionProduction(self, *phi_ptr);
1,699 ✔
385
    },
386
    R"(
387
    Computes the total fission production (nu*fission).
388

389
    Parameters
390
    ----------
391
    scalar_flux_iterate : {'old', 'new'}
392
        Specifies which scalar flux vector to use in the calculation.
393
            - 'old': Use the previous scalar flux iterate.
394
            - 'new': Use the current scalar flux iterate.
395

396
    Returns
397
    -------
398
    float
399
        The total fission production, integrated over the physical volume. In RZ and 1D spherical
400
        geometry the integral covers the full revolution (factors of 2*pi and 4*pi).
401

402
    Raises
403
    ------
404
    ValueError
405
        If `scalar_flux_iterate` is not 'old' or 'new'.
406
    )",
407
    py::arg("scalar_flux_iterate")
1,523 ✔
408
  );
409
  lbs_problem.def(
1,523 ✔
410
    "GetPhiOldLocal",
411
    [](LBSProblem& self)
1,633 ✔
412
    {
413
      return convert_vector(self.GetPhiOldLocal());
110 ✔
414
    },
415
    R"(
416
    Get the previous scalar flux iterate (local vector).
417

418
    Returns
419
    -------
420
    memoryview
421
        Memory view of the local old scalar flux vector.
422
    )"
423
  );
424
  lbs_problem.def(
1,523 ✔
425
    "GetPhiNewLocal",
426
    [](LBSProblem& self)
3,240 ✔
427
    {
428
      return convert_vector(self.GetPhiNewLocal());
1,717 ✔
429
    },
430
    R"(
431
    Get the current scalar flux iterate (local vector).
432

433
    Returns
434
    -------
435
    memoryview
436
        Memory view of the local new scalar flux vector.
437
    )"
438
  );
439
  lbs_problem.def(
1,523 ✔
440
    "WriteFluxMoments",
441
    [](LBSProblem& self, const std::string& file_base)
1,580 ✔
442
    {
443
      LBSSolverIO::WriteFluxMoments(self, file_base);
57 ✔
444
    },
445
    R"(
446
    Write flux moments to file.
447

448
    Parameters
449
    ----------
450
    file_base: str
451
        File basename.
452
    )",
453
    py::arg("file_base")
1,523 ✔
454
  );
455
  lbs_problem.def(
1,523 ✔
456
    "CreateAndWriteSourceMoments",
457
    [](LBSProblem& self, const std::string& file_base)
1,527 ✔
458
    {
459
      std::vector<double> source_moments = self.MakeSourceMomentsFromPhi();
4 ✔
460
      LBSSolverIO::WriteFluxMoments(self, file_base, source_moments);
4 ✔
461
    },
4 ✔
462
    R"(
463
    Write source moments from latest flux iterate to file.
464

465
    Parameters
466
    ----------
467
    file_base: str
468
        File basename.
469
    )",
470
    py::arg("file_base")
1,523 ✔
471
  );
472
  lbs_problem.def(
1,523 ✔
473
    "ReadFluxMomentsAndMakeSourceMoments",
474
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,532 ✔
475
    {
476
      std::vector<double> file_phi;
9 ✔
477
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, file_phi);
9 ✔
478
      log.Log() << "Making source moments from flux file.";
18 ✔
479
      self.SetExtSrcMomentsFrom(self.MakeSourceMomentsFromPhi(file_phi));
17 ✔
480
    },
8 ✔
481
    R"(
482
    Read flux moments and compute corresponding source moments.
483

484
    Parameters
485
    ----------
486
    file_base: str
487
        File basename.
488
    single_file_flag: bool
489
        True to read the single file ``<file_base>.h5`` instead of the per-rank file
490
        ``<file_base><rank>.h5``. Serial runs only: a multi-rank run raises ``ValueError``
491
        before any file is opened.
492
    )",
493
    py::arg("file_base"),
3,046 ✔
494
    py::arg("single_file_flag")
1,523 ✔
495
  );
496
  lbs_problem.def(
1,523 ✔
497
    "ReadSourceMoments",
498
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,529 ✔
499
    {
500
      auto ext_src_moments = self.GetExtSrcMomentsLocal();
6 ✔
501
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, ext_src_moments);
6 ✔
502
      self.SetExtSrcMomentsFrom(ext_src_moments);
6 ✔
503
    },
6 ✔
504
    R"(
505
    Read source moments from file.
506

507
    Parameters
508
    ----------
509
    file_base: str
510
        File basename.
511
    single_file_flag: bool
512
        True to read the single file ``<file_base>.h5`` instead of the per-rank file
513
        ``<file_base><rank>.h5``. Serial runs only: a multi-rank run raises ``ValueError``
514
        before any file is opened.
515
    )",
516
    py::arg("file_base"),
3,046 ✔
517
    py::arg("single_file_flag")
1,523 ✔
518
  );
519
  lbs_problem.def(
1,523 ✔
520
    "ReadFluxMoments",
521
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
1,526 ✔
522
    {
523
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag);
3 ✔
524
    },
525
    R"(
526
    Read flux moment data.
527

528
    Parameters
529
    ----------
530
    file_base: str
531
        File basename.
532
    single_file_flag: bool
533
        True to read the single file ``<file_base>.h5`` instead of the per-rank file
534
        ``<file_base><rank>.h5``. Serial runs only: a multi-rank run raises ``ValueError``
535
        before any file is opened.
536
    )",
537
    py::arg("file_base"),
3,046 ✔
538
    py::arg("single_file_flag")
1,523 ✔
539
  );
540
  lbs_problem.def(
1,523 ✔
541
    "WriteAngularFluxes",
542
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,538 ✔
543
    {
544
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
15 ✔
545
    },
546
    R"(
547
    Write angular flux data to file.
548

549
    Parameters
550
    ----------
551
    file_base: str
552
        File basename.
553
    )",
554
    py::arg("file_base")
1,523 ✔
555
  );
556
  lbs_problem.def(
1,523 ✔
557
    "ReadAngularFluxes",
558
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
1,527 ✔
559
    {
560
      DiscreteOrdinatesProblemIO::ReadAngularFluxes(self, file_base);
4 ✔
561
    },
562
    R"(
563
    Read angular fluxes from file.
564

565
    Parameters
566
    ----------
567
    file_base: str
568
        File basename.
569
    )",
570
    py::arg("file_base")
1,523 ✔
571
  );
572
  lbs_problem.def(
3,046 ✔
573
    "WriteSurfaceAngularFluxes",
574
    [](DiscreteOrdinatesProblem& self,
1,549 ✔
575
       const std::string& file_base,
576
       const std::vector<std::string>& boundary_surfaces,
577
       const std::map<std::string, std::pair<std::string, double>>& interior_surfaces)
578
    {
579
      DiscreteOrdinatesProblemIO::WriteSurfaceAngularFluxes(
26 ✔
580
        self, file_base, boundary_surfaces, interior_surfaces);
581
    },
582
    R"(
583
    Write surface angular flux data to file.
584

585
    Parameters
586
    ----------
587
    file_base: str
588
        File basename.
589
    boundary_surfaces: list[str], default=[]
590
        Boundary names to export.
591
    interior_surfaces: dict[str, tuple[str, float]], default={}
592
        Interior surfaces in the form {'name': ('axis', value)}, where axis is 'x', 'y', or 'z'.
593
        Each interior surface is written under two tags: '<name>_u' for faces whose outward normal
594
        aligns with the positive axis and '<name>_d' for faces with the opposite orientation.
595
        An interior surface must match at least one cell face, must lie on cell faces inside the
596
        mesh, and may not share a plane with another interior surface. Its generated tags may not
597
        equal a requested boundary name. It may not coincide with an exterior boundary; export
598
        that boundary by name using boundary_surfaces instead.
599

600
    Notes
601
    -----
602
    Requires ``options.save_angular_flux=True``. Collective over all ranks; surface selections may
603
    differ by rank. Each rank writes ``<file_base><rank>.h5``. The file records whether the problem
604
    is in adjoint mode. After an adjoint steady-state solve, the stored angular flux is the adjoint
605
    flux for the listed direction, so forward and adjoint data with the same indices refer to the
606
    same direction.
607
    )",
608
    py::arg("file_base"),
1,523 ✔
609
    py::arg("boundary_surfaces") = std::vector<std::string>{},
3,046 ✔
610
    py::arg("interior_surfaces") = std::map<std::string, std::pair<std::string, double>>{}
3,046 ✔
611
  );
612
  lbs_problem.def(
1,523 ✔
613
    "ReadSurfaceAngularFluxes",
614
    [](DiscreteOrdinatesProblem& self, const std::string& file_base, py::list surfaces)
1,537 ✔
615
    {
616
      std::vector<std::string> surface_ids;
14 ✔
617
      for (py::handle surface : surfaces)
50 ✔
618
        surface_ids.push_back(surface.cast<std::string>());
44 ✔
619

620
      const auto surface_fluxes =
14 ✔
621
        DiscreteOrdinatesProblemIO::ReadSurfaceAngularFluxes(self, file_base, surface_ids);
14 ✔
622
      py::list result;
14 ✔
623
      for (const auto& surface_flux : surface_fluxes)
36 ✔
624
      {
625
        py::dict mapping;
22 ✔
626
        mapping["cell_ids"] = surface_flux.mapping.cell_ids;
22 ✔
627
        mapping["num_face_nodes"] = surface_flux.mapping.num_face_nodes;
22 ✔
628
        mapping["cell_map"] = surface_flux.mapping.cell_map;
22 ✔
629
        mapping["centroid_origin"] = surface_flux.mapping.centroid_origin;
22 ✔
630
        mapping["centroid_spacing"] = surface_flux.mapping.centroid_spacing;
22 ✔
631
        mapping["cell_stride"] = surface_flux.mapping.cell_stride;
22 ✔
632
        mapping["nodes_x"] = surface_flux.mapping.nodes_x;
22 ✔
633
        mapping["nodes_y"] = surface_flux.mapping.nodes_y;
22 ✔
634
        mapping["nodes_z"] = surface_flux.mapping.nodes_z;
22 ✔
635

636
        py::dict data;
22 ✔
637
        data["omega"] = surface_flux.data.omega;
22 ✔
638
        data["mu"] = surface_flux.data.mu;
22 ✔
639
        data["wt_d"] = surface_flux.data.wt_d;
22 ✔
640
        data["M_ij"] = surface_flux.data.mass_matrix;
22 ✔
641
        data["fe_shape"] = surface_flux.data.fe_shape;
22 ✔
642
        data["psi"] = surface_flux.data.psi;
22 ✔
643
        data["node_index"] = surface_flux.data.node_index;
22 ✔
644
        data["dir_index"] = surface_flux.data.dir_index;
22 ✔
645

646
        py::dict entry;
22 ✔
647
        entry["groupset_id"] = surface_flux.groupset_id;
22 ✔
648
        entry["adjoint"] = surface_flux.adjoint;
22 ✔
649
        entry["surface_name"] = surface_flux.surface_name;
22 ✔
650
        entry["mapping"] = std::move(mapping);
22 ✔
651
        entry["data"] = std::move(data);
22 ✔
652
        result.append(std::move(entry));
22 ✔
653
      }
22 ✔
654
      return result;
14 ✔
655
    },
14 ✔
656
    R"(
657
    Read surface angular fluxes from file.
658

659
    Parameters
660
    ----------
661
    file_base: str
662
        File basename.
663
    surfaces: list[str]
664
        Stored surface tags to read. Use the boundary name for an exterior boundary. For an
665
        interior surface named '<name>', use '<name>_u' for faces whose outward normal aligns with
666
        the positive axis and '<name>_d' for faces with the opposite orientation.
667

668
    Returns
669
    -------
670
    List[dict]
671
        Surface mapping and angular-flux data for each groupset and requested surface. Each entry
672
        has keys 'groupset_id', 'adjoint', 'surface_name', 'mapping', and 'data'. 'adjoint' is
673
        True when the file was written from an adjoint problem. The 'cell_map' in 'mapping' maps
674
        face centroids, with 'centroid_origin' subtracted before each coordinate is divided by
675
        'centroid_spacing' (1e-9 times the global mesh extent) and rounded to an integer, to the
676
        surface face index. The same face has the same key from either side of an interior surface
677
        and in every file for the same mesh.
678

679
    Notes
680
    -----
681
    Each rank reads ``<file_base><rank>.h5``; the files must have been written with the same mesh
682
    partitioning.
683
    )",
684
    py::arg("file_base"),
3,046 ✔
685
    py::arg("surfaces")
1,523 ✔
686
  );
687
  lbs_problem.def(
1,523 ✔
688
    "SetPointSources",
689
    [](LBSProblem& self, py::kwargs& params)
1,530 ✔
690
    {
691
      // Collect the arguments first so that keyword order does not matter. The problem replaces
692
      // or extends its sources in one validated step.
693
      bool clear = false;
7 ✔
694
      std::vector<std::shared_ptr<PointSource>> sources;
7 ✔
695
      for (auto [key, value] : params)
26 ✔
696
      {
697
        auto c_key = key.cast<std::string>();
12 ✔
698
        if (c_key == "clear_point_sources")
12 ✔
699
          clear = value.cast<bool>();
5 ✔
700
        else if (c_key == "point_sources")
7 ✔
701
        {
702
          for (auto source : value.cast<py::list>())
23 ✔
703
            sources.push_back(source.cast<std::shared_ptr<PointSource>>());
25 ✔
704
        }
705
        else
706
          throw std::invalid_argument("SetPointSources: unknown argument '" + c_key +
×
707
                                      "'; expected 'point_sources' or 'clear_point_sources'.");
×
708
      }
12 ✔
709
      self.SetPointSources(sources, clear);
7 ✔
710
    },
7 ✔
711
    R"(
712
    Set or clear point sources.
713

714
    Parameters
715
    ----------
716
    clear_point_sources: bool, default=False
717
        If true, all current the point sources of the problem are deleted.
718
    point_sources: List[pyopensn.source.PointSource]
719
        List of new point sources to be added to the problem. When both arguments are given,
720
        the current sources are cleared before the new ones are added, regardless of keyword
721
        order. The new list is validated as a whole: a rejected call leaves the problem's
722
        sources unchanged.
723

724
    Notes
725
    -----
726
    This is collective. A source caches mesh data when added. Prefer a separate source object for
727
    each problem. Share one only when both problems use the same mesh and spatial layout.
728
    )"
729
  );
730
  lbs_problem.def(
1,523 ✔
731
    "SetVolumetricSources",
732
    [](LBSProblem& self, py::kwargs& params)
1,839 ✔
733
    {
734
      // Collect the arguments first so that keyword order does not matter. The problem replaces
735
      // or extends its sources in one validated step.
736
      bool clear = false;
316 ✔
737
      std::vector<std::shared_ptr<VolumetricSource>> sources;
316 ✔
738
      for (auto [key, value] : params)
952 ✔
739
      {
740
        auto c_key = key.cast<std::string>();
320 ✔
741
        if (c_key == "clear_volumetric_sources")
320 ✔
742
          clear = value.cast<bool>();
206 ✔
743
        else if (c_key == "volumetric_sources")
114 ✔
744
        {
745
          for (auto source : value.cast<py::list>())
344 ✔
746
            sources.push_back(source.cast<std::shared_ptr<VolumetricSource>>());
346 ✔
747
        }
748
        else
749
          throw std::invalid_argument("SetVolumetricSources: unknown argument '" + c_key +
×
750
                                      "'; expected 'volumetric_sources' or "
751
                                      "'clear_volumetric_sources'.");
×
752
      }
320 ✔
753
      self.SetVolumetricSources(sources, clear);
316 ✔
754
    },
316 ✔
755
    R"(
756
    Set or clear volumetric sources.
757

758
    Parameters
759
    ----------
760
    clear_volumetric_sources: bool, default=False
761
        If true, all current the volumetric sources of the problem are deleted.
762
    volumetric_sources: List[pyopensn.source.VolumetricSource]
763
        List of new volumetric sources to be added to the problem. When both arguments are given,
764
        the current sources are cleared before the new ones are added, regardless of keyword
765
        order. The new list is validated as a whole: a rejected call leaves the problem's
766
        sources unchanged.
767

768
    Notes
769
    -----
770
    This is collective. A source caches mesh data when added. Prefer a separate source object for
771
    each problem. Share one only when both problems use the same mesh and spatial layout.
772
    )"
773
  );
774
  lbs_problem.def(
1,523 ✔
775
    "SetXSMap",
776
    [](LBSProblem& self, py::kwargs& params)
1,953 ✔
777
    {
778
      BlockID2XSMap xs_map;
430 ✔
779
      for (auto [key, value] : params)
1,290 ✔
780
      {
781
        auto c_key = key.cast<std::string>();
430 ✔
782
        if (c_key == "xs_map")
430 ✔
783
        {
784
          auto xs_entries = value.cast<py::list>();
430 ✔
785
          for (auto entry : xs_entries)
1,306 ✔
786
          {
787
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
446 ✔
788
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
446 ✔
789
            const auto& block_ids =
446 ✔
790
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
446 ✔
791
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
446 ✔
792
            for (const auto& block_id : block_ids)
894 ✔
793
              xs_map[block_id] = xs;
448 ✔
794
          }
446 ✔
795
        }
430 ✔
796
        else
797
          throw std::invalid_argument("SetXSMap: unknown argument '" + c_key +
×
798
                                      "'; expected 'xs_map'.");
×
799
      }
430 ✔
800
      self.SetBlockID2XSMap(xs_map);
430 ✔
801
    },
410 ✔
802
    R"(
803
    Replace the block-id to cross-section map.
804

805
    Parameters
806
    ----------
807
    xs_map: List[Dict]
808
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
809
          - block_ids: List[int] (required)
810
              Mesh block ids to associate with the cross section.
811
          - xs: pyopensn.xs.MultiGroupXS (required)
812
              Cross section object.
813

814
    Raises
815
    ------
816
    ValueError
817
        If the problem was constructed with an ``uncollided_flux`` file, or if
818
        ``options.csda_enabled=True`` and the new cross sections split a
819
        charged-particle group block across groupsets, contain more than two
820
        charged-particle group ranges, have negative or non-finite stopping powers or
821
        non-positive energy-group widths, or have stopping power or energy
822
        bounds that do not match the problem's group count. CSDA also rejects
823
        conflicting supplied energy structures, unknown or conflicting charged-particle
824
        species, or a map with no energy structure.
825
        Individual materials may omit bounds and use the shared structure.
826

827
    Notes
828
    -----
829
    This is collective.
830

831
    The problem is refreshed immediately after replacing the map. Material metadata,
832
    precursor storage, GPU carriers, and derived solver state owned by the concrete
833
    problem are rebuilt for the new cross sections.
834

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

840
    Existing precursor concentrations are remapped by local cell and precursor-family
841
    index. When the new material for a cell has fewer precursor families, extra old
842
    families are dropped. When it has more families, newly introduced families are
843
    initialized to zero. If a cell changes through a material with zero precursors,
844
    the precursor history for that cell is discarded and any later reintroduced
845
    precursor families start from zero.
846

847
    If any fissionable material in the new map contains delayed-neutron precursor
848
    data and ``options.use_precursors=True``, all fissionable materials in the map
849
    must contain precursor data. Non-fissionable materials may have zero precursors.
850

851
    The new map is validated before it is committed. A validation error leaves the map unchanged.
852
    A later rebuild failure may leave partial changes. In time-dependent mode, every cross section
853
    must provide ``VELOCITY`` or ``INV_VELOCITY`` data.
854

855
    Forward/adjoint mode toggles via :meth:`LBSProblem.SetAdjoint` do not change this map.
856
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer, so a change to
857
    one, such as ``Scale``, is seen by every problem that uses it. The transport mode belongs
858
    to each problem: problems that share a ``MultiGroupXS`` handle can run in different modes.
859
    )"
860
  );
861
  lbs_problem.def(
1,523 ✔
862
    "ZeroPhi",
863
    [](LBSProblem& self)
1,715 ✔
864
    {
865
      self.ZeroPhi();
192 ✔
866
    },
867
    R"(
868
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
869
    )"
870
  );
871
  lbs_problem.def(
1,523 ✔
872
    "SetAdjoint",
873
    [](LBSProblem& self, bool adjoint)
1,523 ✔
874
    {
875
      self.SetAdjoint(adjoint);
51 ✔
876
    },
877
    py::arg("adjoint") = true,
1,523 ✔
878
    R"(
879
    Set forward/adjoint transport mode.
880

881
    Parameters
882
    ----------
883
    adjoint: bool, default=True
884
        ``True`` enables adjoint mode and ``False`` enables forward mode. Adjoint mode is
885
        supported only for Cartesian geometries with standard quadrature operators
886
        (``operator_method='standard'``), in steady-state mode, and without an
887
        uncollided flux. Uncollided flux generation (:class:`UncollidedProblem`) is
888
        forward only. An unsupported request raises ``ValueError`` before any state
889
        changes.
890

891
    Raises
892
    ------
893
    ValueError
894
        If ``adjoint=True`` and the problem is time-dependent, has
895
        ``options.csda_enabled=True``, was constructed with an
896
        ``uncollided_flux`` file, or is an :class:`UncollidedProblem`. The problem
897
        is left unchanged.
898

899
    Notes
900
    -----
901
    This is one of two supported mode-setting paths in Python:
902
      1. ``options={'adjoint': ...}`` in the problem constructor.
903
      2. ``SetAdjoint(...)`` (this method).
904

905
    If this changes mode, OpenSn performs a full mode-transition reset:
906
      - Materials are reinitialized in the selected mode.
907
      - Point and volumetric sources are cleared.
908
      - Boundary conditions are cleared.
909
      - Scalar and angular flux vectors are zeroed.
910

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

913
    The block-id to cross-section map is preserved across the transition.
914
    The mode change applies to this problem only. Other problems that share the mapped
915
    ``MultiGroupXS`` objects keep their own mode.
916

917
    After a mode change, reapply the desired driving terms before solving, typically:
918
      - :meth:`LBSProblem.SetPointSources`
919
      - :meth:`LBSProblem.SetVolumetricSources`
920
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
921

922
    This routine is intentionally destructive with respect to source/boundary/flux state
923
    to avoid hidden coupling between forward and adjoint setups.
924

925
    This is a collective operation and must be called on all ranks.
926
    )"
927
  );
928
  lbs_problem.def(
1,523 ✔
929
    "SetForward",
930
    &LBSProblem::SetForward,
1,523 ✔
931
    R"(
932
    Set forward transport mode.
933

934
    Equivalent to ``SetAdjoint(False)``.
935
    )"
936
  );
937
  lbs_problem.def(
1,523 ✔
938
    "IsAdjoint",
939
    &LBSProblem::IsAdjoint,
1,523 ✔
940
    R"(
941
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
942
    )"
943
  );
944
  lbs_problem.def(
1,523 ✔
945
    "IsTimeDependent",
946
    &LBSProblem::IsTimeDependent,
1,523 ✔
947
    R"(
948
    Return ``True`` if the problem is in time-dependent mode, otherwise ``False``.
949
    )"
950
  );
951

952
  auto unc_problem =
1,523 ✔
953
    py::class_<UncollidedProblem, std::shared_ptr<UncollidedProblem>, LBSProblem>(
954
      slv,
955
      "UncollidedProblem",
956
      R"(
957
    Define an uncollided transport problem for a first-collision calculation.
958

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

966
    Wrapper of :cpp:class:`opensn::UncollidedProblem`.
967
    )");
1,523 ✔
968
  unc_problem.def(
3,046 ✔
969
    py::init(
1,523 ✔
970
      [](py::kwargs& params)
40 ✔
971
      {
972
        return UncollidedProblem::Create(kwargs_to_param_block(params));
40 ✔
973
      }),
974
    R"(
975
    Construct an uncollided transport problem.
976

977
    Parameters
978
    ----------
979
    mesh : Mesh
980
        Cartesian two- or three-dimensional spatial mesh.
981
    num_groups : int
982
        Number of energy groups.
983
    groupsets : List[Dict], default=[]
984
        Groupset definitions required by the common LBS problem interface. The
985
        uncollided calculation itself does not use an angular quadrature.
986
    xs_map : List[Dict], default=[]
987
        Block-to-cross-section mappings. Total cross sections are used for
988
        attenuation and are recorded in the output file for compatibility
989
        checking by the collided problem.
990
    boundary_conditions : List[Dict], default=[]
991
        Vacuum or reflecting boundary conditions, using the same ``name`` and
992
        ``type`` entries as ``DiscreteOrdinatesProblem``. Reflecting boundaries
993
        are represented with directly projected image sources and must be
994
        planar, mutually orthogonal symmetry planes without an opposing
995
        reflecting plane.
996
    point_sources : List[pyopensn.source.PointSource], default=[]
997
        Explicit point sources. At least one point source is required. For now,
998
        each source must lie strictly inside a single cell; sources exactly on
999
        faces, edges, or vertices are rejected.
1000
    near_source : List[pyopensn.logvol.LogicalVolume]
1001
        Near-source ray-traced region for each point source, in the same order
1002
        as ``point_sources``. This list is required whenever point sources are
1003
        provided. Its length must equal the number of point sources, and every
1004
        point source must lie in its corresponding region.
1005
    scattering_order : int, default=0
1006
        Maximum spherical-harmonic order written to the HDF5 file. It must be
1007
        at least the scattering order of the collided problem that consumes
1008
        the file.
1009
    Notes
1010
    -----
1011
    Only explicit point sources are supported by the uncollided generator.
1012
    Each point source must lie strictly inside a single cell. Uncollided
1013
    generation must run on a Cartesian two- or three-dimensional mesh with
1014
    exactly one MPI rank.
1015

1016
    Point-source placement strongly affects spatial accuracy. Construct or
1017
    locally refine the mesh so that each source lies well inside its containing
1018
    cell and near the cell centroid whenever the physical model permits. Exact
1019
    balance after the near-source correction does not guarantee that the local
1020
    flux shape is resolved; monitor the reported correction and verify mesh
1021
    convergence.
1022

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

1028
    Uncollided generation is forward only: ``options={'adjoint': True}`` and
1029
    ``SetAdjoint(True)`` are rejected. The other ``options`` and ``use_gpus`` are
1030
    inherited from the base class interface but have no effect on uncollided
1031
    generation and should be omitted.
1032

1033
    The uncollided file generated by :class:`UncollidedSolver` is
1034
    partition-independent and may be consumed by a serial or parallel collided
1035
    calculation on the same mesh. The collided problem must use the same
1036
    reflecting boundaries recorded in the file.
1037
    Each reflected image source is ray traced to every finite-element volume
1038
    quadrature point and projected directly into the spatial discretization.
1039
    )");
1040

1041
  // discrete ordinate solver
1042
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
1,523 ✔
1043
                               LBSProblem>(
1044
    slv,
1045
    "DiscreteOrdinatesProblem",
1046
    R"(
1047
    Base class for discrete ordinates problems in Cartesian geometry.
1048

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

1053
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
1054
    )"
1055
  );
1,523 ✔
1056
  do_problem.def(
3,046 ✔
1057
    py::init(
1,523 ✔
1058
      [](py::kwargs& params)
2,925 ✔
1059
      {
1060
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
2,925 ✔
1061
      }
1062
    ),
1063
    R"(
1064
    Construct a discrete ordinates problem with Cartesian geometry.
1065

1066
    Parameters
1067
    ----------
1068
    mesh : Mesh
1069
        The spatial mesh.
1070
    num_groups : int
1071
        The total number of energy groups.
1072
    groupsets : List[Dict]
1073
        A list of input parameter blocks, each block provides the iterative properties for a
1074
        groupset. The inclusive ranges must partition all groups in order, starting at group 0
1075
        and ending at ``num_groups - 1``, without gaps or overlaps. Each dictionary supports:
1076

1077
          - groups_from_to: List[int] (required)
1078
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1079
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1080
              Handle to an angular quadrature.
1081
          - angle_aggregation_type: {'polar', 'single', 'azimuthal', 'quadrant', 'octant'},
1082
            default='single'
1083
              Angle aggregation method to use during sweeping. ``'quadrant'`` (2D) and
1084
              ``'octant'`` (3D) put all directions in the same quadrant or octant in one angle
1085
              set. They require a Cartesian orthogonal mesh and a quadrature in which every
1086
              direction has nonzero ``Omega_x`` and ``Omega_y`` (and, for ``'octant'``,
1087
              ``Omega_z``), so each direction lies strictly inside one quadrant or octant.
1088
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1089
            'petsc_gmres', 'petsc_fgmres', 'petsc_bicgstab'}, default='petsc_richardson'
1090

1091
              Iterative method used for inner linear solves. ``'petsc_fgmres'`` is flexible
1092
              GMRES with right preconditioning; it remains exact when the WGDSA/TGDSA
1093
              preconditioner solves its diffusion system only approximately.
1094
          - l_abs_tol: float, default=1.0e-6
1095
              Inner linear solver absolute residual tolerance.
1096
          - l_max_its: int, default=200
1097
              Inner linear solver maximum iterations.
1098
          - gmres_restart_interval: int, default=30
1099
              GMRES restart interval, if GMRES is used.
1100
          - allow_cycles: bool, default=True
1101
              Whether cyclic dependencies are allowed in sweeps.
1102
          - apply_wgdsa: bool, default=False
1103
              Enable within-group DSA for this groupset. With ``csda_enabled`` and charged-particle
1104
              groups in the groupset, each group's diffusion operator also removes the CSDA
1105
              slowing-down rate ``S_g / dE_g``, and the groups are solved one at a time in energy
1106
              order, coupled by in-groupset scattering and slowing-down. See
1107
              :ref:`theory_csda_wgdsa`.
1108
          - wgdsa_l_abs_tol: float, default=1.0e-4
1109
              Residual tolerance of the WGDSA diffusion solve, relative to the norm of its
1110
              right-hand side (the name is historical).
1111
          - wgdsa_l_max_its: int, default=30
1112
              WGDSA maximum iterations.
1113
          - wgdsa_verbose: bool, default=False
1114
              Verbose WGDSA output.
1115
          - wgdsa_petsc_options: str, default=''
1116
              PETSc options string for the WGDSA solver.
1117
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1118
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1119
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1120
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
1121
              PETSc KSP/PC setup.
1122
          - wgdsa_direct_solve_threshold: int, default=20000
1123
              Maximum global WGDSA diffusion unknown count for the automatic direct solve. The
1124
              CSDA operator solves one group at a time, so the count is that of one group.
1125
          - wgdsa_boundary_closure: {'marshak', 'closure_r', 'dirichlet'}, default='marshak'
1126
              Boundary condition of the WGDSA diffusion correction on vacuum, isotropic and
1127
              arbitrary boundaries. ``marshak`` imposes the natural Marshak condition, a face
1128
              mass term with coefficient 1/2; ``closure_r`` blends the
1129
              Marshak form written with the quadrature's partial current (face mass
1130
              coefficient 2 beta_f, beta_f the quadrature's half-range moment, against the
1131
              analytic 1/2) on thin boundary cells with the half-range transport form on
1132
              thick ones, uses beta_f as the floor of the interior penalty, and is faster
1133
              than ``marshak`` when boundary cells are many mean free paths thick;
1134
              ``dirichlet`` (OpenSn's original) imposes a zero correction weakly
1135
              (interior-penalty term). Reflecting boundaries are zero-current.
1136
          - apply_tgdsa: bool, default=False
1137
              Enable two-grid DSA for this groupset.
1138
          - tgdsa_l_abs_tol: float, default=1.0e-4
1139
              Residual tolerance of the TGDSA diffusion solve, relative to the norm of its
1140
              right-hand side (the name is historical).
1141
          - tgdsa_l_max_its: int, default=30
1142
              TGDSA maximum iterations.
1143
          - tgdsa_verbose: bool, default=False
1144
              Verbose TGDSA output.
1145
          - tgdsa_petsc_options: str, default=''
1146
              PETSc options string for the TGDSA solver.
1147
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1148
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1149
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1150
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
1151
              PETSc KSP/PC setup.
1152
          - tgdsa_direct_solve_threshold: int, default=20000
1153
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
1154
          - tgdsa_boundary_closure: {'marshak', 'closure_r', 'dirichlet'}, default='marshak'
1155
              Boundary condition of the TGDSA diffusion correction, with the same choices
1156
              and boundaries as ``wgdsa_boundary_closure``.
1157
          - wgdsa_h_perp_convention: {'face_side', 'cell'}, default='face_side'
1158
              Penalty length of the WGDSA MIP operator. ``face_side`` uses the face-dependent
1159
              orthogonal length (3V/A_f tet, V/A_f hex and quad, 2V/A_f triangle) capped by four
1160
              times the smallest height of the face's PWLD side simplices, with an extra factor 2
1161
              on polygons and polyhedra; the cap keeps the operator positive definite on strongly
1162
              distorted cells and is inactive on regular ones. ``cell`` (OpenSn's original) uses
1163
              the cell-wide lengths 3V/S (tet) and V/S (hex) with the extra factor 2, and on
1164
              polygons the face-dependent lengths without the cap or the factor 2. On polyhedra
1165
              its penalty is larger (six times on cubes), which slows DSA on cells a few mean free
1166
              paths thick; on polygons it is half the ``face_side`` penalty and can make the
1167
              operator indefinite on strongly distorted cells.
1168
          - tgdsa_h_perp_convention: {'face_side', 'cell'}, default='face_side'
1169
              Penalty length of the TGDSA MIP operator, as ``wgdsa_h_perp_convention``.
1170
    xs_map : List[Dict]
1171
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1172
          - block_ids: List[int] (required)
1173
              Mesh block IDs to associate with the cross section.
1174
          - xs: pyopensn.xs.MultiGroupXS (required)
1175
              Cross-section object to assign to the specified blocks.
1176
    boundary_conditions: List[Dict], default=[]
1177
        A list containing tables for each boundary specification. Each dictionary supports:
1178
          - name: str (required)
1179
              Boundary name that identifies the specific boundary.
1180
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1181
              Boundary type specification.
1182
          - group_strength: List[float], optional
1183
              Required when ``type='isotropic'``. Isotropic strength per group.
1184
          - start_time: float, optional
1185
              Active start time for isotropic boundaries only. Defaults to -infinity.
1186
          - end_time: float, optional
1187
              Active end time for isotropic boundaries only. Defaults to infinity.
1188
          - function: AngularFluxFunction, optional
1189
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1190
              that returns incoming angular flux from group and direction.
1191
          - time_function: AngularFluxTimeFunction, optional
1192
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1193
              returns incoming angular flux from group, direction, and time.
1194

1195
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1196
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1197
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1198
        that callback.
1199
    point_sources: List[pyopensn.source.PointSource], default=[]
1200
        A list of point sources.
1201
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1202
        A list of volumetric sources.
1203
    options : Dict, default={}
1204
        A block of optional configuration parameters, including:
1205
          - max_mpi_message_size: int, default=4194304
1206
              Maximum size in bytes of one sweep MPI message. Larger angular-flux transfers
1207
              are split into messages of at most this size.
1208
          - restart_writes_enabled: bool, default=False
1209
              Enable restart dump writes for solvers that support restart output.
1210
          - write_delayed_psi_to_restart: bool, default=True
1211
              Include delayed sweep angular-flux buffers in steady-state restart dumps. These
1212
              buffers are optional when a steady-state restart is used only as a transient
1213
              initial condition because the transient initialization can reconstruct angular
1214
              state from the flux moments. Time-dependent restart dumps always include them,
1215
              regardless of this option, because continuing a transient requires them whenever
1216
              the problem has delayed sweep angular state, including partitioned parallel,
1217
              reflected-boundary, and cyclic-sweep cases.
1218
          - write_angular_flux_to_restart: bool, default=True
1219
              Include stored angular fluxes in steady-state restart dumps when
1220
              ``save_angular_flux=True``. They are optional when a steady-state restart is used
1221
              only as a transient initial condition. Time-dependent restart dumps always
1222
              include them, regardless of this option, because continuing a transient requires
1223
              them.
1224
          - read_restart_path: str, default=''
1225
              File stem for reading a full restart. Scalar flux and precursor data can be
1226
              remapped when the MPI rank count or mesh partition changes. Full transient
1227
              continuation still requires the original partitioned angular state layout.
1228
          - read_initial_condition_path: str, default=''
1229
              File stem for reading restart data as an initial condition. Cannot be combined
1230
              with ``read_restart_path``. A steady-state restart may be used by
1231
              ``TransientSolver`` through this option. The first ``TransientSolver.Initialize()``
1232
              loads it once; later initializations continue from the problem's current state.
1233
          - write_restart_path: str, default=''
1234
              File stem for restart dump writes. OpenSn appends the MPI rank and
1235
              ``.restart.h5`` to this stem.
1236
          - write_restart_time_interval: int, default=0
1237
            (must be 0 or >=30)
1238
          - use_precursors: bool, default=True
1239
            Enable delayed neutrons. With ``False`` every solve (steady-state, k-eigenvalue and
1240
            transient) uses prompt neutrons only, even if the cross sections have delayed-neutron
1241
            data; a k-eigenvalue solve then gives the prompt eigenvalue. This is treated as user
1242
            intent and remains active across later ``SetXSMap`` calls, even if the current XS map
1243
            temporarily contains no precursor-bearing material. When XS maps are swapped, existing
1244
            precursor concentrations are remapped by cell and precursor-family index; new families
1245
            start at zero and removed families are discarded. If any fissionable material in the
1246
            active map has precursor data and ``use_precursors=True``, all fissionable materials in
1247
            that map must have precursor data. Non-fissionable materials may have zero precursors.
1248
          - delayed_neutrons: bool, default=True
1249
            Alias of ``use_precursors``. Give at most one of the two names.
1250
          - use_source_moments: bool, default=False
1251
            Use the problem's source moments as the fixed source instead of its
1252
            volumetric and point sources. Provide them (e.g. with ``ReadSourceMoments``)
1253
            after creating the problem and before solving; a solve without them raises
1254
            ``ValueError``. They are an external source, so the k-eigenvalue solvers
1255
            reject a problem with this option.
1256
          - save_angular_flux: bool, default=False
1257
            Store angular flux state (`psi`) for transient mode, angular-flux
1258
            field functions, and angular-flux I/O.
1259
          - deterministic_sweeps: bool, default=False
1260
            Make every sweep bitwise reproducible from run to run: the scalar flux moments and
1261
            face outflows are summed on the host from stored angular fluxes in a fixed angle order
1262
            instead of in the order that MPI messages and GPU threads complete. Stores the angular
1263
            flux in host memory without enabling ``save_angular_flux``. GPU sweeps also copy it to
1264
            the host after every sweep, which makes them roughly 2-5 times slower. Meant for
1265
            debugging and reproducible comparisons.
1266
          - adjoint: bool, default=False
1267
          - csda_enabled: bool, default=False
1268
            Enable CSDA charged-particle transport for CEPXS data loaded with
1269
            ``csda_format=True``. CSDA requires a Cartesian
1270
            :class:`SteadyStateSourceSolver` solve, ``sweep_type='AAH'``, CPU
1271
            sweeps, no ``apply_tgdsa`` in any groupset, and charged-particle
1272
            group ranges that are not split across groupsets. ``apply_wgdsa``
1273
            uses a CSDA diffusion operator in groupsets with charged-particle
1274
            groups. All materials must supply identical per-group energy bounds
1275
            or omit them; at least one material must supply the shared structure.
1276
          - verbose_inner_iterations: bool, default=True
1277
            Print inner iteration details, including WGS and AGS iterations.
1278
          - verbose_outer_iterations: bool, default=True
1279
            Print outer solver progress, including PI/NLKE iterations and transient steps.
1280
          - max_ags_iterations: int, optional
1281
            Maximum across-groupset iterations per transport solve. A value set here is used by
1282
            every solver. If it is not set, each solver uses its own default: 100 for the
1283
            steady-state and transient solvers, and 1 in each power iteration of
1284
            :class:`pyopensn.solver.PowerIterationKEigenSolver`.
1285
          - ags_tolerance: float, default=1.0e-6
1286
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1287
          - power_default_kappa: float, default=3.20435e-11
1288
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1289
          - field_function_prefix: str, default=''
1290
          - fused_aao_sweep: bool, default=False
1291
            Use the fused all-at-once GPU scheduler for ``AAH`` sweeps. Each kernel launch
1292
            sweeps the current level of up to 256 ready angle sets instead of one, which
1293
            reduces launch count when individual levels are too small to fill the GPU. Up to
1294
            ``fused_aao_lanes`` such runs are in flight at once, so angle sets that become
1295
            ready later (for example when upstream angular fluxes arrive from another rank)
1296
            start without waiting for the others. The solution matches the default scheduler
1297
            to floating-point rounding. Requires ``sweep_type="AAH"`` and ``use_gpus=True``.
1298
            Otherwise construction raises an error.
1299
          - fused_aao_lanes: int, default=8
1300
            With ``fused_aao_sweep=True``, the number of fused runs (each a batch of angle
1301
            sets on its own GPU stream) that may be in flight at once on each rank. Must be at
1302
            least 1. Ignored unless ``fused_aao_sweep=True``.
1303

1304
        These options are applied at problem creation.
1305
    sweep_type : str, default="AAH"
1306
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1307
        Both sweep types support time-dependent (transient) mode.
1308
    time_dependent : bool, default=False
1309
        If true, the problem starts in time-dependent mode. Otherwise it starts in
1310
        steady-state mode. Requires ``options.save_angular_flux=True`` and
1311
        ``VELOCITY`` or ``INV_VELOCITY`` data in every cross section.
1312
        Both ``AAH`` and ``CBC`` sweep types support time-dependent mode.
1313
    uncollided_flux : str, default=""
1314
        HDF5 file generated by :class:`UncollidedSolver`. For steady-state
1315
        forward fixed-source calculations, OpenSn uses its moments to
1316
        construct the first-collision scattering and fission source, solves
1317
        for the collided component, and adds the uncollided moments back to
1318
        the converged state. The file must match the current group count, mesh
1319
        cell IDs, cell-node layout and coordinates, total cross sections, and
1320
        reflecting boundary set. Its maximum moment order must be at least
1321
        this problem's scattering order. The same serial file may be read by
1322
        any MPI partitioning of the matching mesh. The first-collision source
1323
        replaces the fixed sources, so the problem must not also have volumetric
1324
        or point sources or incoming-flux (isotropic or arbitrary) boundaries,
1325
        and its cross sections cannot be replaced.
1326
    use_gpus : bool, default=False
1327
        A flag specifying whether GPU acceleration is used for the sweep. Both
1328
        ```AAH``` and ```CBC``` sweep types support GPU acceleration.
1329

1330
    Notes
1331
    -----
1332
    Construction validates and builds the runtime problem. It is collective. Use equivalent input
1333
    on every MPI rank.
1334
    )"
1335
  );
1336
  do_problem.def(
1,523 ✔
1337
    "SetTimeDependentMode",
1338
    py::overload_cast<>(&DiscreteOrdinatesProblem::SetTimeDependentMode),
1,523 ✔
1339
    R"(
1340
    Set the problem to time-dependent mode.
1341

1342
    Notes
1343
    -----
1344
    Switch problem from steady-state to time-dependent mode. This updates problem
1345
    internals (sweep chunk mode and source-function) while preserving user boundary
1346
    conditions and fixed sources.
1347

1348
    Requires ``options.save_angular_flux=True`` at problem creation and ``VELOCITY`` or
1349
    ``INV_VELOCITY`` data in every cross section. Not supported for adjoint, GPU,
1350
    curvilinear, or uncollided-flux problems. An unsupported request raises
1351
    ``ValueError`` before any state changes.
1352

1353
    This is a collective operation and must be called on all ranks.
1354
    )"
1355
  );
1356
  do_problem.def(
1,523 ✔
1357
    "SetSteadyStateMode",
1358
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
1,523 ✔
1359
    R"(
1360
    Set the problem to steady-state mode.
1361

1362
    Notes
1363
    -----
1364
    Switch problem from time-dependent to steady-state mode. This updates problem
1365
    internals (sweep chunk mode and source-function) while preserving user boundary
1366
    conditions and fixed sources.
1367

1368
    This is a collective operation and must be called on all ranks.
1369
    )"
1370
  );
1371
  do_problem.def(
1,523 ✔
1372
    "IsTimeDependent",
1373
    &DiscreteOrdinatesProblem::IsTimeDependent,
1,523 ✔
1374
    R"(
1375
    Return ``True`` if the problem is currently in time-dependent mode.
1376
    )"
1377
  );
1378
  do_problem.def(
1,523 ✔
1379
    "SetBoundaryOptions",
1380
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
1,735 ✔
1381
    {
1382
      bool clear_boundary_conditions = false;
212 ✔
1383
      std::vector<InputParameters> boundary_params;
212 ✔
1384
      for (auto [key, value] : params)
812 ✔
1385
      {
1386
        auto c_key = key.cast<std::string>();
388 ✔
1387
        if (c_key == "clear_boundary_conditions")
388 ✔
1388
          clear_boundary_conditions = value.cast<bool>();
176 ✔
1389
        else if (c_key == "boundary_conditions")
212 ✔
1390
        {
1391
          auto boundaries = value.cast<py::list>();
212 ✔
1392
          for (auto boundary : boundaries)
1,095 ✔
1393
          {
1394
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
671 ✔
1395
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
671 ✔
1396
            boundary_params.push_back(std::move(input));
671 ✔
1397
          }
671 ✔
1398
        }
212 ✔
1399
        else
UNCOV
1400
          throw std::invalid_argument("SetBoundaryOptions: unknown argument '" + c_key +
×
1401
                                      "'; expected 'boundary_conditions' or "
UNCOV
1402
                                      "'clear_boundary_conditions'.");
×
1403
      }
388 ✔
1404
      if (clear_boundary_conditions or not boundary_params.empty())
212 ✔
1405
        self.SetBoundaryOptions(boundary_params, clear_boundary_conditions);
212 ✔
1406
    },
212 ✔
1407
    R"(
1408
    Set or clear boundary conditions.
1409

1410
    Parameters
1411
    ----------
1412
    clear_boundary_conditions: bool, default=False
1413
        If true, all current boundary conditions are deleted.
1414
    boundary_conditions: List[Dict]
1415
        A list of boundary condition dictionaries. Each dictionary supports:
1416
          - name: str (required)
1417
              Boundary name that identifies the specific boundary.
1418
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1419
              Boundary type specification.
1420
          - group_strength: List[float], optional
1421
              Required when ``type='isotropic'``. Isotropic strength per group.
1422
          - start_time: float, optional
1423
              Active start time for isotropic boundaries only. Defaults to -infinity.
1424
          - end_time: float, optional
1425
              Active end time for isotropic boundaries only. Defaults to infinity.
1426
          - function: AngularFluxFunction, optional
1427
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1428
              that returns incoming angular flux from group and direction.
1429
          - time_function: AngularFluxTimeFunction, optional
1430
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1431
              returns incoming angular flux from group, direction, and time.
1432

1433
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1434
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1435
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1436
        that callback.
1437

1438
    Notes
1439
    -----
1440
    Mode transitions via :meth:`LBSProblem.SetAdjoint` clear all boundary conditions.
1441
    Reapply boundaries with this method before solving in the new mode.
1442

1443
    This is a collective operation and must be called on all ranks.
1444
    )"
1445
  );
1446
  do_problem.def(
1,523 ✔
1447
    "ZeroPsi",
1448
    [](DiscreteOrdinatesProblem& self)
1,523 ✔
1449
    {
UNCOV
1450
      self.ZeroPsi();
×
1451
    },
1452
    R"(
1453
    Zero the angular-flux storage.
1454
    )"
1455
  );
1456
  do_problem.def(
1,523 ✔
1457
    "GetPsi",
1458
    [](DiscreteOrdinatesProblem& self)
1,787 ✔
1459
    {
1460
      const auto& psi = self.GetPsiNewLocal();
264 ✔
1461
      py::list psi_list;
264 ✔
1462
      for (const auto& vec : psi)
528 ✔
1463
      {
1464
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()));
264 ✔
1465
        std::copy(vec.begin(), vec.end(), static_cast<double*>(array.mutable_data()));
264 ✔
1466
        psi_list.append(array);
264 ✔
1467
      }
264 ✔
1468
      return psi_list;
264 ✔
1469
    },
×
1470
    R"(
1471
    Return psi as a list of NumPy arrays (float64).
1472

1473
    The arrays are copies of the current angular-flux state. Mutating them does not
1474
    mutate the problem.
1475
    )"
1476
  );
1477
  do_problem.def(
1,523 ✔
1478
    "GetAngularFieldFunctionList",
1479
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
1,523 ✔
1480
    {
UNCOV
1481
      std::vector<unsigned int> group_ids;
×
UNCOV
1482
      std::vector<size_t> angle_ids;
×
UNCOV
1483
      group_ids.reserve(groups.size());
×
UNCOV
1484
      angle_ids.reserve(angles.size());
×
1485

UNCOV
1486
      for (py::handle g : groups)
×
UNCOV
1487
        group_ids.push_back(g.cast<unsigned int>());
×
UNCOV
1488
      for (py::handle a : angles)
×
UNCOV
1489
        angle_ids.push_back(a.cast<size_t>());
×
1490

UNCOV
1491
      auto ff_list = self.CreateAngularFluxFieldFunctionList(group_ids, angle_ids);
×
UNCOV
1492
      py::list out;
×
UNCOV
1493
      for (const auto& ff : ff_list)
×
UNCOV
1494
        out.append(ff);
×
UNCOV
1495
      return out;
×
UNCOV
1496
    },
×
1497
    R"(
1498
    Create field functions for selected angular flux components.
1499

1500
    Note: You must enable angular flux storage (``save_angular_flux=True``) in
1501
    the problem options. For transient problems this is required. The angular
1502
    flux is also stored when ``deterministic_sweeps=True``. If neither option
1503
    is set, the returned field functions will remain zero.
1504

1505
    Example
1506
    -------
1507
    .. code-block:: python
1508

1509
       solver.Initialize()
1510
       solver.Execute()
1511
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1512

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

1518
    1) Use ``TransientSolver.Execute()`` with a post-advance callback:
1519

1520
    .. code-block:: python
1521

1522
       solver = TransientSolver(problem=phys)
1523
       solver.Initialize()
1524
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1525

1526
       def post_advance():
1527
           for ff in ang_ff:
1528
               ff.Update()
1529
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1530

1531
       solver.SetPostAdvanceCallback(post_advance)
1532
       solver.Execute()
1533

1534
    2) Use a custom Python loop with ``TransientSolver.Advance()``:
1535

1536
    .. code-block:: python
1537

1538
       solver = TransientSolver(problem=phys)
1539
       solver.Initialize()
1540
       ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1541
       for _ in range(num_steps):
1542
           solver.Advance()
1543
           for ff in ang_ff:
1544
               ff.Update()
1545
           FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1546

1547
    Parameters
1548
    ----------
1549
    groups : List[int]
1550
        Global group indices to export.
1551
    angles : List[int]
1552
        Angle indices within the groupset quadrature for each group.
1553

1554
    Returns
1555
    -------
1556
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
1557
        Field functions for the requested ``(group, angle)`` pairs. Each returned field function
1558
        is a snapshot, but supports ``CanUpdate()`` and ``Update()`` while its owning problem is
1559
        alive.
1560
    )",
1561
    py::arg("groups"),
3,046 ✔
1562
    py::arg("angles")
1,523 ✔
1563
  );
1564
  do_problem.def(
1,523 ✔
1565
    "ComputeLeakage",
1566
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
2,133 ✔
1567
    {
1568
      auto grid = self.GetGrid();
610 ✔
1569
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
610 ✔
1570
      const auto coord_sys = grid->GetCoordinateSystem();
610 ✔
1571
      const auto mesh_type = grid->GetType();
610 ✔
1572
      const auto dim = grid->GetDimension();
610 ✔
1573
      // Resolve the requested boundaries, preserving user order. An empty list selects all.
1574
      std::vector<std::uint64_t> bndry_ids;
610 ✔
1575
      if (not bnd_names.empty())
610 ✔
1576
      {
1577
        for (py::handle name : bnd_names)
2,731 ✔
1578
        {
1579
          const auto sname = name.cast<std::string>();
1,767 ✔
1580
          const auto bid = self.FindBoundaryID(sname);
1,767 ✔
1581
          if (not bid.has_value())
1,767 ✔
1582
            throw std::invalid_argument("ComputeLeakage: Boundary name '" + sname +
4 ✔
1583
                                        "' is not valid for this problem.");
6 ✔
1584
          bndry_ids.push_back(*bid);
1,765 ✔
1585
        }
1,767 ✔
1586
      }
1587
      else
1588
      {
1589
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
383 ✔
1590
      }
1591
      // compute the leakage
1592
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
608 ✔
1593
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
1,216 ✔
1594
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
608 ✔
1595

1596
      auto ToRZName = [](const std::string& name)
686 ✔
1597
      {
1598
        if (name == "xmin") return std::string("rmin");
78 ✔
1599
        if (name == "xmax") return std::string("rmax");
66 ✔
1600
        if (name == "ymin") return std::string("zmin");
44 ✔
1601
        if (name == "ymax") return std::string("zmax");
22 ✔
UNCOV
1602
        return name;
×
1603
      };
1604

1605
      // convert result to native Python
1606
      py::dict result;
608 ✔
1607
      for (const auto& bndry_id : bndry_ids)
3,099 ✔
1608
      {
1609
        const auto it = leakage.find(bndry_id);
2,491 ✔
1610
        if (it == leakage.end())
2,491 ✔
UNCOV
1611
          continue;
×
1612
        // construct numpy array and copy contents
1613
        const auto& grp_wise_leakage = it->second;
2,491 ✔
1614
        py::array_t<double> np_vector(static_cast<py::ssize_t>(grp_wise_leakage.size()));
2,491 ✔
1615
        auto buffer = np_vector.request();
2,491 ✔
1616
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
2,491 ✔
1617
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
2,491 ✔
1618
        std::string name = allowed_bd_ids.at(bndry_id);
2,491 ✔
1619
        if (rz_ortho)
2,491 ✔
1620
          name = ToRZName(name);
78 ✔
1621
        result[py::str(name)] = std::move(np_vector);
4,982 ✔
1622
      }
2,491 ✔
1623

1624
      return result;
608 ✔
1625
    },
1,220 ✔
1626
    R"(
1627
    Compute leakage for the problem.
1628

1629
    Parameters
1630
    ----------
1631
    bnd_names : List[str]
1632
        A list of boundary names for which leakage should be computed. An empty list selects all
1633
        boundaries. RZ problems use rmin, rmax, zmin, and zmax.
1634

1635
    Returns
1636
    -------
1637
    Dict[str, numpy.ndarray]
1638
        A dictionary mapping boundary names to group-wise leakage vectors.
1639
        Each array contains the net leakage (per group): the outgoing partial current minus the
1640
        incoming partial current, integrated over the corresponding boundary surface. On a
1641
        vacuum boundary this equals the outgoing current; on a boundary with an incident flux it
1642
        is reduced by the inflow and can be negative; on a reflecting boundary it vanishes at
1643
        convergence. In RZ geometry the surface integral covers the full revolution.
1644

1645
    Raises
1646
    ------
1647
    ValueError
1648
        If one or more boundary ids are not present on the current mesh.
1649
    )",
1650
    py::arg("bnd_names")
1,523 ✔
1651
  );
1652

1653
  // discrete ordinates curvilinear problem
1654
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
1,523 ✔
1655
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1656
                                           DiscreteOrdinatesProblem>(
1657
    slv,
1658
    "DiscreteOrdinatesCurvilinearProblem",
1659
    R"(
1660
    Base class for discrete ordinates problems in curvilinear geometry.
1661

1662
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1663
    )"
1664
  );
1,523 ✔
1665
  do_curvilinear_problem.def(
3,046 ✔
1666
    py::init(
1,523 ✔
1667
      [](py::kwargs& params)
239 ✔
1668
      {
1669
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
239 ✔
1670
      }
1671
    ),
1672
    R"(
1673
    Construct a discrete ordinates problem for curvilinear geometry.
1674

1675
    Two geometries are supported: 2D cylindrical (``r-z``, a 2D mesh with
1676
    ``coord_sys="cylindrical"`` and :class:`pyopensn.aquad.GLCProductQuadrature2DRZ`) and 1D
1677
    spherical (a 1D radial mesh with ``coord_sys="spherical"`` and
1678
    :class:`pyopensn.aquad.GLProductQuadrature1DSpherical`; boundaries ``zmin`` (inner) and
1679
    ``zmax`` (outer), and a mesh starting at r = 0 is a solid sphere whose center needs no
1680
    boundary condition). Groupsets must use ``angle_aggregation_type`` ``'azimuthal'`` or
1681
    ``'single'``, ``sweep_type`` must be ``'AAH'``, and time-dependent problems and GPU
1682
    acceleration are not supported. Integrated quantities are reported for the full revolution
1683
    (``r-z``) or the full sphere.
1684

1685
    Warnings
1686
    --------
1687
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1688

1689
    Parameters
1690
    ----------
1691
    mesh : Mesh
1692
        The spatial mesh. Its geometry type (cylindrical or spherical) is taken directly from
1693
        the mesh -- e.g. from the ``coord_sys`` argument used to build it with a mesh
1694
        generator. There is no separate ``coord_system`` parameter on this class; supplying
1695
        one is rejected as an unknown parameter.
1696
    num_groups : int
1697
        The total number of energy groups.
1698
    groupsets : list of dict
1699
        A list of input parameter blocks, each block provides the iterative properties for a
1700
        groupset. Each dictionary supports:
1701

1702
          - groups_from_to: List[int] (required)
1703
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1704
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1705
              Handle to an angular quadrature.
1706
          - angle_aggregation_type: {'single', 'azimuthal'}, default='single'
1707
              Angle aggregation method to use during sweeping. Curvilinear problems do not
1708
              support ``'polar'``.
1709
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1710
            'petsc_gmres', 'petsc_fgmres', 'petsc_bicgstab'}, default='petsc_richardson'
1711

1712
              Iterative method used for inner linear solves. ``'petsc_fgmres'`` is flexible
1713
              GMRES with right preconditioning; it remains exact when the WGDSA/TGDSA
1714
              preconditioner solves its diffusion system only approximately.
1715
          - l_abs_tol: float, default=1.0e-6
1716
              Inner linear solver absolute residual tolerance.
1717
          - l_max_its: int, default=200
1718
              Inner linear solver maximum iterations.
1719
          - gmres_restart_interval: int, default=30
1720
              GMRES restart interval, if GMRES is used.
1721
          - allow_cycles: bool, default=True
1722
              Whether cyclic dependencies are allowed in sweeps.
1723
          - apply_wgdsa: bool, default=False
1724
              Enable within-group DSA for this groupset.
1725
          - wgdsa_l_abs_tol: float, default=1.0e-4
1726
              Residual tolerance of the WGDSA diffusion solve, relative to the norm of its
1727
              right-hand side (the name is historical).
1728
          - wgdsa_l_max_its: int, default=30
1729
              WGDSA maximum iterations.
1730
          - wgdsa_verbose: bool, default=False
1731
              Verbose WGDSA output.
1732
          - wgdsa_petsc_options: str, default=''
1733
              PETSc options string for the WGDSA solver.
1734
          - wgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1735
              WGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1736
              ``wgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1737
              otherwise. ``petsc_options`` lets ``wgdsa_petsc_options`` control the
1738
              PETSc KSP/PC setup.
1739
          - wgdsa_direct_solve_threshold: int, default=20000
1740
              Maximum global WGDSA diffusion unknown count for the automatic direct solve.
1741
          - wgdsa_boundary_closure: {'marshak', 'closure_r', 'dirichlet'}, default='marshak'
1742
              Boundary condition of the WGDSA diffusion correction on vacuum, isotropic and
1743
              arbitrary boundaries. ``marshak`` imposes the natural Marshak condition, a face
1744
              mass term with coefficient 1/2; ``closure_r`` blends the
1745
              Marshak form written with the quadrature's partial current (face mass
1746
              coefficient 2 beta_f, beta_f the quadrature's half-range moment, against the
1747
              analytic 1/2) on thin boundary cells with the half-range transport form on
1748
              thick ones, uses beta_f as the floor of the interior penalty, and is faster
1749
              than ``marshak`` when boundary cells are many mean free paths thick;
1750
              ``dirichlet`` (OpenSn's original) imposes a zero correction weakly
1751
              (interior-penalty term). Reflecting boundaries are zero-current.
1752
          - apply_tgdsa: bool, default=False
1753
              Enable two-grid DSA for this groupset.
1754
          - tgdsa_l_abs_tol: float, default=1.0e-4
1755
              Residual tolerance of the TGDSA diffusion solve, relative to the norm of its
1756
              right-hand side (the name is historical).
1757
          - tgdsa_l_max_its: int, default=30
1758
              TGDSA maximum iterations.
1759
          - tgdsa_verbose: bool, default=False
1760
              Verbose TGDSA output.
1761
          - tgdsa_petsc_options: str, default=''
1762
              PETSc options string for the TGDSA solver.
1763
          - tgdsa_solver_policy: {'auto', 'direct', 'iterative', 'petsc_options'}, default='auto'
1764
              TGDSA diffusion solver policy. ``auto`` uses a direct PETSc LU solve below
1765
              ``tgdsa_direct_solve_threshold`` global unknowns and an iterative solve
1766
              otherwise. ``petsc_options`` lets ``tgdsa_petsc_options`` control the
1767
              PETSc KSP/PC setup.
1768
          - tgdsa_direct_solve_threshold: int, default=20000
1769
              Maximum global TGDSA diffusion unknown count for the automatic direct solve.
1770
          - tgdsa_boundary_closure: {'marshak', 'closure_r', 'dirichlet'}, default='marshak'
1771
              Boundary condition of the TGDSA diffusion correction, with the same choices
1772
              and boundaries as ``wgdsa_boundary_closure``.
1773
          - wgdsa_h_perp_convention: {'face_side', 'cell'}, default='face_side'
1774
              Penalty length of the WGDSA MIP operator. ``face_side`` uses the face-dependent
1775
              orthogonal length (3V/A_f tet, V/A_f hex and quad, 2V/A_f triangle) capped by four
1776
              times the smallest height of the face's PWLD side simplices, with an extra factor 2
1777
              on polygons and polyhedra; the cap keeps the operator positive definite on strongly
1778
              distorted cells and is inactive on regular ones. ``cell`` (OpenSn's original) uses
1779
              the cell-wide lengths 3V/S (tet) and V/S (hex) with the extra factor 2, and on
1780
              polygons the face-dependent lengths without the cap or the factor 2. On polyhedra
1781
              its penalty is larger (six times on cubes), which slows DSA on cells a few mean free
1782
              paths thick; on polygons it is half the ``face_side`` penalty and can make the
1783
              operator indefinite on strongly distorted cells.
1784
          - tgdsa_h_perp_convention: {'face_side', 'cell'}, default='face_side'
1785
              Penalty length of the TGDSA MIP operator, as ``wgdsa_h_perp_convention``.
1786
    xs_map : list of dict
1787
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1788
          - block_ids: List[int] (required)
1789
              Mesh block IDs to associate with the cross section.
1790
          - xs: pyopensn.xs.MultiGroupXS (required)
1791
              Cross-section object to assign to the specified blocks.
1792
    boundary_conditions: List[Dict], default=[]
1793
        A list containing tables for each boundary specification. Each dictionary supports:
1794
          - name: str (required)
1795
              Boundary name that identifies the specific boundary.
1796
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1797
              Boundary type specification.
1798
          - group_strength: List[float], optional
1799
              Required when ``type='isotropic'``. Isotropic strength per group.
1800
          - start_time: float, optional
1801
              Active start time for isotropic boundaries only. Defaults to -infinity.
1802
          - end_time: float, optional
1803
              Active end time for isotropic boundaries only. Defaults to infinity.
1804
          - function: AngularFluxFunction, optional
1805
              Required when ``type='arbitrary'`` unless ``time_function`` is supplied. Callable
1806
              that returns incoming angular flux from group and direction.
1807
          - time_function: AngularFluxTimeFunction, optional
1808
              Required when ``type='arbitrary'`` unless ``function`` is supplied. Callable that
1809
              returns incoming angular flux from group, direction, and time.
1810

1811
        Isotropic boundaries may use ``start_time``/``end_time`` for simple on/off behavior.
1812
        Arbitrary boundaries must specify exactly one of ``function`` or ``time_function``; use
1813
        ``time_function`` for time-dependent arbitrary inflow and handle any active window inside
1814
        that callback.
1815
    point_sources: List[pyopensn.source.PointSource], default=[]
1816
        A list of point sources.
1817
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1818
        A list of volumetric sources.
1819
    options : dict, optional
1820
        A block of optional configuration parameters applied at problem creation, including:
1821
          - max_mpi_message_size: int, default=4194304
1822
              Maximum size in bytes of one sweep MPI message. Larger angular-flux transfers
1823
              are split into messages of at most this size.
1824
          - restart_writes_enabled: bool, default=False
1825
              Enable restart dump writes for solvers that support restart output.
1826
          - write_delayed_psi_to_restart: bool, default=True
1827
              Include delayed sweep angular-flux buffers in steady-state restart dumps. These
1828
              buffers are optional when a steady-state restart is used only as a transient
1829
              initial condition because the transient initialization can reconstruct angular
1830
              state from the flux moments. Time-dependent restart dumps always include them,
1831
              regardless of this option, because continuing a transient requires them whenever
1832
              the problem has delayed sweep angular state, including partitioned parallel,
1833
              reflected-boundary, and cyclic-sweep cases.
1834
          - write_angular_flux_to_restart: bool, default=True
1835
              Include stored angular fluxes in steady-state restart dumps when
1836
              ``save_angular_flux=True``. They are optional when a steady-state restart is used
1837
              only as a transient initial condition. Time-dependent restart dumps always
1838
              include them, regardless of this option, because continuing a transient requires
1839
              them.
1840
          - read_restart_path: str, default=''
1841
              File stem for reading a full restart. Scalar flux and precursor data can be
1842
              remapped when the MPI rank count or mesh partition changes. Full transient
1843
              continuation still requires the original partitioned angular state layout.
1844
          - read_initial_condition_path: str, default=''
1845
              File stem for reading restart data as an initial condition. Cannot be combined
1846
              with ``read_restart_path``. A steady-state restart may be used by
1847
              ``TransientSolver`` through this option. The first ``TransientSolver.Initialize()``
1848
              loads it once; later initializations continue from the problem's current state.
1849
          - write_restart_path: str, default=''
1850
              File stem for restart dump writes. OpenSn appends the MPI rank and
1851
              ``.restart.h5`` to this stem.
1852
          - write_restart_time_interval: int, default=0
1853
          - use_precursors: bool, default=True
1854
            Enable delayed neutrons. With ``False`` every solve uses prompt neutrons only, even if
1855
            the cross sections have delayed-neutron data. This remains active across later
1856
            ``SetXSMap`` calls, even if the current map temporarily has no precursor-bearing
1857
            material. When XS maps change, precursor concentrations are remapped by cell and
1858
            precursor-family index; newly introduced families start at zero and removed families
1859
            are discarded.
1860
          - delayed_neutrons: bool, default=True
1861
            Alias of ``use_precursors``. Give at most one of the two names.
1862
          - use_source_moments: bool, default=False
1863
            Use the problem's source moments as the fixed source instead of its
1864
            volumetric and point sources. Provide them (e.g. with ``ReadSourceMoments``)
1865
            after creating the problem and before solving; a solve without them raises
1866
            ``ValueError``. They are an external source, so the k-eigenvalue solvers
1867
            reject a problem with this option.
1868
          - save_angular_flux: bool, default=False
1869
            Store angular flux state (`psi`) for transient mode, angular-flux
1870
            field functions, and angular-flux I/O.
1871
          - deterministic_sweeps: bool, default=False
1872
            Make every sweep bitwise reproducible from run to run: the scalar flux moments and
1873
            face outflows are summed from stored angular fluxes in a fixed angle order instead of
1874
            in the order that MPI messages arrive. Stores the angular flux in host memory without
1875
            enabling ``save_angular_flux``. Meant for debugging and reproducible comparisons.
1876
          - csda_enabled: bool, default=False
1877
            Enable CSDA charged-particle transport for CEPXS data loaded with
1878
            ``csda_format=True``. CSDA is not supported for this curvilinear
1879
            problem class.
1880
          - verbose_inner_iterations: bool, default=True
1881
            Print inner iteration details, including WGS and AGS iterations.
1882
          - verbose_outer_iterations: bool, default=True
1883
            Print outer solver progress, including PI/NLKE iterations and transient steps.
1884
          - max_ags_iterations: int, optional
1885
            Maximum across-groupset iterations per transport solve. A value set here is used by
1886
            every solver. If it is not set, each solver uses its own default: 100 for the
1887
            steady-state and transient solvers, and 1 in each power iteration of
1888
            :class:`pyopensn.solver.PowerIterationKEigenSolver`.
1889
          - ags_tolerance: float, default=1.0e-6
1890
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1891
          - power_default_kappa: float, default=3.20435e-11
1892
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1893
          - field_function_prefix: str, default=''
1894
    sweep_type : str, optional
1895
        The sweep type to use. Curvilinear problems support only `AAH`, the default.
1896
        Time-dependent mode is not supported for curvilinear problems.
1897
    )"
1898
  );
1899
}
1,523 ✔
1900

1901
// Wrap uncollided solver
1902
void
1903
WrapUncollidedSolver(py::module& slv)
1,523 ✔
1904
{
1905
  // clang-format off
1906
  auto uncollided_solver =
1,523 ✔
1907
    py::class_<UncollidedSolver, std::shared_ptr<UncollidedSolver>, Solver>(
1908
      slv,
1909
      "UncollidedSolver",
1910
      R"(
1911
    Generate and write uncollided flux moments for first-collision transport.
1912

1913
    Wrapper of :cpp:class:`opensn::UncollidedSolver`.
1914
    )"
1915
  );
1,523 ✔
1916
  uncollided_solver.def(
3,046 ✔
1917
    py::init(
1,523 ✔
1918
      [](py::kwargs& params)
30 ✔
1919
      {
1920
        return UncollidedSolver::Create(kwargs_to_param_block(params));
30 ✔
1921
      }
1922
    ),
1923
    R"(
1924
    Construct an uncollided transport solver.
1925

1926
    Parameters
1927
    ----------
1928
    problem : pyopensn.solver.UncollidedProblem
1929
        Existing uncollided problem instance.
1930
    file_name : str
1931
        Output HDF5 file containing uncollided flux moments, mesh and total
1932
        cross-section metadata, and production/removal/outflow balance terms.
1933
    progress_interval : int, default=5
1934
        Percentage interval for source-point progress reports. Reports include
1935
        completed source points, elapsed time, and estimated remaining time.
1936
        Set to zero to disable progress reporting. Valid values are integers in
1937
        ``[0, 100]``.
1938

1939
    Notes
1940
    -----
1941
    :meth:`Initialize` requires exactly one MPI rank. The generated HDF5 file
1942
    may then be reused by a serial or parallel collided calculation on the
1943
    same mesh. :meth:`Execute` uses the problem's point sources as they are when
1944
    it runs, including sources replaced after :meth:`Initialize`.
1945

1946
    Internal threading in the current uncollided implementation is capped by
1947
    the ``OPENSN_NUM_THREADS`` environment variable and defaults to ``1`` when
1948
    the variable is unset or invalid.
1949
    )"
1950
  );
1951
  // clang-format on
1952
}
1,523 ✔
1953

1954
// Wrap steady-state solver
1955
void
1956
WrapSteadyState(py::module& slv)
1,523 ✔
1957
{
1958

1959
  // clang-format off
1960
  // steady state solver
1961
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
1,523 ✔
1962
                                        Solver>(
1963
    slv,
1964
    "SteadyStateSourceSolver",
1965
    R"(
1966
    Steady state solver.
1967

1968
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1969
    )"
1970
  );
1,523 ✔
1971
  steady_state_solver.def(
3,046 ✔
1972
    py::init(
1,523 ✔
1973
      [](py::kwargs& params)
1,759 ✔
1974
      {
1975
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
1,759 ✔
1976
      }
1977
    ),
1978
    R"(
1979
    Construct a steady state solver.
1980

1981
    Parameters
1982
    ----------
1983
    problem : pyopensn.solver.DiscreteOrdinatesProblem
1984
        Existing discrete ordinates problem instance.
1985
    compute_balance : bool, optional, default=False
1986
        If true, print the balance table after :meth:`Execute` completes.
1987

1988
    Notes
1989
    -----
1990
    If the problem was constructed with ``options={'read_restart_path': ...}``,
1991
    restart data is read during :meth:`Initialize`. If it was constructed with
1992
    ``options={'restart_writes_enabled': True, ...}``, a restart dump is written
1993
    after :meth:`Execute` completes.
1994

1995
    Restart files are rank-layout specific. A restart written with ``N`` MPI
1996
    ranks must be read with the same rank count and a compatible problem
1997
    definition.
1998
    )"
1999
  );
2000
  steady_state_solver.def(
1,523 ✔
2001
    "GetNumSweeps",
2002
    &SteadyStateSourceSolver::GetNumSweeps,
1,523 ✔
2003
    R"(
2004
    Return the cumulative number of transport sweeps performed by all groupsets.
2005

2006
    Returns
2007
    -------
2008
    int
2009
        Total number of transport sweeps.
2010
    )"
2011
  );
2012
  steady_state_solver.def(
1,523 ✔
2013
    "IsConverged",
2014
    &SteadyStateSourceSolver::IsConverged,
1,523 ✔
2015
    R"(
2016
    Return True if the transport solve of the last :meth:`Execute` converged.
2017

2018
    With more than one groupset this is the across-groupset iteration, otherwise the
2019
    within-group solve of the single groupset. Returns False if the iteration limit was
2020
    reached or the solve failed, if the solve applied no convergence test (a single sweep,
2021
    ``petsc_richardson`` with ``l_max_its = 1``), and before the first :meth:`Execute`.
2022
    )"
2023
  );
2024
  steady_state_solver.def(
1,523 ✔
2025
    "ComputeBalanceTable",
2026
    [](const SteadyStateSourceSolver& self)
1,523 ✔
2027
    {
2028
      return BalanceTableToDict(self.ComputeBalanceTable());
498 ✔
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. On reflecting boundaries the outgoing partial
2048
          current is counted as the incoming one, so reflecting faces add equal
2049
          amounts to ``inflow_rate`` and ``outflow_rate`` and cancel in ``balance``.
2050
        - ``outflow_rate``:
2051
          Global outgoing boundary contribution accumulated from face outflow
2052
          tallies, including reflecting boundaries (see ``inflow_rate``).
2053
        - ``balance``:
2054
          Rate balance,
2055
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2056
          Omitted when CSDA is enabled, because it does not include CSDA
2057
          particle deposition; use ``csda_particle_balance`` instead.
2058
        - ``csda_particle_deposition_rate``:
2059
          Present only when CSDA is enabled. Rate at which particles slow down
2060
          out of the lowest-energy group of each charged-particle block. Electron
2061
          and positron contributions are both counted as positive.
2062
        - ``csda_particle_balance``:
2063
          Present only when CSDA is enabled. Signed relative particle residual:
2064
          ``production_rate + inflow_rate - absorption_rate - outflow_rate -
2065
          csda_particle_deposition_rate``, divided by
2066
          ``production_rate + inflow_rate``. Zero gain gives zero for a zero
2067
          residual and signed infinity otherwise.
2068
        - ``csda_energy_production_rate``, ``csda_energy_inflow_rate``, and
2069
          ``csda_energy_outflow_rate``:
2070
          Present only when CSDA is enabled. Production, boundary inflow, and
2071
          boundary outflow rates, each weighted by group-midpoint energy. For a
2072
          converged solve, ``csda_energy_production_rate +
2073
          csda_energy_inflow_rate - csda_energy_outflow_rate`` equals the
2074
          integral of the ``csda_energy_deposition`` field function.
2075
        - ``csda_energy_balance``:
2076
          Present only when CSDA is enabled. Signed relative energy residual:
2077
          energy production plus boundary inflow, minus boundary outflow,
2078
          collision energy loss, and CSDA continuous energy loss, all weighted by
2079
          group-midpoint energy, divided by
2080
          ``csda_energy_production_rate + csda_energy_inflow_rate``. Zero gain
2081
          gives zero for a zero residual and signed infinity otherwise.
2082

2083
    Notes
2084
    -----
2085
    This solver applies no extra normalization to the balance table.
2086
    CSDA balance entries are convergence-sensitive and should be interpreted only
2087
    after the linear solve has converged to the requested tolerance.
2088
    )"
2089
  );
2090
  // clang-format on
2091
}
1,523 ✔
2092

2093
// Wrap transient solver
2094
void
2095
WrapTransient(py::module& slv)
1,523 ✔
2096
{
2097
  // clang-format off
2098
  auto transient_solver =
1,523 ✔
2099
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
2100
      slv,
2101
      "TransientSolver",
2102
      R"(
2103
      Transient solver.
2104

2105
      Wrapper of :cpp:class:`opensn::TransientSolver`.
2106
      )"
2107
    );
1,523 ✔
2108
  transient_solver.def(
3,046 ✔
2109
    py::init(
1,523 ✔
2110
      [](py::kwargs& params)
884 ✔
2111
      {
2112
        return TransientSolver::Create(kwargs_to_param_block(params));
884 ✔
2113
      }
2114
    ),
2115
    R"(
2116
    Construct a transient solver.
2117

2118
    Parameters
2119
    ----------
2120
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
2121
        Existing discrete ordinates problem instance.
2122
    dt : float, optional, default=2.0e-3
2123
        Time step size used during the simulation.
2124
    stop_time : float, optional, default=0.1
2125
        Absolute end time of the transient, not a duration. A solver that starts after
2126
        another one on the same problem continues from that problem's time.
2127
    theta : float, optional, default=0.5
2128
        Time differencing scheme parameter.
2129
    initial_state : str, optional, default="existing"
2130
        Initial state for the transient solve. Allowed values: existing, zero.
2131
        "existing" continues from the problem's current solution and time. "zero"
2132
        resets the scalar flux, precursors, and stored angular fluxes to zero and starts
2133
        at time 0. Restart data and a pending initial condition
2134
        (``read_initial_condition_path``) take precedence.
2135
    verbose : bool, optional, default=True
2136
        Enable verbose logging.
2137
    compute_balance : bool, optional, default=False
2138
        If true, print the timestep balance table (the one :meth:`ComputeBalanceTable`
2139
        returns) after every timestep, whether taken by :meth:`Execute` or :meth:`Advance`.
2140

2141
    Notes
2142
    -----
2143
    The associated problem must have ``save_angular_flux=True`` enabled. This
2144
    is required for transient problems.
2145

2146
    If the problem was constructed with ``options={'read_restart_path': ...}``,
2147
    restart data is read during :meth:`Initialize`. If it was constructed with
2148
    ``options={'read_initial_condition_path': ...}``, restart data is read as a
2149
    transient initial condition during :meth:`Initialize`, then the problem is
2150
    switched to time-dependent mode; a steady-state restart may be used in this
2151
    mode. In this initial-condition path, stored angular fluxes and delayed
2152
    sweep angular-flux buffers are optional in the steady-state restart; the
2153
    transient solver reconstructs angular state from the loaded flux moments.
2154
    If it was constructed with
2155
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
2156
    be written during :meth:`Execute` and a final restart dump is written when
2157
    execution completes.
2158

2159
    A full transient continuation restart read with ``read_restart_path`` is
2160
    different from a steady-state initial-condition restart read with
2161
    ``read_initial_condition_path``. Full transient continuation restarts require
2162
    angular flux state in the restart file; if the problem has delayed sweep
2163
    angular state, including partitioned parallel, reflected-boundary, or
2164
    cyclic-sweep cases, continuation also requires delayed sweep angular-flux
2165
    buffers in the restart. Restart files are rank-layout specific: use the same MPI rank
2166
    count and compatible problem definition when reading files written by a
2167
    previous run.
2168
    )"
2169
  );
2170
  transient_solver.def(
1,523 ✔
2171
    "SetTimeStep",
2172
    &TransientSolver::SetTimeStep,
1,523 ✔
2173
    R"(
2174
    Set the timestep size used by :meth:`Advance`.
2175

2176
    Parameters
2177
    ----------
2178
    dt : float
2179
        New timestep size.
2180
    )");
2181
  transient_solver.def(
1,523 ✔
2182
    "SetTheta",
2183
    &TransientSolver::SetTheta,
1,523 ✔
2184
    R"(
2185
    Set the theta parameter used by :meth:`Advance`.
2186

2187
    Parameters
2188
    ----------
2189
    theta : float
2190
        Theta value between 1.0e-16 and 1.
2191
    )");
2192
  transient_solver.def(
1,523 ✔
2193
    "Advance",
2194
    &TransientSolver::Advance,
1,523 ✔
2195
    R"(
2196
    Advance the solver by a single timestep.
2197

2198
    Notes
2199
    -----
2200
    You must call :meth:`Initialize` before calling :meth:`Advance` or
2201
    :meth:`Execute`.
2202
    )");
2203
  transient_solver.def(
1,523 ✔
2204
    "SetPreAdvanceCallback",
2205
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,523 ✔
2206
      &TransientSolver::SetPreAdvanceCallback),
2207
    R"(
2208
    Register a callback that runs before each advance within :meth:`Execute`.
2209

2210
    :param callback: Function invoked before the solver advances a timestep. Pass None
2211
        to clear. If the callback modifies the timestep, the new value is used for the
2212
        upcoming step.
2213
    :type callback: Optional[Callable[[], None]]
2214
    )");
2215
  transient_solver.def(
1,523 ✔
2216
    "SetPreAdvanceCallback",
2217
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPreAdvanceCallback),
1,523 ✔
2218
    "Clear the PreAdvance callback by passing None.");
2219
  transient_solver.def(
1,523 ✔
2220
    "SetPostAdvanceCallback",
2221
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
1,523 ✔
2222
      &TransientSolver::SetPostAdvanceCallback),
2223
    R"(
2224
    Register a callback that runs after each advance within :meth:`Execute`.
2225

2226
    :param callback: Function invoked after the solver advances a timestep. Pass None
2227
        to clear.
2228
    :type callback: Optional[Callable[[], None]]
2229
    )");
2230
  transient_solver.def(
1,523 ✔
2231
    "SetPostAdvanceCallback",
2232
    py::overload_cast<std::nullptr_t>(&TransientSolver::SetPostAdvanceCallback),
1,523 ✔
2233
    "Clear the PostAdvance callback by passing None.");
2234
  transient_solver.def(
1,523 ✔
2235
    "IsConverged",
2236
    &TransientSolver::IsConverged,
1,523 ✔
2237
    R"(
2238
    Return True if the transport solve of every time step since :meth:`Initialize` converged.
2239

2240
    A time step whose solve reaches its iteration limit, fails, or applies no convergence test
2241
    (a single sweep, ``petsc_richardson`` with ``l_max_its = 1``) makes this False until the
2242
    next :meth:`Initialize`, so checking it after each :meth:`Advance` identifies the first
2243
    such step. Returns False before the first time step.
2244
    )"
2245
  );
2246
  transient_solver.def(
1,523 ✔
2247
    "ComputeBalanceTable",
2248
    [](const TransientSolver& self)
1,739 ✔
2249
    {
2250
      return BalanceTableToDict(self.ComputeBalanceTable());
216 ✔
2251
    },
2252
    R"(
2253
    Compute and return the global balance table using the solver's normalization.
2254
    This is a collective operation and must be called on all ranks.
2255

2256
    Returns
2257
    -------
2258
    dict
2259
        Dictionary with the following entries:
2260

2261
        - ``absorption_rate``:
2262
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2263
          groups and the full domain.
2264
        - ``production_rate``:
2265
          Global volumetric production/source rate used by the solver,
2266
          approximately ``integral Q dV`` summed over groups and the full domain.
2267
        - ``inflow_rate``:
2268
          Global incoming boundary contribution integrated over incoming
2269
          angular flux on boundaries. On reflecting boundaries the outgoing partial
2270
          current is counted as the incoming one, so reflecting faces add equal
2271
          amounts to ``inflow_rate`` and ``outflow_rate`` and cancel in ``balance``.
2272
        - ``outflow_rate``:
2273
          Global outgoing boundary contribution accumulated from face outflow
2274
          tallies, including reflecting boundaries (see ``inflow_rate``).
2275
        - ``balance``:
2276
          Rate balance,
2277
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2278
        - ``initial_inventory``:
2279
          Total particle inventory at the start of the timestep, computed as
2280
          ``integral (1 / v_g) * phi_old dV`` summed over groups and the full domain.
2281
        - ``final_inventory``:
2282
          Total particle inventory at the end of the timestep, computed as
2283
          ``integral (1 / v_g) * phi_new dV`` summed over groups and the full domain.
2284
        - ``predicted_inventory_change``:
2285
          Inventory change predicted by the current timestep balance, computed as
2286
          ``dt * balance``.
2287
        - ``actual_inventory_change``:
2288
          Measured change in total particle inventory over the timestep, computed as
2289
          ``final_inventory - initial_inventory``.
2290
        - ``inventory_residual``:
2291
          Mismatch between the measured and predicted timestep inventory
2292
          changes, computed as
2293
          ``actual_inventory_change - predicted_inventory_change``.
2294

2295
    Notes
2296
    -----
2297
    This solver applies no extra normalization to the balance table.
2298

2299
    The transient inventory terms currently use the end-of-step rate balance to
2300
    estimate the timestep inventory change.
2301
    )"
2302
  );
2303
  slv.attr("BackwardEuler") = 1.0;
1,523 ✔
2304
  slv.attr("CrankNicolson") = 0.5;
3,046 ✔
2305
  // clang-format on
2306
}
1,523 ✔
2307

2308
// Wrap non-linear k-eigen solver
2309
void
2310
WrapNLKEigen(py::module& slv)
1,523 ✔
2311
{
2312
  // clang-format off
2313
  // non-linear k-eigen solver
2314
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
1,523 ✔
2315
                                              Solver>(
2316
    slv,
2317
    "NonLinearKEigenSolver",
2318
    R"(
2319
    Non-linear k-eigenvalue solver.
2320

2321
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
2322

2323
    Supports one or more groupsets when the groupsets run without WGDSA/TGDSA.
2324
    If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen solve
2325
    must use a single groupset; otherwise ``Initialize()`` raises a ``ValueError``.
2326

2327
    The eigenvector is returned with an arbitrary scale that depends on the solver, the
2328
    acceleration method, and the starting flux. Normalize absolute quantities (power, reaction
2329
    rates, leakage, balance rates) explicitly, e.g. with ``power_normalization_target``.
2330
    )"
2331
  );
1,523 ✔
2332
  non_linear_k_eigen_solver.def(
3,046 ✔
2333
    py::init(
1,523 ✔
2334
      [](py::kwargs& params)
74 ✔
2335
      {
2336
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
74 ✔
2337
      }
2338
        ),
2339
    R"(
2340
    Construct a non-linear k-eigenvalue solver.
2341

2342
    Parameters
2343
    ----------
2344
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2345
        Existing discrete ordinates problem instance.
2346
        Multiple groupsets are supported when groupset WGDSA/TGDSA is disabled.
2347
        If WGDSA or TGDSA is enabled on any groupset, the nonlinear k-eigen
2348
        solve must use a single groupset.
2349
    nl_abs_tol: float, default=1.0e-8
2350
        Non-linear absolute tolerance.
2351
    nl_rel_tol: float, default=1.0e-8
2352
        Non-linear relative tolerance.
2353
    nl_sol_tol: float, default=1.0e-50
2354
        Non-linear solution tolerance.
2355
    nl_max_its: int, default=50
2356
        Non-linear algorithm maximum iterations.
2357
    l_abs_tol: float, default=1.0e-8
2358
        Linear absolute tolerance. The value used is ``min(l_abs_tol, nl_abs_tol)``, so
2359
        that the linear solves can reduce the nonlinear residual below ``nl_abs_tol``.
2360
    l_rel_tol: float, default=1.0e-8
2361
        Linear relative tolerance.
2362
    l_div_tol: float, default=1.0e6
2363
        Linear divergence tolerance.
2364
    l_max_its: int, default=50
2365
        Linear algorithm maximum iterations.
2366
    l_gmres_restart_intvl: int, default=30
2367
        GMRES restart interval.
2368
    l_gmres_breakdown_tol: float, default=0.1
2369
        GMRES breakdown tolerance.
2370
    reset_phi0: bool, default=True
2371
        If true, reinitializes scalar fluxes to 1.0.
2372
    num_initial_power_iterations: int, default=0
2373
        Number of initial power iterations before the non-linear solve.
2374
    compute_balance: bool, default=False
2375
        If true, print the balance table, with production scaled by ``1/k_eff``, after
2376
        :meth:`Execute` completes.
2377

2378
    Notes
2379
    -----
2380
    PETSc convergence failures and iteration limits are reported through the
2381
    solver status and log output, consistent with the other transport solvers.
2382
    Invalid residual states are reported to PETSc as function-domain
2383
    errors so SNES can backtrack or terminate with the appropriate reason.
2384
    )"
2385
  );
2386
  non_linear_k_eigen_solver.def(
1,523 ✔
2387
    "GetEigenvalue",
2388
    &NonLinearKEigenSolver::GetEigenvalue,
1,523 ✔
2389
    R"(
2390
    Return the current k-eigenvalue.
2391
    )"
2392
  );
2393
  non_linear_k_eigen_solver.def(
1,523 ✔
2394
    "GetNumSweeps",
2395
    &NonLinearKEigenSolver::GetNumSweeps,
1,523 ✔
2396
    R"(
2397
    Return the cumulative number of transport sweeps performed by all groupsets, including
2398
    those of the initial power iterations.
2399

2400
    Returns
2401
    -------
2402
    int
2403
        Total number of transport sweeps.
2404
    )"
2405
  );
2406
  non_linear_k_eigen_solver.def(
1,523 ✔
2407
    "IsConverged",
2408
    &NonLinearKEigenSolver::IsConverged,
1,523 ✔
2409
    R"(
2410
    Return True if the nonlinear solve of the last :meth:`Execute` converged.
2411

2412
    Returns False if the nonlinear solver stopped on its iteration limit or another
2413
    divergence reason, and before the first :meth:`Execute`.
2414
    )"
2415
  );
2416
  non_linear_k_eigen_solver.def(
1,523 ✔
2417
    "ComputeBalanceTable",
2418
    [](const NonLinearKEigenSolver& self)
1,523 ✔
2419
    {
UNCOV
2420
      return BalanceTableToDict(self.ComputeBalanceTable());
×
2421
    },
2422
    R"(
2423
    Compute and return the global balance table using the solver's normalization.
2424
    This is a collective operation and must be called on all ranks.
2425

2426
    Returns
2427
    -------
2428
    dict
2429
        Dictionary with the following entries:
2430

2431
        - ``absorption_rate``:
2432
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2433
          groups and the full domain.
2434
        - ``production_rate``:
2435
          Global volumetric production/source rate used by the solver,
2436
          approximately ``integral Q dV`` summed over groups and the full domain.
2437
        - ``inflow_rate``:
2438
          Global incoming boundary contribution integrated over incoming
2439
          angular flux on boundaries. On reflecting boundaries the outgoing partial
2440
          current is counted as the incoming one, so reflecting faces add equal
2441
          amounts to ``inflow_rate`` and ``outflow_rate`` and cancel in ``balance``.
2442
        - ``outflow_rate``:
2443
          Global outgoing boundary contribution accumulated from face outflow
2444
          tallies, including reflecting boundaries (see ``inflow_rate``).
2445
        - ``balance``:
2446
          Rate balance,
2447
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2448

2449
    Notes
2450
    -----
2451
    For k-eigenvalue balance reporting, this solver scales the production term by
2452
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2453
    )"
2454
  );
2455
  // clang-format on
2456
}
1,523 ✔
2457

2458
// Wrap power iteration solvers
2459
void
2460
WrapPIteration(py::module& slv)
1,523 ✔
2461
{
2462
  // clang-format off
2463
  // power iteration k-eigen solver
2464
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
1,523 ✔
2465
                                      Solver>(
2466
    slv,
2467
    "PowerIterationKEigenSolver",
2468
    R"(
2469
    Power iteration k-eigenvalue solver.
2470

2471
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
2472

2473
    The eigenvector is returned with an arbitrary scale that depends on the solver, the
2474
    acceleration method, and the starting flux. Normalize absolute quantities (power, reaction
2475
    rates, leakage, balance rates) explicitly, e.g. with ``power_normalization_target``.
2476
    )"
2477
  );
1,523 ✔
2478
  pi_k_eigen_solver.def(
3,046 ✔
2479
    py::init(
1,523 ✔
2480
      [](py::kwargs& params)
725 ✔
2481
      {
2482
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
725 ✔
2483
      }
2484
    ),
2485
    R"(
2486
    Construct a power iteration k-eigen solver.
2487

2488
    Parameters
2489
    ----------
2490
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2491
        Existing DiscreteOrdinatesProblem instance.
2492
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
2493
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration. It must be
2494
        created for the same ``problem``. ``Initialize()`` checks the requirements of the
2495
        acceleration method against the problem and raises a ``ValueError`` listing every
2496
        violation.
2497
    max_iters: int, default = 1000
2498
        Maximum power iterations allowed.
2499
    k_tol: float, default = 1.0e-10
2500
        Tolerance on the estimated relative error of the k-eigenvalue. The iteration converges
2501
        when the relative change of k between power iterations is below ``k_tol * (1 - rho)``
2502
        in two consecutive iterations, where ``rho`` is the convergence rate estimated from the
2503
        change of the production-normalized flux. Values below ``1.0e-12`` are treated as
2504
        ``1.0e-12``.
2505
    reset_phi0: bool, default=True
2506
        If true, reinitializes scalar fluxes to 1.0.
2507
    compute_balance: bool, default=False
2508
        If true, print the balance table, with production scaled by ``1/k_eff``, after
2509
        :meth:`Execute` completes.
2510

2511
    Notes
2512
    -----
2513
    With more than one groupset, each power iteration makes one across-groupset pass unless the
2514
    problem sets ``max_ags_iterations``.
2515

2516
    If the problem was constructed with ``options={'read_restart_path': ...}``,
2517
    restart data is read during :meth:`Initialize`. If it was constructed with
2518
    ``options={'restart_writes_enabled': True, ...}``, timed restart dumps may
2519
    be written during the outer iteration loop and a final restart dump is
2520
    written when execution completes.
2521
    )"
2522
  );
2523
  pi_k_eigen_solver.def(
1,523 ✔
2524
    "GetEigenvalue",
2525
    &PowerIterationKEigenSolver::GetEigenvalue,
1,523 ✔
2526
    R"(
2527
    Return the current k-eigenvalue.
2528
    )"
2529
  );
2530
  pi_k_eigen_solver.def(
1,523 ✔
2531
    "GetNumPowerIterations",
2532
    &PowerIterationKEigenSolver::GetNumPowerIterations,
1,523 ✔
2533
    R"(
2534
    Return the number of completed power iterations.
2535
    )"
2536
  );
2537
  pi_k_eigen_solver.def(
1,523 ✔
2538
    "GetNumSweeps",
2539
    &PowerIterationKEigenSolver::GetNumSweeps,
1,523 ✔
2540
    R"(
2541
    Return the total number of transport sweeps applied by all WGS solvers.
2542
    )"
2543
  );
2544
  pi_k_eigen_solver.def(
1,523 ✔
2545
    "IsConverged",
2546
    &PowerIterationKEigenSolver::IsConverged,
1,523 ✔
2547
    R"(
2548
    Return True if the last :meth:`Execute` met the convergence criteria.
2549

2550
    Convergence requires the k-eigenvalue test on ``k_tol`` to pass and, when an
2551
    acceleration method is attached, its own convergence check (for CMFD, the balance
2552
    residual) to pass. Returns False if ``max_iters`` was reached first.
2553
    )"
2554
  );
2555
  pi_k_eigen_solver.def(
1,523 ✔
2556
    "ComputeBalanceTable",
2557
    [](const PowerIterationKEigenSolver& self)
1,523 ✔
2558
    {
2559
      return BalanceTableToDict(self.ComputeBalanceTable());
35 ✔
2560
    },
2561
    R"(
2562
    Compute and return the global balance table using the solver's normalization.
2563
    This is a collective operation and must be called on all ranks.
2564

2565
    Returns
2566
    -------
2567
    dict
2568
        Dictionary with the following entries:
2569

2570
        - ``absorption_rate``:
2571
          Global absorption rate, approximately ``integral sigma_a * phi dV`` summed over
2572
          groups and the full domain.
2573
        - ``production_rate``:
2574
          Global volumetric production/source rate used by the solver,
2575
          approximately ``integral Q dV`` summed over groups and the full domain.
2576
        - ``inflow_rate``:
2577
          Global incoming boundary contribution integrated over incoming
2578
          angular flux on boundaries. On reflecting boundaries the outgoing partial
2579
          current is counted as the incoming one, so reflecting faces add equal
2580
          amounts to ``inflow_rate`` and ``outflow_rate`` and cancel in ``balance``.
2581
        - ``outflow_rate``:
2582
          Global outgoing boundary contribution accumulated from face outflow
2583
          tallies, including reflecting boundaries (see ``inflow_rate``).
2584
        - ``balance``:
2585
          Rate balance,
2586
          ``production_rate + inflow_rate - absorption_rate - outflow_rate``.
2587

2588
    Notes
2589
    -----
2590
    For k-eigenvalue balance reporting, this solver scales the production term by
2591
    ``1 / k_eff`` before forming both ``production_rate`` and ``balance``.
2592
    )"
2593
  );
2594
  // clang-format on
2595
}
1,523 ✔
2596

2597
// Wrap LBS solver
2598
void
2599
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
1,523 ✔
2600
{
2601
  // clang-format off
2602
  // discrete ordinates k-eigen acceleration base
2603
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
1,523 ✔
2604
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
2605
    slv,
2606
    "DiscreteOrdinatesKEigenAcceleration",
2607
    R"(
2608
    Base class for discrete ordinates k-eigenvalue acceleration methods.
2609

2610
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
2611
    )"
2612
  );
1,523 ✔
2613
  // SCDSA acceleration
2614
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
1,523 ✔
2615
                                       std::shared_ptr<SCDSAAcceleration>,
2616
                                       DiscreteOrdinatesKEigenAcceleration>(
2617
    slv,
2618
    "SCDSAAcceleration",
2619
    R"(
2620
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
2621

2622
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
2623

2624
    Requires a single groupset and, with the AAH sweep, ``l_max_its`` greater than one.
2625
    ``sdm='pwlc'`` is not supported in 1D spherical geometry. The owning solver checks these
2626
    requirements in ``Initialize()``. With ``l_max_its`` greater than one, each power iteration
2627
    performs one sweep after the groupset solve to form the scalar-flux change, i.e. one sweep more
2628
    than ``l_max_its``.
2629
    )"
2630
  );
1,523 ✔
2631
  scdsa_acceleration.def(
3,046 ✔
2632
    py::init(
1,523 ✔
2633
      [](py::kwargs& params)
32 ✔
2634
      {
2635
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
32 ✔
2636
      }
2637
    ),
2638
    R"(
2639
    SCDSA acceleration for the power iteration k-eigenvalue solver.
2640

2641
    Parameters
2642
    ----------
2643
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2644
        Existing DiscreteOrdinatesProblem instance.
2645
    l_abs_tol: float, default=1.0e-10
2646
        Residual tolerance of the diffusion solves, relative to the norm of their right-hand
2647
        side (the name is historical).
2648
    max_iters: int, default=100
2649
        Maximum allowable iterations.
2650
    verbose: bool, default=False
2651
        If true, enables verbose output.
2652
    petsc_options: str, default="ssss"
2653
        Additional PETSc options.
2654
    pi_max_its: int, default=50
2655
        Maximum allowable iterations for inner power iterations.
2656
    pi_k_tol: float, default=1.0e-10
2657
        k-eigenvalue tolerance for the inner power iterations.
2658
    sdm: str, default="pwld"
2659
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2660
            - 'pwld' : Piecewise Linear Discontinuous
2661
            - 'pwlc' : Piecewise Linear Continuous
2662
    )"
2663
  );
2664
  // SMM acceleration
2665
  auto smm_acceleration = py::class_<SMMAcceleration,
1,523 ✔
2666
                                     std::shared_ptr<SMMAcceleration>,
2667
                                     DiscreteOrdinatesKEigenAcceleration>(
2668
    slv,
2669
    "SMMAcceleration",
2670
    R"(
2671
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
2672

2673
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
2674

2675
    Requires a single groupset, isotropic (P0) scattering, ``options.save_angular_flux=True``,
2676
    only vacuum and reflecting boundaries (an isotropic boundary is rejected even with zero
2677
    strength), and, with the AAH sweep, ``l_max_its`` greater than one. The owning solver checks
2678
    these requirements in ``Initialize()``.
2679
    )"
2680
  );
1,523 ✔
2681
  smm_acceleration.def(
3,046 ✔
2682
    py::init(
1,523 ✔
2683
      [](py::kwargs& params)
24 ✔
2684
      {
2685
        return SMMAcceleration::Create(kwargs_to_param_block(params));
24 ✔
2686
      }
2687
    ),
2688
    R"(
2689
    SMM acceleration for the power iteration k-eigenvalue solver.
2690

2691
    Warnings
2692
    --------
2693
       SMM acceleration is **experimental** and should be used with caution!
2694
       SMM acceleration only supports problems with isotropic scattering.
2695

2696
    Parameters
2697
    ----------
2698
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2699
        Existing DiscreteOrdinatesProblem instance.
2700
    l_abs_tol: float, default=1.0e-10
2701
        Residual tolerance of the diffusion solves, relative to the norm of their right-hand
2702
        side (the name is historical).
2703
    max_iters: int, default=100
2704
        Maximum allowable iterations.
2705
    verbose: bool, default=False
2706
        If true, enables verbose output.
2707
    petsc_options: str, default="ssss"
2708
        Additional PETSc options.
2709
    pi_max_its: int, default=50
2710
        Maximum allowable iterations for inner power iterations.
2711
    pi_k_tol: float, default=1.0e-10
2712
        k-eigenvalue tolerance for the inner power iterations.
2713
    sdm: str, default="pwlc"
2714
        Spatial discretization method to use for the diffusion solver. Valid choices are:
2715
            - 'pwld' : Piecewise Linear Discontinuous
2716
            - 'pwlc' : Piecewise Linear Continuous
2717
    )"
2718
  );
2719
  // CMFD acceleration
2720
  auto cmfd_acceleration = py::class_<CMFDAcceleration,
1,523 ✔
2721
                                      std::shared_ptr<CMFDAcceleration>,
2722
                                      DiscreteOrdinatesKEigenAcceleration>(
2723
    slv,
2724
    "CMFDAcceleration",
2725
    R"(
2726
    Construct a CMFD accelerator for the power iteration k-eigenvalue solver.
2727

2728
    Wrapper of :cpp:class:`opensn::CMFDAcceleration`.
2729
    )"
2730
  );
1,523 ✔
2731
  cmfd_acceleration.def(
3,046 ✔
2732
    py::init(
1,523 ✔
2733
      [](py::kwargs& params)
154 ✔
2734
      {
2735
        return CMFDAcceleration::Create(kwargs_to_param_block(params));
154 ✔
2736
      }
2737
    ),
2738
    R"(
2739
    CMFD acceleration for the power iteration k-eigenvalue solver.
2740

2741
    With CMFD, each power iteration performs a configured number of high-order WGS
2742
    transport update iterations, then applies a bounded low-order scalar-flux correction.
2743
    The defaults use one WGS update iteration, the partial-current closure, fixed
2744
    correction relaxation, and a transport-current balance gate before outer power
2745
    iteration is allowed to converge.
2746

2747
    Most users should tune only the common controls: ``coarse_mesh``, ``current_closure``,
2748
    ``aggregation_size``, ``group_aggregation_size``, ``relaxation``,
2749
    ``update_wgs_max_its``, ``update_wgs_abs_tol``, and
2750
    ``balance_residual_tolerance``. Parameters marked "developer/debug" are exposed for
2751
    investigations and regression testing, but normally should be left at their defaults.
2752

2753
    Parameters
2754
    ----------
2755
    problem: pyopensn.solver.DiscreteOrdinatesProblem
2756
        Existing DiscreteOrdinatesProblem instance.
2757
    coarse_mesh: str, default="local_aggregation"
2758
        Common option. Coarse-mesh construction method. Valid choices are:
2759
            - 'identity' : one CMFD coarse cell per transport cell
2760
            - 'local_aggregation' : connected same-block fine cells aggregated locally
2761
            - 'global_aggregation' : connected same-block fine cells aggregated across MPI ranks
2762

2763
        ``"local_aggregation"`` is the conservative default. ``"global_aggregation"`` can
2764
        reduce the coarse problem size on larger distributed meshes. Global aggregation is a
2765
        logical CMFD coarse-space construction: it does not repartition the transport mesh or
2766
        change fine-cell ownership. Each global coarse cell has one owning rank, while ranks
2767
        owning member fine cells keep membership records for restriction and prolongation.
2768
        Aggregates are connected by face adjacency, remain within one mesh block, and may be
2769
        smaller than ``aggregation_size`` near boundaries or disconnected regions. ``"identity"``
2770
        is primarily for debugging and method comparisons.
2771
    current_closure: str, default="partial"
2772
        Common option. CMFD face-current closure. Valid choices are:
2773
            - 'partial' : partial-current CMFD (pCMFD); each one-sided partial current carries
2774
              half of the diffusion coupling plus its own transport correction
2775
            - 'net' : match the signed transport current across each coarse face
2776

2777
        ``"partial"`` is the default and is recommended for production use: it is typically
2778
        stable with an unrelaxed correction on both optically thin and thick coarse cells,
2779
        whereas the net-current closure can oscillate or diverge on optically thick coarse
2780
        cells. Both closures share the same fixed point. ``"net"`` can converge in fewer
2781
        iterations on some problems with optically thin coarse cells.
2782
    aggregation_size: int, default=32
2783
        Common option. Target number of fine cells per aggregated coarse cell for
2784
        ``coarse_mesh="local_aggregation"`` or ``coarse_mesh="global_aggregation"``.
2785
        Larger values reduce CMFD matrix size and
2786
        setup/solve cost, but make the spatial correction less detailed. Smaller values
2787
        increase the coarse-system cost but may improve robustness.
2788
    group_aggregation_size: int, default=1
2789
        Common option. Number of transport energy groups per CMFD coarse group, not the
2790
        final number of coarse groups. A value of 1 preserves the full transport group
2791
        structure in the low-order system. To target ``N`` total coarse groups, use
2792
        ``(num_groups + N - 1) // N``.
2793
    relaxation: float, default=1.0
2794
        Common option. Strictly positive relaxation factor applied to the CMFD scalar-flux correction.
2795
        This is the requested correction strength. The correction limiter may damp or
2796
        skip an individual correction if the requested update would produce an invalid
2797
        k-eigenvalue or non-finite flux, or would change the sign of the scalar flux in
2798
        some coarse cell and coarse group. The default
2799
        applies the full correction. A value below 1 is more conservative, but even an
2800
        ideal correction then leaves about ``1 - relaxation`` of the error; reduce it when
2801
        corrections are repeatedly damped or skipped.
2802
    update_wgs_max_its: int, default=1
2803
        Common option. Maximum WGS iterations used before each CMFD correction. The
2804
        default performs one transport update iteration per power iteration; larger
2805
        values make each transport update more accurate but more expensive.
2806
    update_wgs_abs_tol: float, default=1.0e-12
2807
        Common option. WGS absolute tolerance used before each CMFD correction. When
2808
        ``update_wgs_max_its=1``, this tolerance is normally not the stopping criterion
2809
        because only one WGS iteration is allowed. It matters when more WGS iterations
2810
        are allowed.
2811
    balance_residual_tolerance: float, default=1.0e-6
2812
        Common option. Restricted transport-current balance residual tolerance required
2813
        before CMFD permits outer power-iteration convergence. This prevents false
2814
        convergence when ``k_eff_change`` is small but the CMFD-restricted transport
2815
        balance is still inconsistent. This is a consistency guard, not an estimate of
2816
        the k-eigenvalue error. For large 3D cases it may need to be looser than the
2817
        outer ``k_tol``; choose it tight enough to prevent false convergence but not so
2818
        tight that it forces unnecessary asymptotic balance iterations.
2819
    l_abs_tol: float, default=1.0e-7
2820
        Developer/debug. Absolute residual tolerance for each CMFD coarse linear solve.
2821
        This affects the low-order linear solve, not the outer transport convergence.
2822
    l_rel_tol: float, default=1.0e-8
2823
        Developer/debug. Relative residual tolerance for each iterative CMFD coarse
2824
        linear solve. The solve converges when either ``l_rel_tol`` or ``l_abs_tol`` is
2825
        met. Unlike the absolute tolerance, it does not depend on the coarse flux
2826
        normalization.
2827
    max_iters: int, default=100
2828
        Developer/debug. Maximum iterations for each CMFD coarse linear solve. Increase
2829
        only if the coarse KSP solve is reaching its iteration limit. CMFD skips a
2830
        correction when any coarse linear solve fails to converge within this limit. If
2831
        the log reports ``correction = skipped (coarse_linear_solve_not_converged)``,
2832
        use a direct coarse solve for modest coarse systems, increase this limit for
2833
        iterative solves, or provide stronger ``petsc_options``.
2834
    verbose: bool, default=False
2835
        Developer/debug. If true, prints CMFD diagnostic and timing metrics. These are
2836
        useful for regression tests and performance studies but can be noisy in production.
2837
    petsc_options: str, default=""
2838
        Developer/debug. Additional PETSc options for the CMFD coarse solver. Used only
2839
        for solver experiments, especially with ``coarse_solver_policy="petsc_options"``.
2840
    pi_max_its: int, default=50
2841
        Developer/debug. Maximum inner power iterations for the CMFD coarse k solve (at least 1).
2842
        Increase only if the coarse k solve is not converging enough to give useful
2843
        corrections.
2844
    pi_k_tol: float, default=1.0e-8
2845
        Developer/debug. Tolerance on the relative change of the coarse k-eigenvalue between
2846
        CMFD coarse power iterations. This is separate from the outer
2847
        ``PowerIterationKEigenSolver`` k tolerance.
2848
    pi_phi_tol: float, default=1.0e-6
2849
        Developer/debug. Tolerance on the estimated coarse flux-shape error of the CMFD coarse
2850
        power iteration: the relative L2 change of the production-normalized coarse flux divided
2851
        by ``1 - rho``, with the convergence rate ``rho`` estimated from successive changes. The
2852
        coarse power iteration stops when both ``pi_k_tol`` and ``pi_phi_tol`` are met (at least
2853
        two iterations) or after ``pi_max_its`` iterations.
2854
    correction_max_attempts: int, default=10
2855
        Developer/debug. Maximum CMFD correction damping attempts before skipping the
2856
        correction for the current transport update. Each failed attempt halves the
2857
        damping. If the log reports ``correction = skipped (sign_change_guard)``,
2858
        first reduce ``relaxation`` or use finer spatial/energy aggregation; changing
2859
        this option is mainly for diagnostics.
2860
    correction_min_damping: float, default=1.0e-4
2861
        Developer/debug. Minimum CMFD correction damping factor considered during correction
2862
        limiting. If no admissible correction is found above this damping, CMFD skips
2863
        the correction for that transport update.
2864
    inactive_iterations: int, default=0
2865
        Developer/debug. Number of initial power iterations before applying CMFD
2866
        corrections. These iterations retain the WGS settings configured on the problem.
2867
        CMFD applies its transport update controls starting with the first active iteration.
2868
    coarse_solver_policy: str, default="auto"
2869
        Developer/debug. Coarse solver policy. Valid choices are:
2870
            - 'auto' : PETSc preonly+LU below ``coarse_direct_solve_threshold``
2871
              global unknowns, GMRES with hypre BoomerAMG otherwise
2872
            - 'direct' : PETSc preonly+LU
2873
            - 'iterative' : GMRES with hypre BoomerAMG
2874
            - 'petsc_options' : allow ``petsc_options`` to override the PETSc KSP/PC setup
2875

2876
        CMFD corrections from unconverged coarse linear solves are always skipped.
2877
        The skipped correction uses the unaccelerated transport update for that power
2878
        iteration, including the raw transport k update so that power iteration can continue
2879
        to move.
2880
    coarse_direct_solve_threshold: int, default=20000
2881
        Developer/debug. Maximum global CMFD unknown count for automatic direct coarse
2882
        solves when ``coarse_solver_policy="auto"``. Larger values make ``"auto"`` use
2883
        direct solves for larger coarse systems; choose values based on available host
2884
        memory and acceptable factorization cost.
2885
    )"
2886
  );
2887
  cmfd_acceleration.def(
1,523 ✔
2888
    "GetNumAppliedCorrections",
2889
    &CMFDAcceleration::GetNumAppliedCorrections,
1,523 ✔
2890
    R"(
2891
    Return the number of CMFD corrections applied during the last power-iteration solve.
2892

2893
    Inactive iterations are not counted. The count is reset at the start of each solve.
2894
    )"
2895
  );
2896
  cmfd_acceleration.def(
1,523 ✔
2897
    "GetNumSkippedCorrections",
2898
    &CMFDAcceleration::GetNumSkippedCorrections,
1,523 ✔
2899
    R"(
2900
    Return the number of CMFD corrections skipped during the last power-iteration solve.
2901

2902
    A correction is skipped when a coarse linear solve does not converge or when every
2903
    damping attempt is rejected; the power iteration then uses the unaccelerated transport
2904
    update. Inactive iterations are not counted. The count is reset at the start of each
2905
    solve.
2906
    )"
2907
  );
2908
  // clang-format on
2909
}
1,523 ✔
2910

2911
// Wrap the solver components of OpenSn
2912
void
2913
py_solver(py::module& pyopensn)
130 ✔
2914
{
2915
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
130 ✔
2916
  WrapProblem(slv);
130 ✔
2917
  WrapSolver(slv);
130 ✔
2918
  WrapLBS(slv);
130 ✔
2919
  WrapUncollidedSolver(slv);
130 ✔
2920
  WrapSteadyState(slv);
130 ✔
2921
  WrapTransient(slv);
130 ✔
2922
  WrapNLKEigen(slv);
130 ✔
2923
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
130 ✔
2924
  WrapPIteration(slv);
130 ✔
2925
}
130 ✔
2926

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

© 2026 Coveralls, Inc