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

Open-Sn / opensn / 22535783549

27 Feb 2026 08:22PM UTC coverage: 74.152%. Remained the same
22535783549

push

github

web-flow
Merge pull request #950 from wdhawkins/solvers_refactor

Update steady-state <--> time-dependent mode transitions

79 of 113 new or added lines in 7 files covered. (69.91%)

329 existing lines in 8 files now uncovered.

20096 of 27101 relevant lines covered (74.15%)

66824563.91 hits per line

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

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

32
namespace opensn
33
{
34

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

47
    Wrapper of :cpp:class:`opensn::Problem`.
48
    )"
49
  );
672✔
50
  problem.def(
672✔
51
    "GetFieldFunctions",
52
    [](Problem& self)
672✔
53
    {
54
      py::list ff_list;
×
55
      std::vector<std::shared_ptr<FieldFunctionGridBased>>& cpp_ff_list = self.GetFieldFunctions();
×
56
      for (std::shared_ptr<FieldFunctionGridBased>& ff : cpp_ff_list) {
×
57
        ff_list.append(ff);
×
58
      }
59
      return ff_list;
×
60
    },
×
61
    R"(
62
    Get the list of field functions.
63

64
    Returns
65
    -------
66
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
67
        List of grid-based field functions representing solution data such as scalar fluxes.
68
    )"
69
  );
70
  // clang-format on
71
}
672✔
72

73
// Wrap solver
74
void
75
WrapSolver(py::module& slv)
672✔
76
{
77
  // clang-format off
78
  // solver base
79
  auto solver = py::class_<Solver, std::shared_ptr<Solver> >(
672✔
80
    slv,
81
    "Solver",
82
    R"(
83
    Base class for all solvers.
84

85
    Wrapper of :cpp:class:`opensn::Solver`.
86
    )"
87
  );
672✔
88
  solver.def(
672✔
89
    "Initialize",
90
    &Solver::Initialize,
672✔
91
    "Initialize the solver."
92
  );
93
  solver.def(
672✔
94
    "Execute",
95
    &Solver::Execute,
672✔
96
    "Execute the solver."
97
  );
98
  solver.def(
672✔
99
    "Advance",
100
    &Solver::Advance,
672✔
101
    "Advance time values function."
102
  );
103
  // clang-format on
104
}
672✔
105

106
// Wrap LBS solver
107
void
108
WrapLBS(py::module& slv)
672✔
109
{
110
  // clang-format off
111
  // LBS problem
112
  auto lbs_problem = py::class_<LBSProblem, std::shared_ptr<LBSProblem>, Problem>(
672✔
113
    slv,
114
    "LBSProblem",
115
    R"(
116
    Base class for all linear Boltzmann problems.
117

118
    Wrapper of :cpp:class:`opensn::LBSProblem`.
119
    )"
120
  );
672✔
121
  lbs_problem.def(
672✔
122
    "GetScalarFieldFunctionList",
123
    [](LBSProblem& self, bool only_scalar_flux)
672✔
124
    {
125
      py::list field_function_list_per_group;
383✔
126
      for (unsigned int group = 0; group < self.GetNumGroups(); group++)
18,447✔
127
      {
128
        if (only_scalar_flux)
18,064✔
129
        {
130
          std::size_t ff_index = self.MapPhiFieldFunction(group, 0);
11,608✔
131
          field_function_list_per_group.append(self.GetFieldFunctions()[ff_index]);
11,608✔
132
        }
133
        else
134
        {
135
          py::list field_function_list_per_moment;
6,456✔
136
          for (unsigned int moment = 0; moment < self.GetNumMoments(); moment++)
23,832✔
137
          {
138
            std::size_t ff_index = self.MapPhiFieldFunction(group, moment);
17,376✔
139
            field_function_list_per_moment.append(self.GetFieldFunctions()[ff_index]);
17,376✔
140
          }
141
          field_function_list_per_group.append(field_function_list_per_moment);
6,456✔
142
        }
6,456✔
143
      }
144
      return field_function_list_per_group;
383✔
145
    },
×
146
    R"(
147
    Return field functions grouped by energy group and, optionally, by moment.
148

149
    Parameters
150
    ----------
151
    only_scalar_flux : bool, default=True
152
        If True, returns only the zeroth moment (scalar flux) field function for each group.
153
        The result is a flat list of field functions, one per group.
154

155
        If False, returns all moment field functions for each group.
156
        The result is a nested list where each entry corresponds to a group and contains
157
        a list of field functions for all moments (e.g., scalar flux, higher-order moments).
158

159
    Returns
160
    -------
161
    Union[List[pyopensn.fieldfunc.FieldFunctionGridBased], List[List[pyopensn.fieldfunc.FieldFunctionGridBased]]]
162
        The structure of the returned list depends on the `only_scalar_flux` flag.
163

164
    Notes
165
    -----
166
    The moment index varies more rapidly than the group index when `only_scalar_flux` is False.
167
    )",
168
    py::arg("only_scalar_flux") = true
672✔
169
  );
170
  lbs_problem.def(
672✔
171
    "GetPowerFieldFunction",
172
    &LBSProblem::GetPowerFieldFunction,
672✔
173
    R"(
174
    Returns the power generation field function, if enabled.
175
    )"
176
  );
177
  lbs_problem.def(
672✔
178
    "GetTime",
179
    &LBSProblem::GetTime,
672✔
180
    R"(
181
    Get the current simulation time in seconds.
182
    )"
183
  );
184
  lbs_problem.def(
672✔
185
    "GetTimeStep",
186
    &LBSProblem::GetTimeStep,
672✔
187
    R"(
188
    Get the current timestep size.
189
    )"
190
  );
191
  lbs_problem.def(
672✔
192
    "SetOptions",
193
    [](LBSProblem& self, py::kwargs& params)
672✔
194
    {
195
      InputParameters input = LBSProblem::GetOptionsBlock();
×
196
      input.AssignParameters(kwargs_to_param_block(params));
×
197
      self.SetOptions(input);
×
198
    },
×
199
    R"(
200
    Set problem options from a large list of parameters.
201

202
    Parameters
203
    ----------
204
    max_mpi_message_size: int default=32768
205
        The maximum MPI message size used during sweeps.
206
    restart_writes_enabled: bool, default=False
207
        Flag that controls writing of restart dumps.
208
        Requires ``write_restart_path`` to be set to a non-empty value.
209
    write_delayed_psi_to_restart: bool, default=True
210
        Flag that controls writing of delayed angular fluxes to restarts.
211
    read_restart_path: str, default=''
212
        Full path for reading restart dumps including file basename.
213
    write_restart_path: str, default=''
214
        Full path for writing restart dumps including file basename.
215
    write_restart_time_interval: int, default=0
216
        Time interval in seconds at which restart data is to be written.
217
        Must be ``0`` (disabled) or at least ``30`` seconds.
218
    use_precursors: bool, default=False
219
        Flag for using delayed neutron precursors.
220
    use_source_moments: bool, default=False
221
        Flag for ignoring fixed sources and selectively using source moments obtained elsewhere.
222
    save_angular_flux: bool, default=False
223
        Flag indicating whether angular fluxes are to be stored or not.
224
    adjoint: bool, default=False
225
        Toggle adjoint transport mode.
226
    verbose_inner_iterations: bool, default=True
227
        Flag to control verbosity of inner iterations.
228
    verbose_outer_iterations: bool, default=True
229
        Flag to control verbosity of across-groupset iterations.
230
    max_ags_iterations: int, default=100
231
        Maximum number of across-groupset iterations. ``0`` is allowed.
232
    ags_tolerance: float, default=1.0e-6
233
        Across-groupset iterations tolerance.
234
    ags_convergence_check: {'l2', 'pointwise'}, default='l2'
235
        Type of convergence check for AGS iterations.
236
    verbose_ags_iterations: bool, default=True
237
        Flag to control verbosity of across-groupset iterations.
238
    power_field_function_on: bool, default=False
239
        Flag to control the creation of the power generation field function. If set to ``True``, a
240
        field function will be created with the general name ``<solver_name>_power_generation``.
241
    power_default_kappa: float, default=3.20435e-11
242
        Default ``kappa`` value (Energy released per fission) to use for power generation when cross
243
        sections do not have ``kappa`` values. Default corresponds to 200 MeV per fission.
244
    power_normalization: float, default=-1.0
245
        Power normalization factor to use. Supply a negative or zero number to turn this off.
246
    field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
247
        Prefix option on field function names. If unset, flux field functions will be exported as
248
        ``phi_gXXX_mYYY``, where ``XXX`` is the zero-padded 3-digit group number and ``YYY`` is the
249
        zero-padded 3-digit moment.
250
    field_function_prefix: str, default=''
251
        Prefix to use on all field functions. By default, this is empty. If specified, flux moments
252
        are exported as ``prefix_phi_gXXX_mYYY``.
253

254
    Notes
255
    -----
256
    This call is additive: only options explicitly supplied are updated.
257

258
    This is one of three supported mode-setting paths in Python:
259
      1. ``options={'adjoint': ...}`` in the problem constructor.
260
      2. ``SetOptions(adjoint=...)`` (this method).
261
      3. ``SetAdjoint(...)``.
262

263
    If ``adjoint`` is explicitly supplied in this call and differs from the current mode, OpenSn
264
    performs the same mode-transition behavior as :meth:`LBSProblem.SetAdjoint`. If ``adjoint``
265
    is omitted, the existing mode is unchanged.
266

267
    Options requirements:
268
    - ``restart_writes_enabled=True`` requires non-empty ``write_restart_path``.
269
    - ``write_restart_time_interval>0`` requires ``restart_writes_enabled=True``.
270
    - ``write_restart_time_interval`` must be ``0`` or ``>=30``.
271
    - non-empty ``field_function_prefix`` requires ``field_function_prefix_option='prefix'``.
272
    )"
273
  );
274
  lbs_problem.def(
672✔
275
    "ComputeFissionRate",
276
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
672✔
277
    {
278
      const std::vector<double>* phi_ptr = nullptr;
×
279
      if (scalar_flux_iterate == "old")
×
280
      {
281
        phi_ptr = &self.GetPhiOldLocal();
×
282
      }
283
      else if (scalar_flux_iterate == "new")
×
284
      {
285
        phi_ptr = &self.GetPhiNewLocal();
×
286
      }
287
      else
288
      {
289
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
290
      }
291
      return ComputeFissionRate(self, *phi_ptr);
×
292
    },
293
    R"(
294
    Computes the total fission rate.
295

296
    Parameters
297
    ----------
298
    scalar_flux_iterate : {'old', 'new'}
299
        Specifies which scalar flux vector to use in the calculation.
300
            - 'old': Use the previous scalar flux iterate.
301
            - 'new': Use the current scalar flux iterate.
302

303
    Returns
304
    -------
305
    float
306
        The total fission rate.
307

308
    Raises
309
    ------
310
    ValueError
311
        If `scalar_flux_iterate` is not 'old' or 'new'.
312
    )",
313
    py::arg("scalar_flux_iterate")
672✔
314
  );
315
  lbs_problem.def(
672✔
316
    "ComputeFissionProduction",
317
    [](LBSProblem& self, const std::string& scalar_flux_iterate)
1,416✔
318
    {
319
      const std::vector<double>* phi_ptr = nullptr;
744✔
320
      if (scalar_flux_iterate == "old")
744✔
321
      {
322
        phi_ptr = &self.GetPhiOldLocal();
44✔
323
      }
324
      else if (scalar_flux_iterate == "new")
700✔
325
      {
326
        phi_ptr = &self.GetPhiNewLocal();
700✔
327
      }
328
      else
329
      {
330
        throw std::invalid_argument("Unknown scalar_flux_iterate value: \"" + scalar_flux_iterate + "\".");
×
331
      }
332
      return ComputeFissionProduction(self, *phi_ptr);
744✔
333
    },
334
    R"(
335
    Computes the total fission production (nu*fission).
336

337
    Parameters
338
    ----------
339
    scalar_flux_iterate : {'old', 'new'}
340
        Specifies which scalar flux vector to use in the calculation.
341
            - 'old': Use the previous scalar flux iterate.
342
            - 'new': Use the current scalar flux iterate.
343

344
    Returns
345
    -------
346
    float
347
        The total fission production.
348

349
    Raises
350
    ------
351
    ValueError
352
        If `scalar_flux_iterate` is not 'old' or 'new'.
353
    )",
354
    py::arg("scalar_flux_iterate")
672✔
355
  );
356
  lbs_problem.def(
672✔
357
    "GetPhiOldLocal",
358
    [](LBSProblem& self)
720✔
359
    {
360
      return convert_vector(self.GetPhiOldLocal());
48✔
361
    },
362
    R"(
363
    Get the previous scalar flux iterate (local vector).
364

365
    Returns
366
    -------
367
    memoryview
368
        Memory view of the local old scalar flux vector.
369
    )"
370
  );
371
  lbs_problem.def(
672✔
372
    "GetPhiNewLocal",
373
    [](LBSProblem& self)
728✔
374
    {
375
      return convert_vector(self.GetPhiNewLocal());
56✔
376
    },
377
    R"(
378
    Get the current scalar flux iterate (local vector).
379

380
    Returns
381
    -------
382
    memoryview
383
        Memory view of the local new scalar flux vector.
384
    )"
385
  );
386
  lbs_problem.def(
672✔
387
    "WriteFluxMoments",
388
    [](LBSProblem& self, const std::string& file_base)
704✔
389
    {
390
      LBSSolverIO::WriteFluxMoments(self, file_base);
32✔
391
    },
392
    R"(
393
    Write flux moments to file.
394

395
    Parameters
396
    ----------
397
    file_base: str
398
        File basename.
399
    )",
400
    py::arg("file_base")
672✔
401
  );
402
  lbs_problem.def(
672✔
403
    "CreateAndWriteSourceMoments",
404
    [](LBSProblem& self, const std::string& file_base)
676✔
405
    {
406
      std::vector<double> source_moments = self.MakeSourceMomentsFromPhi();
4✔
407
      LBSSolverIO::WriteFluxMoments(self, file_base, source_moments);
4✔
408
    },
4✔
409
    R"(
410
    Write source moments from latest flux iterate to file.
411

412
    Parameters
413
    ----------
414
    file_base: str
415
        File basename.
416
    )",
417
    py::arg("file_base")
672✔
418
  );
419
  lbs_problem.def(
672✔
420
    "ReadFluxMomentsAndMakeSourceMoments",
421
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
672✔
422
    {
423
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, self.GetExtSrcMomentsLocal());
×
424
      log.Log() << "Making source moments from flux file.";
×
425
      std::vector<double>& temp_phi = self.GetPhiOldLocal();
×
426
      self.GetPhiOldLocal() = self.GetExtSrcMomentsLocal();
×
427
      self.GetExtSrcMomentsLocal() = self.MakeSourceMomentsFromPhi();
×
428
      self.GetPhiOldLocal() = temp_phi;
×
429
    },
×
430
    R"(
431
    Read flux moments and compute corresponding source moments.
432

433
    Parameters
434
    ----------
435
    file_base: str
436
        File basename.
437
    single_file_flag: bool
438
        True if all flux moments are in a single file.
439
    )",
440
    py::arg("file_base"),
1,344✔
441
    py::arg("single_file_flag")
672✔
442
  );
443
  lbs_problem.def(
672✔
444
    "ReadSourceMoments",
445
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
676✔
446
    {
447
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag, self.GetExtSrcMomentsLocal());
4✔
448
    },
4✔
449
    R"(
450
    Read source moments from file.
451

452
    Parameters
453
    ----------
454
    file_base: str
455
        File basename.
456
    single_file_flag: bool
457
        True if all source moments are in a single file.
458
    )",
459
    py::arg("file_base"),
1,344✔
460
    py::arg("single_file_flag")
672✔
461
  );
462
  lbs_problem.def(
672✔
463
    "ReadFluxMoments",
464
    [](LBSProblem& self, const std::string& file_base, bool single_file_flag)
672✔
465
    {
466
      LBSSolverIO::ReadFluxMoments(self, file_base, single_file_flag);
×
467
    },
468
    R"(
469
    Read flux moment data.
470

471
    Parameters
472
    ----------
473
    file_base: str
474
        File basename.
475
    single_file_flag: bool
476
        True if all flux moments are in a single file.
477
    )",
478
    py::arg("file_base"),
1,344✔
479
    py::arg("single_file_flag")
672✔
480
  );
481
  lbs_problem.def(
672✔
482
    "WriteAngularFluxes",
483
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
676✔
484
    {
485
      DiscreteOrdinatesProblemIO::WriteAngularFluxes(self, file_base);
4✔
486
    },
487
    R"(
488
    Write angular flux data to file.
489

490
    Parameters
491
    ----------
492
    file_base: str
493
        File basename.
494
    )",
495
    py::arg("file_base")
672✔
496
  );
497
  lbs_problem.def(
672✔
498
    "ReadAngularFluxes",
499
    [](DiscreteOrdinatesProblem& self, const std::string& file_base)
676✔
500
    {
501
      DiscreteOrdinatesProblemIO::ReadAngularFluxes(self, file_base);
4✔
502
    },
503
    R"(
504
    Read angular fluxes from file.
505

506
    Parameters
507
    ----------
508
    file_base: str
509
        File basename.
510
    )",
511
    py::arg("file_base")
672✔
512
  );
513
  lbs_problem.def(
672✔
514
    "SetPointSources",
515
    [](LBSProblem& self, py::kwargs& params)
672✔
516
    {
517
      for (auto [key, value] : params)
×
518
      {
519
        auto c_key = key.cast<std::string>();
×
520
        if (c_key == "clear_point_sources")
×
521
          self.ClearPointSources();
×
522
        else if (c_key == "point_sources")
×
523
        {
524
          auto sources = value.cast<py::list>();
×
525
          for (auto source : sources)
×
526
          {
527
            self.AddPointSource(source.cast<std::shared_ptr<PointSource>>());
×
528
          }
529
        }
×
530
        else
531
          throw std::runtime_error("Invalid argument provided to SetPointSources.\n");
×
532
      }
×
533
    },
×
534
    R"(
535
    Set or clear point sources.
536

537
    Parameters
538
    ----------
539
    clear_point_sources: bool, default=False
540
        If true, all current the point sources of the problem are deleted.
541
    point_sources: List[pyopensn.source.PointSource]
542
        List of new point sources to be added to the problem.
543
    )"
544
  );
545
  lbs_problem.def(
672✔
546
    "SetVolumetricSources",
547
    [](LBSProblem& self, py::kwargs& params)
708✔
548
    {
549
      for (auto [key, value] : params)
108✔
550
      {
551
        auto c_key = key.cast<std::string>();
36✔
552
        if (c_key == "clear_volumetric_sources")
36✔
553
          self.ClearVolumetricSources();
12✔
554
        else if (c_key == "volumetric_sources")
24✔
555
        {
556
          auto sources = value.cast<py::list>();
24✔
557
          for (auto source : sources)
72✔
558
          {
559
            self.AddVolumetricSource(source.cast<std::shared_ptr<VolumetricSource>>());
48✔
560
          }
561
        }
24✔
562
        else
563
          throw std::runtime_error("Invalid argument provided to SetVolumetricSources.\n");
×
564
      }
36✔
565
    },
36✔
566
    R"(
567
    Set or clear volumetric sources.
568

569
    Parameters
570
    ----------
571
    clear_volumetric_sources: bool, default=False
572
        If true, all current the volumetric sources of the problem are deleted.
573
    volumetric_sources: List[pyopensn.source.VolumetricSource]
574
        List of new volumetric sources to be added to the problem.
575
    )"
576
  );
577
  lbs_problem.def(
672✔
578
    "SetXSMap",
579
    [](LBSProblem& self, py::kwargs& params)
840✔
580
    {
581
      BlockID2XSMap xs_map;
168✔
582
      for (auto [key, value] : params)
504✔
583
      {
584
        auto c_key = key.cast<std::string>();
168✔
585
        if (c_key == "xs_map")
168✔
586
        {
587
          auto xs_entries = value.cast<py::list>();
168✔
588
          for (auto entry : xs_entries)
504✔
589
          {
590
            InputParameters xs_entry_pars = LBSProblem::GetXSMapEntryBlock();
168✔
591
            xs_entry_pars.AssignParameters(pyobj_to_param_block("", entry.cast<py::dict>()));
168✔
592
            const auto& block_ids =
168✔
593
              xs_entry_pars.GetParam("block_ids").GetVectorValue<unsigned int>();
168✔
594
            auto xs = xs_entry_pars.GetSharedPtrParam<MultiGroupXS>("xs");
168✔
595
            for (const auto& block_id : block_ids)
336✔
596
              xs_map[block_id] = xs;
168✔
597
          }
168✔
598
        }
168✔
599
        else
600
          throw std::runtime_error("Invalid argument provided to SetXSMap.\n");
×
601
      }
168✔
602
      self.SetBlockID2XSMap(xs_map);
168✔
603
    },
168✔
604
    R"(
605
    Replace the block-id to cross-section map.
606

607
    Parameters
608
    ----------
609
    xs_map: List[Dict]
610
        A list of block-id to cross-section mapping dictionaries. Each dictionary supports:
611
          - block_ids: List[int] (required)
612
              Mesh block ids to associate with the cross section.
613
          - xs: pyopensn.xs.MultiGroupXS (required)
614
              Cross section object.
615

616
    Notes
617
    -----
618
    Forward/adjoint mode toggles via ``SetOptions(adjoint=...)`` (or the low-level
619
    :meth:`LBSProblem.SetAdjoint`) do not change this map.
620
    The ``MultiGroupXS`` objects themselves are mutable and shared by pointer. If the same
621
    ``MultiGroupXS`` handle is shared across multiple problems, toggling adjoint mode in one
622
    problem also changes the transport mode seen by the others.
623
    )"
624
  );
625
  lbs_problem.def(
672✔
626
    "SetSaveAngularFlux",
627
    [](LBSProblem& self, bool save)
672✔
628
    {
629
      self.SetSaveAngularFlux(save);
×
630
    },
631
    py::arg("save")
672✔
632
  );
633
  lbs_problem.def(
672✔
634
    "ZeroPhi",
635
    [](LBSProblem& self)
672✔
636
    {
NEW
637
      self.ZeroPhi();
×
638
    },
639
    R"(
640
    Zero the scalar-flux vectors (``phi_old`` and ``phi_new``) in-place.
641
    )"
642
  );
643
  lbs_problem.def(
672✔
644
    "ZeroPsi",
645
    [](LBSProblem& self)
672✔
646
    {
NEW
647
      self.ZeroPsi();
×
648
    },
649
    R"(
650
    Zero the angular-flux storage (if present for this problem type).
651
    )"
652
  );
653
  lbs_problem.def(
672✔
654
    "SetAdjoint",
655
    [](LBSProblem& self, bool adjoint)
692✔
656
    {
657
      self.SetAdjoint(adjoint);
20✔
658
    },
659
    R"(
660
    Set forward/adjoint transport mode.
661

662
    Parameters
663
    ----------
664
    adjoint: bool
665
        ``True`` enables adjoint mode and ``False`` enables forward mode.
666

667
    Notes
668
    -----
669
    This is one of three supported mode-setting paths in Python:
670
      1. ``options={'adjoint': ...}`` in the problem constructor.
671
      2. ``SetOptions(adjoint=...)``.
672
      3. ``SetAdjoint(...)`` (this method).
673

674
    In typical Python workflows, use constructor options or ``SetOptions``.
675

676
    When this changes mode after the problem has been initialized, OpenSn performs a
677
    full mode-transition reset:
678
      - Materials are reinitialized in the selected mode.
679
      - Point and volumetric sources are cleared.
680
      - Boundary conditions are cleared.
681
      - Scalar and angular flux vectors are zeroed.
682

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

687
    After a mode change, reapply the desired driving terms before solving, typically:
688
      - :meth:`LBSProblem.SetPointSources`
689
      - :meth:`LBSProblem.SetVolumetricSources`
690
      - :meth:`DiscreteOrdinatesProblem.SetBoundaryOptions`
691

692
    This routine is intentionally destructive with respect to source/boundary/flux state
693
    to avoid hidden coupling between forward and adjoint setups.
694
    )"
695
  );
696
  lbs_problem.def(
672✔
697
    "IsAdjoint",
698
    &LBSProblem::IsAdjoint,
672✔
699
    R"(
700
    Return ``True`` if the problem is in adjoint mode, otherwise ``False``.
701
    )"
702
  );
703

704
  // discrete ordinate solver
705
  auto do_problem = py::class_<DiscreteOrdinatesProblem, std::shared_ptr<DiscreteOrdinatesProblem>,
672✔
706
                               LBSProblem>(
707
    slv,
708
    "DiscreteOrdinatesProblem",
709
    R"(
710
    Base class for discrete ordinates problems in Cartesian geometry.
711

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

716
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesProblem`.
717
    )"
718
  );
672✔
719
  do_problem.def(
1,344✔
720
    py::init(
672✔
721
      [](py::kwargs& params)
498✔
722
      {
723
        return DiscreteOrdinatesProblem::Create(kwargs_to_param_block(params));
498✔
724
      }
725
    ),
726
    R"(
727
    Construct a discrete ordinates problem with Cartesian geometry.
728

729
    Parameters
730
    ----------
731
    mesh : MeshContinuum
732
        The spatial mesh.
733
    num_groups : int
734
        The total number of energy groups.
735
    groupsets : List[Dict], default=[]
736
        A list of input parameter blocks, each block provides the iterative properties for a
737
        groupset. Each dictionary supports:
738
          - groups_from_to: List[int] (required)
739
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
740
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
741
              Handle to an angular quadrature.
742
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
743
              Angle aggregation method to use during sweeping.
744
          - angle_aggregation_num_subsets: int, default=1
745
              Number of angle subsets used for aggregation.
746
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
747
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
748
              Iterative method used for inner linear solves.
749
          - l_abs_tol: float, default=1.0e-6
750
              Inner linear solver absolute residual tolerance.
751
          - l_max_its: int, default=200
752
              Inner linear solver maximum iterations.
753
          - gmres_restart_interval: int, default=30
754
              GMRES restart interval, if GMRES is used.
755
          - allow_cycles: bool, default=True
756
              Whether cyclic dependencies are allowed in sweeps.
757
          - apply_wgdsa: bool, default=False
758
              Enable within-group DSA for this groupset.
759
          - wgdsa_l_abs_tol: float, default=1.0e-4
760
              WGDSA linear absolute tolerance.
761
          - wgdsa_l_max_its: int, default=30
762
              WGDSA maximum iterations.
763
          - wgdsa_verbose: bool, default=False
764
              Verbose WGDSA output.
765
          - wgdsa_petsc_options: str, default=''
766
              PETSc options string for the WGDSA solver.
767
          - apply_tgdsa: bool, default=False
768
              Enable two-grid DSA for this groupset.
769
          - tgdsa_l_abs_tol: float, default=1.0e-4
770
              TGDSA linear absolute tolerance.
771
          - tgdsa_l_max_its: int, default=30
772
              TGDSA maximum iterations.
773
          - tgdsa_verbose: bool, default=False
774
              Verbose TGDSA output.
775
          - tgdsa_petsc_options: str, default=''
776
              PETSc options string for the TGDSA solver.
777
    xs_map : List[Dict], default=[]
778
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
779
          - block_ids: List[int] (required)
780
              Mesh block IDs to associate with the cross section.
781
          - xs: pyopensn.xs.MultiGroupXS (required)
782
              Cross-section object to assign to the specified blocks.
783
    boundary_conditions: List[Dict], default=[]
784
        A list containing tables for each boundary specification. Each dictionary supports:
785
          - name: str (required)
786
              Boundary name that identifies the specific boundary.
787
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
788
              Boundary type specification.
789
          - group_strength: List[float], optional
790
              Required when ``type='isotropic'``. Isotropic strength per group.
791
          - function: AngularFluxFunction, optional
792
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
793
    point_sources: List[pyopensn.source.PointSource], default=[]
794
        A list of point sources.
795
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
796
        A list of volumetric sources.
797
    options : Dict, default={}
798
        A block of optional configuration parameters. Each dictionary supports the same keys as
799
        :meth:`LBSProblem.SetOptions`, including:
800
          - max_mpi_message_size: int, default=32768
801
          - restart_writes_enabled: bool, default=False
802
          - write_delayed_psi_to_restart: bool, default=True
803
          - read_restart_path: str, default=''
804
          - write_restart_path: str, default=''
805
          - write_restart_time_interval: int, default=0
806
            (must be 0 or >=30)
807
          - use_precursors: bool, default=False
808
          - use_source_moments: bool, default=False
809
          - save_angular_flux: bool, default=False
810
          - adjoint: bool, default=False
811
          - verbose_inner_iterations: bool, default=True
812
          - verbose_outer_iterations: bool, default=True
813
          - max_ags_iterations: int, default=100
814
          - ags_tolerance: float, default=1.0e-6
815
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
816
          - verbose_ags_iterations: bool, default=True
817
          - power_field_function_on: bool, default=False
818
          - power_default_kappa: float, default=3.20435e-11
819
          - power_normalization: float, default=-1.0
820
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
821
          - field_function_prefix: str, default=''
822
        Requirements are the same as :meth:`LBSProblem.SetOptions`.
823
    sweep_type : str, default="AAH"
824
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
825
    time_dependent : bool, default=False
826
        If true, the problem is constructed in time-dependent mode. Otherwise it starts in
827
        steady-state mode.
828
    use_gpus : bool, default=False
829
        A flag specifying whether GPU acceleration is used for the sweep. Currently, only ``AAH`` is
830
        supported.
831
    )"
832
  );
833
  do_problem.def(
672✔
834
    "SetTimeDependentMode",
835
    &DiscreteOrdinatesProblem::SetTimeDependentMode,
672✔
836
    R"(
837
    Set the problem to time-dependent mode.
838

839
    Notes
840
    -----
841
    You only need to call this if the problem was constructed in steady-state mode
842
    (the default) and you want to switch to time-dependent mode.
843

844
    If called before initialization, this only sets the mode flag used at
845
    initialization time; no reset is performed because runtime state does not yet exist.
846
    It may also force ``save_angular_flux=True`` because transient mode requires angular-flux
847
    storage. Runtime state will be set when Initialize is called.
848

849
    If called after initialization, this updates problem internals (sweep chunk mode and
850
    source-function) while preserving user boundary conditions and fixed sources.
851
    If ``save_angular_flux`` is currently false, this transition enables it while in
852
    time-dependent mode.
853
    )"
854
  );
855
  do_problem.def(
672✔
856
    "SetSteadyStateMode",
857
    &DiscreteOrdinatesProblem::SetSteadyStateMode,
672✔
858
    R"(
859
    Set the problem to steady-state mode.
860

861
    Notes
862
    -----
863
    You only need to call this if the problem was constructed in time-dependent mode
864
    (``time_dependent=True``) and you want to switch to steady-state mode.
865

866
    If called before initialization, this only sets the mode flag used at
867
    initialization time; no reset is performed because runtime state does not yet exist.
868
    Runtime state will be set when Initialize is called. This does not force
869
    ``save_angular_flux`` in the pre-initialize path.
870

871
    If called after initialization, this updates problem internals (sweep chunk mode and
872
    source-function) while preserving user boundary conditions and fixed sources.
873
    If ``save_angular_flux`` was auto-enabled when entering time-dependent mode, this call
874
    restores the previous value.
875
    )"
876
  );
877
  do_problem.def(
672✔
878
    "IsTimeDependent",
879
    &DiscreteOrdinatesProblem::IsTimeDependent,
672✔
880
    R"(
881
    Return ``True`` if the problem is currently in time-dependent mode.
882
    )"
883
  );
884
  do_problem.def(
672✔
885
    "SetOptions",
886
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
684✔
887
    {
888
      InputParameters input = DiscreteOrdinatesProblem::GetOptionsBlock();
12✔
889
      input.AssignParameters(kwargs_to_param_block(params));
12✔
890
      self.SetOptions(input);
12✔
891
    },
12✔
892
    R"(
893
    Set problem options from a large list of parameters.
894

895
    Parameters
896
    ----------
897
    **kwargs
898
        Same option keys and semantics as :meth:`LBSProblem.SetOptions`.
899

900
    Notes
901
    -----
902
    This call is additive: only options explicitly supplied are updated.
903

904
    As with :meth:`LBSProblem.SetOptions`, if ``adjoint`` is explicitly supplied and differs from
905
    the current mode, the same mode-transition behavior as :meth:`LBSProblem.SetAdjoint` is used.
906
    )"
907
  );
908
  do_problem.def(
672✔
909
    "SetBoundaryOptions",
910
    [](DiscreteOrdinatesProblem& self, py::kwargs& params)
676✔
911
    {
912
      for (auto [key, value] : params)
12✔
913
      {
914
        auto c_key = key.cast<std::string>();
4✔
915
        if (c_key == "clear_boundary_conditions")
4✔
UNCOV
916
          self.ClearBoundaries();
×
917
        else if (c_key == "boundary_conditions")
4✔
918
        {
919
          auto boundaries = value.cast<py::list>();
4✔
920
          for (auto boundary : boundaries)
24✔
921
          {
922
            InputParameters input = DiscreteOrdinatesProblem::GetBoundaryOptionsBlock();
16✔
923
            input.AssignParameters(pyobj_to_param_block("", boundary.cast<py::dict>()));
16✔
924
            self.SetBoundaryOptions(input);
16✔
925
          }
16✔
926
        }
4✔
927
        else
UNCOV
928
          throw std::runtime_error("Invalid argument provided to SetBoundaryOptions.\n");
×
929
      }
4✔
930
    },
4✔
931
    R"(
932
    Set or clear boundary conditions.
933

934
    Parameters
935
    ----------
936
    clear_boundary_conditions: bool, default=False
937
        If true, all current boundary conditions are deleted.
938
    boundary_conditions: List[Dict]
939
        A list of boundary condition dictionaries. Each dictionary supports:
940
          - name: str (required)
941
              Boundary name that identifies the specific boundary.
942
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
943
              Boundary type specification.
944
          - group_strength: List[float], optional
945
              Required when ``type='isotropic'``. Isotropic strength per group.
946
          - function: AngularFluxFunction, optional
947
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
948

949
    Notes
950
    -----
951
    Mode transitions via ``SetOptions(adjoint=...)`` (or the low-level
952
    :meth:`LBSProblem.SetAdjoint`) clear all boundary conditions.
953
    Reapply boundaries with this method before solving in the new mode.
954
    )"
955
  );
956
  do_problem.def(
672✔
957
    "GetPsi",
958
    [](DiscreteOrdinatesProblem& self)
680✔
959
    {
960
      const auto& psi = self.GetPsiNewLocal();
8✔
961
      py::list psi_list;
8✔
962
      for (const auto& vec : psi)
16✔
963
      {
964
        auto array = py::array_t<double>(static_cast<py::ssize_t>(vec.size()),
8✔
965
                                         vec.data(),
966
                                         py::cast(self));
8✔
967
        psi_list.append(array);
8✔
968
      }
8✔
969
      return psi_list;
8✔
UNCOV
970
    },
×
971
    R"(
972
    Return psi as a list of NumPy arrays (float64), using zero-copy views into the
973
    underlying data.
974
    )"
975
  );
976
  do_problem.def(
672✔
977
    "GetAngularFieldFunctionList",
978
    [](DiscreteOrdinatesProblem& self, py::list groups, py::list angles)
672✔
979
    {
UNCOV
980
      std::vector<unsigned int> group_ids;
×
UNCOV
981
      std::vector<size_t> angle_ids;
×
UNCOV
982
      group_ids.reserve(groups.size());
×
UNCOV
983
      angle_ids.reserve(angles.size());
×
984

UNCOV
985
      for (py::handle g : groups)
×
UNCOV
986
        group_ids.push_back(g.cast<unsigned int>());
×
UNCOV
987
      for (py::handle a : angles)
×
UNCOV
988
        angle_ids.push_back(a.cast<size_t>());
×
989

990
      try
×
991
      {
UNCOV
992
        auto ff_list = self.GetAngularFluxFieldFunctionList(group_ids, angle_ids);
×
UNCOV
993
        py::list out;
×
UNCOV
994
        for (const auto& ff : ff_list)
×
UNCOV
995
          out.append(ff);
×
UNCOV
996
        return out;
×
UNCOV
997
      }
×
UNCOV
998
      catch (const std::exception& err)
×
999
      {
UNCOV
1000
        const std::string message = err.what();
×
UNCOV
1001
        if (message.find("problem not initialized") != std::string::npos)
×
1002
          throw std::runtime_error(
×
1003
            "GetAngularFieldFunctionList requires Initialize() to be called first. "
1004
            "Call solver.Initialize() (or problem.Initialize()) before requesting "
UNCOV
1005
            "angular flux field functions.");
×
UNCOV
1006
        throw;
×
UNCOV
1007
      }
×
UNCOV
1008
    },
×
1009
    R"(
1010
    Return field functions for selected angular flux components.
1011

1012
    This requires `Initialize()` to have completed; it throws if the problem is
1013
    not yet initialized.
1014
    Note: You must enable angular flux storage (``save_angular_flux=True``) in
1015
    the problem options, or use a transient/time-dependent solver that retains
1016
    angular fluxes, otherwise the field functions will remain zero.
1017

1018
    Example
1019
    -------
1020
    ```python
1021
    solver.Initialize()
1022
    solver.Execute()
1023
    ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1024
    ```
1025

1026
    For transient/time-dependent runs, call this after each timestep. Two common
1027
    patterns are:
1028

1029
    1) Use `TransientSolver.Execute()` with a post-advance callback:
1030
    ```python
1031
    solver = TransientSolver(problem=phys)
1032
    solver.Initialize()
1033

1034
    def post_advance():
1035
        ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1036
        FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1037

1038
    solver.SetPostAdvanceCallback(post_advance)
1039
    solver.Execute()
1040
    ```
1041

1042
    2) Use a custom Python loop with `TransientSolver.Advance()`:
1043
    ```python
1044
    solver = TransientSolver(problem=phys)
1045
    solver.Initialize()
1046
    for _ in range(num_steps):
1047
        solver.Advance()
1048
        ang_ff = phys.GetAngularFieldFunctionList(groups=[0], angles=[0])
1049
        FieldFunctionGridBased.ExportMultipleToPVTU(ang_ff, "angular_flux_t")
1050
    ```
1051

1052
    Parameters
1053
    ----------
1054
    groups : List[int]
1055
        Global group indices to export.
1056
    angles : List[int]
1057
        Angle indices within the groupset quadrature for each group.
1058

1059
    Returns
1060
    -------
1061
    List[pyopensn.fieldfunc.FieldFunctionGridBased]
1062
        Field functions for the requested (group, angle) pairs.
1063
    )",
1064
    py::arg("groups"),
1,344✔
1065
    py::arg("angles")
672✔
1066
  );
1067
  do_problem.def(
672✔
1068
    "ComputeLeakage",
1069
    [](DiscreteOrdinatesProblem& self, py::list bnd_names)
711✔
1070
    {
1071
      auto grid = self.GetGrid();
39✔
1072
      // get the supported boundaries
1073
      std::map<std::string, std::uint64_t> allowed_bd_names = grid->GetBoundaryNameMap();
39✔
1074
      std::map<std::uint64_t, std::string> allowed_bd_ids = grid->GetBoundaryIDMap();
39✔
1075
      const auto coord_sys = grid->GetCoordinateSystem();
39✔
1076
      const auto mesh_type = grid->GetType();
39✔
1077
      const auto dim = grid->GetDimension();
39✔
1078
      // get the boundaries to parse, preserving user order
1079
      std::vector<std::uint64_t> bndry_ids;
39✔
1080
      if (bnd_names.size() > 1)
39✔
1081
      {
1082
        for (py::handle name : bnd_names)
184✔
1083
        {
1084
          auto sname = name.cast<std::string>();
112✔
1085
          if (coord_sys == CoordinateSystemType::CYLINDRICAL && dim == 2)
112✔
1086
          {
1087
            if (sname == "xmin" || sname == "xmax" || sname == "ymin" || sname == "ymax")
24✔
UNCOV
1088
              throw std::runtime_error("ComputeLeakage: Boundary name '" + sname +
×
1089
                                       "' is invalid for cylindrical orthogonal meshes. "
UNCOV
1090
                                       "Use rmin, rmax, zmin, zmax.");
×
1091

1092
            if (mesh_type == MeshType::ORTHOGONAL)
24✔
1093
            {
1094
              if (sname == "rmin") sname = "xmin";
24✔
1095
              else if (sname == "rmax") sname = "xmax";
24✔
1096
              else if (sname == "zmin") sname = "ymin";
16✔
1097
              else if (sname == "zmax") sname = "ymax";
8✔
1098
            }
1099
          }
1100
          bndry_ids.push_back(allowed_bd_names.at(sname));
112✔
1101
        }
112✔
1102
      }
1103
      else
1104
      {
1105
        bndry_ids = self.GetGrid()->GetUniqueBoundaryIDs();
9✔
1106
      }
1107
      // compute the leakage
1108
      std::map<std::uint64_t, std::vector<double>> leakage = ComputeLeakage(self, bndry_ids);
39✔
1109
      const bool rz_ortho = (coord_sys == CoordinateSystemType::CYLINDRICAL &&
78✔
1110
                             mesh_type == MeshType::ORTHOGONAL && dim == 2);
39✔
1111

1112
      auto to_rz_name = [](const std::string& name)
63✔
1113
      {
1114
        if (name == "xmin") return std::string("rmin");
24✔
1115
        if (name == "xmax") return std::string("rmax");
24✔
1116
        if (name == "ymin") return std::string("zmin");
16✔
1117
        if (name == "ymax") return std::string("zmax");
8✔
UNCOV
1118
        return name;
×
1119
      };
1120

1121
      // convert result to native Python
1122
      py::dict result;
39✔
1123
      for (const auto& bndry_id : bndry_ids)
157✔
1124
      {
1125
        const auto it = leakage.find(bndry_id);
118✔
1126
        if (it == leakage.end())
118✔
UNCOV
1127
          continue;
×
1128
        // construct numpy array and copy contents
1129
        const auto& grp_wise_leakage = it->second;
118✔
1130
        py::array_t<double> np_vector(py::ssize_t(grp_wise_leakage.size()));
118✔
1131
        auto buffer = np_vector.request();
118✔
1132
        auto *np_vector_data = static_cast<double*>(buffer.ptr);
118✔
1133
        std::copy(grp_wise_leakage.begin(), grp_wise_leakage.end(), np_vector_data);
118✔
1134
        std::string name = allowed_bd_ids.at(bndry_id);
118✔
1135
        if (rz_ortho)
118✔
1136
          name = to_rz_name(name);
24✔
1137
        result[py::str(name)] = std::move(np_vector);
236✔
1138
      }
118✔
1139

1140
      return result;
39✔
1141
    },
78✔
1142
    R"(
1143
    Compute leakage for the problem.
1144

1145
    Parameters
1146
    ----------
1147
    bnd_names : List[str]
1148
        A list of boundary names for which leakage should be computed.
1149

1150
    Returns
1151
    -------
1152
    Dict[str, numpy.ndarray]
1153
        A dictionary mapping boundary names to group-wise leakage vectors.
1154
        Each array contains the outgoing angular flux (per group) integrated over
1155
        the corresponding boundary surface.
1156

1157
    Raises
1158
    ------
1159
    RuntimeError
1160
        If `save_angular_flux` option was not enabled during problem setup.
1161

1162
    ValueError
1163
        If one or more boundary ids are not present on the current mesh.
1164
    )",
1165
    py::arg("bnd_names")
672✔
1166
  );
1167

1168
  // discrete ordinates curvilinear problem
1169
  auto do_curvilinear_problem = py::class_<DiscreteOrdinatesCurvilinearProblem,
672✔
1170
                                           std::shared_ptr<DiscreteOrdinatesCurvilinearProblem>,
1171
                                           DiscreteOrdinatesProblem>(
1172
    slv,
1173
    "DiscreteOrdinatesCurvilinearProblem",
1174
    R"(
1175
    Base class for discrete ordinates problems in curvilinear geometry.
1176

1177
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesCurvilinearProblem`.
1178
    )"
1179
  );
672✔
1180
  do_curvilinear_problem.def(
1,344✔
1181
    py::init(
672✔
1182
      [](py::kwargs& params)
80✔
1183
      {
1184
        return DiscreteOrdinatesCurvilinearProblem::Create(kwargs_to_param_block(params));
80✔
1185
      }
1186
    ),
1187
    R"(
1188
    Construct a discrete ordinates problem for curvilinear geometry.
1189

1190
    Warnings
1191
    --------
1192
       DiscreteOrdinatesCurvilinearProblem is **experimental** and should be used with caution!
1193

1194
    Parameters
1195
    ----------
1196
    mesh : MeshContinuum
1197
        The spatial mesh.
1198
    coord_system : int
1199
        Coordinate system to use. Must be set to 2 (cylindrical coordinates).
1200
    num_groups : int
1201
        The total number of energy groups.
1202
    groupsets : list of dict
1203
        A list of input parameter blocks, each block provides the iterative properties for a
1204
        groupset. Each dictionary supports:
1205
          - groups_from_to: List[int] (required)
1206
              Two-entry list with the first and last group id for the groupset, e.g. ``[0, 3]``.
1207
          - angular_quadrature: pyopensn.aquad.AngularQuadrature, optional
1208
              Handle to an angular quadrature.
1209
          - angle_aggregation_type: {'polar', 'single', 'azimuthal'}, default='polar'
1210
              Angle aggregation method to use during sweeping.
1211
          - angle_aggregation_num_subsets: int, default=1
1212
              Number of angle subsets used for aggregation.
1213
          - inner_linear_method: {'classic_richardson', 'petsc_richardson',
1214
            'petsc_gmres', 'petsc_bicgstab'}, default='petsc_richardson'
1215
              Iterative method used for inner linear solves.
1216
          - l_abs_tol: float, default=1.0e-6
1217
              Inner linear solver absolute residual tolerance.
1218
          - l_max_its: int, default=200
1219
              Inner linear solver maximum iterations.
1220
          - gmres_restart_interval: int, default=30
1221
              GMRES restart interval, if GMRES is used.
1222
          - allow_cycles: bool, default=True
1223
              Whether cyclic dependencies are allowed in sweeps.
1224
          - apply_wgdsa: bool, default=False
1225
              Enable within-group DSA for this groupset.
1226
          - wgdsa_l_abs_tol: float, default=1.0e-4
1227
              WGDSA linear absolute tolerance.
1228
          - wgdsa_l_max_its: int, default=30
1229
              WGDSA maximum iterations.
1230
          - wgdsa_verbose: bool, default=False
1231
              Verbose WGDSA output.
1232
          - wgdsa_petsc_options: str, default=''
1233
              PETSc options string for the WGDSA solver.
1234
          - apply_tgdsa: bool, default=False
1235
              Enable two-grid DSA for this groupset.
1236
          - tgdsa_l_abs_tol: float, default=1.0e-4
1237
              TGDSA linear absolute tolerance.
1238
          - tgdsa_l_max_its: int, default=30
1239
              TGDSA maximum iterations.
1240
          - tgdsa_verbose: bool, default=False
1241
              Verbose TGDSA output.
1242
          - tgdsa_petsc_options: str, default=''
1243
    xs_map : list of dict
1244
        A list of mappings from block ids to cross-section definitions. Each dictionary supports:
1245
          - block_ids: List[int] (required)
1246
              Mesh block IDs to associate with the cross section.
1247
          - xs: pyopensn.xs.MultiGroupXS (required)
1248
              Cross-section object to assign to the specified blocks.
1249
    boundary_conditions: List[Dict], default=[]
1250
        A list containing tables for each boundary specification. Each dictionary supports:
1251
          - name: str (required)
1252
              Boundary name that identifies the specific boundary.
1253
          - type: {'vacuum', 'isotropic', 'reflecting', 'arbitrary'} (required)
1254
              Boundary type specification.
1255
          - group_strength: List[float], optional
1256
              Required when ``type='isotropic'``. Isotropic strength per group.
1257
          - function: AngularFluxFunction, optional
1258
              Required when ``type='arbitrary'``. Callable that returns incoming angular flux.
1259
    point_sources: List[pyopensn.source.PointSource], default=[]
1260
        A list of point sources.
1261
    volumetric_sources: List[pyopensn.source.VolumetricSource], default=[]
1262
        A list of volumetric sources.
1263
    options : dict, optional
1264
        A block of optional configuration parameters. Each dictionary supports the same keys as
1265
        :meth:`LBSProblem.SetOptions`, including:
1266
          - max_mpi_message_size: int, default=32768
1267
          - restart_writes_enabled: bool, default=False
1268
          - write_delayed_psi_to_restart: bool, default=True
1269
          - read_restart_path: str, default=''
1270
          - write_restart_path: str, default=''
1271
          - write_restart_time_interval: int, default=0
1272
          - use_precursors: bool, default=False
1273
          - use_source_moments: bool, default=False
1274
          - save_angular_flux: bool, default=False
1275
          - verbose_inner_iterations: bool, default=True
1276
          - verbose_outer_iterations: bool, default=True
1277
          - max_ags_iterations: int, default=100
1278
          - ags_tolerance: float, default=1.0e-6
1279
          - ags_convergence_check: {'l2', 'pointwise'}, default='l2'
1280
          - verbose_ags_iterations: bool, default=True
1281
          - power_field_function_on: bool, default=False
1282
          - power_default_kappa: float, default=3.20435e-11
1283
          - power_normalization: float, default=-1.0
1284
          - field_function_prefix_option: {'prefix', 'solver_name'}, default='prefix'
1285
          - field_function_prefix: str, default=''
1286
    sweep_type : str, optional
1287
        The sweep type to use. Must be one of `AAH` or `CBC`. Defaults to `AAH`.
1288
    )"
1289
  );
1290
}
672✔
1291

1292
// Wrap steady-state solver
1293
void
1294
WrapSteadyState(py::module& slv)
672✔
1295
{
1296
  // clang-format off
1297
  // steady state solver
1298
  auto steady_state_solver = py::class_<SteadyStateSourceSolver, std::shared_ptr<SteadyStateSourceSolver>,
672✔
1299
                                        Solver>(
1300
    slv,
1301
    "SteadyStateSourceSolver",
1302
    R"(
1303
    Steady state solver.
1304

1305
    Wrapper of :cpp:class:`opensn::SteadyStateSourceSolver`.
1306
    )"
1307
  );
672✔
1308
  steady_state_solver.def(
1,344✔
1309
    py::init(
672✔
1310
      [](py::kwargs& params)
357✔
1311
      {
1312
        return SteadyStateSourceSolver::Create(kwargs_to_param_block(params));
357✔
1313
      }
1314
    ),
1315
    R"(
1316
    Construct a steady state solver.
1317

1318
    Parameters
1319
    ----------
1320
    pyopensn.solver.LBSProblem : LBSProblem
1321
        Existing LBSProblem instance.
1322
    )"
1323
  );
1324
  // clang-format on
1325
}
672✔
1326

1327
// Wrap transient solver
1328
void
1329
WrapTransient(py::module& slv)
672✔
1330
{
1331
  // clang-format off
1332
  auto transient_solver =
672✔
1333
    py::class_<TransientSolver, std::shared_ptr<TransientSolver>, Solver>(
1334
      slv,
1335
      "TransientSolver",
1336
      R"(
1337
      Transient solver.
1338

1339
      Wrapper of :cpp:class:`opensn::TransientSolver`.
1340
      )"
1341
    );
672✔
1342
  transient_solver.def(
1,344✔
1343
    py::init(
672✔
1344
      [](py::kwargs& params)
156✔
1345
      {
1346
        return TransientSolver::Create(kwargs_to_param_block(params));
156✔
1347
      }
1348
    ),
1349
    R"(
1350
    Construct a transient solver.
1351

1352
    Parameters
1353
    ----------
1354
    pyopensn.solver.DiscreteOrdinatesProblem : DiscreteOrdinatesProblem
1355
        Existing discrete ordinates problem instance.
1356
    dt : float, optional, default=2.0e-3
1357
        Time step size used during the simulation.
1358
    stop_time : float, optional, default=0.1
1359
        Simulation end time.
1360
    theta : float, optional, default=0.5
1361
        Time differencing scheme parameter.
1362
    initial_state : str, optional, default="existing"
1363
        Initial state for the transient solve. Allowed values: existing, zero.
1364
        In "zero" mode, the solver may initialize the problem internally if needed.
1365
    verbose : bool, optional, default=True
1366
        Enable verbose logging.
1367
    )"
1368
  );
1369
  transient_solver.def(
672✔
1370
    "SetTimeStep",
1371
    &TransientSolver::SetTimeStep,
672✔
1372
    R"(
1373
    Set the timestep size used by :meth:`Advance`.
1374

1375
    Parameters
1376
    ----------
1377
    dt : float
1378
        New timestep size.
1379
    )");
1380
  transient_solver.def(
672✔
1381
    "SetTheta",
1382
    &TransientSolver::SetTheta,
672✔
1383
    R"(
1384
    Set the theta parameter used by :meth:`Advance`.
1385

1386
    Parameters
1387
    ----------
1388
    theta : float
1389
        Theta value between 1.0e-16 and 1.
1390
    )");
1391
  transient_solver.def(
672✔
1392
    "Advance",
1393
    &TransientSolver::Advance,
672✔
1394
    R"(
1395
    Advance the solver by a single timestep.
1396

1397
    Notes
1398
    -----
1399
    You must call :meth:`Initialize` before calling :meth:`Advance` or
1400
    :meth:`Execute`.
1401
    )");
1402
  transient_solver.def(
672✔
1403
    "SetPreAdvanceCallback",
1404
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
672✔
1405
      &TransientSolver::SetPreAdvanceCallback),
1406
    R"(
1407
    Register a callback that runs before each advance within :meth:`Execute`.
1408

1409
    Parameters
1410
    ----------
1411
    callback : Optional[Callable[[], None]]
1412
        Function invoked before the solver advances a timestep. Pass None to clear.
1413
        If the callback modifies the timestep, the new value is used for the
1414
        upcoming step.
1415
    )");
1416
  transient_solver.def(
672✔
1417
    "SetPreAdvanceCallback",
1418
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
672✔
1419
      &TransientSolver::SetPreAdvanceCallback),
1420
    "Clear the PreAdvance callback by passing None.");
1421
  transient_solver.def(
672✔
1422
    "SetPostAdvanceCallback",
1423
    static_cast<void (TransientSolver::*)(std::function<void()>)>(
672✔
1424
      &TransientSolver::SetPostAdvanceCallback),
1425
    R"(
1426
    Register a callback that runs after each advance within :meth:`Execute`.
1427

1428
    Parameters
1429
    ----------
1430
    callback : Optional[Callable[[], None]]
1431
        Function invoked after the solver advances a timestep. Pass None to clear.
1432
    )");
1433
  transient_solver.def(
672✔
1434
    "SetPostAdvanceCallback",
1435
    static_cast<void (TransientSolver::*)(std::nullptr_t)>(
672✔
1436
      &TransientSolver::SetPostAdvanceCallback),
1437
    "Clear the PostAdvance callback by passing None.");
1438
  slv.attr("BackwardEuler") = 1.0;
672✔
1439
  slv.attr("CrankNicolson") = 0.5;
1,344✔
1440
  // clang-format on
1441
}
672✔
1442

1443
// Wrap non-linear k-eigen solver
1444
void
1445
WrapNLKEigen(py::module& slv)
672✔
1446
{
1447
  // clang-format off
1448
  // non-linear k-eigen solver
1449
  auto non_linear_k_eigen_solver = py::class_<NonLinearKEigenSolver, std::shared_ptr<NonLinearKEigenSolver>,
672✔
1450
                                              Solver>(
1451
    slv,
1452
    "NonLinearKEigenSolver",
1453
    R"(
1454
    Non-linear k-eigenvalue solver.
1455

1456
    Wrapper of :cpp:class:`opensn::NonLinearKEigenSolver`.
1457
    )"
1458
  );
672✔
1459
  non_linear_k_eigen_solver.def(
1,344✔
1460
    py::init(
672✔
1461
      [](py::kwargs& params)
28✔
1462
      {
1463
        return NonLinearKEigenSolver::Create(kwargs_to_param_block(params));
28✔
1464
      }
1465
        ),
1466
    R"(
1467
    Construct a non-linear k-eigenvalue solver.
1468

1469
    Parameters
1470
    ----------
1471
    lbs_problem: pyopensn.solver.LBSProblem
1472
        Existing LBSProblem instance.
1473
    nl_abs_tol: float, default=1.0e-8
1474
        Non-linear absolute tolerance.
1475
    nl_rel_tol: float, default=1.0e-8
1476
        Non-linear relative tolerance.
1477
    nl_sol_tol: float, default=1.0e-50
1478
        Non-linear solution tolerance.
1479
    nl_max_its: int, default=50
1480
        Non-linear algorithm maximum iterations.
1481
    l_abs_tol: float, default=1.0e-8
1482
        Linear absolute tolerance.
1483
    l_rel_tol: float, default=1.0e-8
1484
        Linear relative tolerance.
1485
    l_div_tol: float, default=1.0e6
1486
        Linear divergence tolerance.
1487
    l_max_its: int, default=50
1488
        Linear algorithm maximum iterations.
1489
    l_gmres_restart_intvl: int, default=30
1490
        GMRES restart interval.
1491
    l_gmres_breakdown_tol: float, default=1.0e6
1492
        GMRES breakdown tolerance.
1493
    reset_phi0: bool, default=True
1494
        If true, reinitializes scalar fluxes to 1.0.
1495
    num_initial_power_iterations: int, default=0
1496
        Number of initial power iterations before the non-linear solve.
1497
    )"
1498
  );
1499
  non_linear_k_eigen_solver.def(
672✔
1500
    "GetEigenvalue",
1501
    &NonLinearKEigenSolver::GetEigenvalue,
672✔
1502
    R"(
1503
    Return the current k‑eigenvalue.
1504
    )"
1505
  );
1506
  // clang-format on
1507
}
672✔
1508

1509
// Wrap power iteration solvers
1510
void
1511
WrapPIteration(py::module& slv)
672✔
1512
{
1513
  // clang-format off
1514
  // power iteration k-eigen solver
1515
  auto pi_k_eigen_solver = py::class_<PowerIterationKEigenSolver, std::shared_ptr<PowerIterationKEigenSolver>,
672✔
1516
                                      Solver>(
1517
    slv,
1518
    "PowerIterationKEigenSolver",
1519
    R"(
1520
    Power iteration k-eigenvalue solver.
1521

1522
    Wrapper of :cpp:class:`opensn::PowerIterationKEigenSolver`.
1523
    )"
1524
  );
672✔
1525
  pi_k_eigen_solver.def(
1,344✔
1526
    py::init(
672✔
1527
      [](py::kwargs& params)
147✔
1528
      {
1529
        return PowerIterationKEigenSolver::Create(kwargs_to_param_block(params));
147✔
1530
      }
1531
    ),
1532
    R"(
1533
    Construct a power iteration k-eigen solver.
1534

1535
    Parameters
1536
    ----------
1537
    problem: pyopensn.solver.LBSProblem
1538
        Existing DiscreteOrdinatesProblem instance.
1539
    acceleration: pyopensn.solver.DiscreteOrdinatesKEigenAcceleration
1540
        Optional DiscreteOrdinatesKEigenAcceleration instance for acceleration.
1541
    max_iters: int, default = 1000
1542
        Maximum power iterations allowed.
1543
    k_tol: float, default = 1.0e-10
1544
        Tolerance on the k-eigenvalue.
1545
    reset_solution: bool, default=True
1546
        If true, initialize flux moments to 1.0.
1547
    reset_phi0: bool, default=True
1548
        If true, reinitializes scalar fluxes to 1.0.
1549
    )"
1550
  );
1551
  pi_k_eigen_solver.def(
672✔
1552
    "GetEigenvalue",
1553
    &PowerIterationKEigenSolver::GetEigenvalue,
672✔
1554
    R"(
1555
    Return the current k‑eigenvalue.
1556
    )"
1557
  );
1558
  // clang-format on
1559
}
672✔
1560

1561
// Wrap LBS solver
1562
void
1563
WrapDiscreteOrdinatesKEigenAcceleration(py::module& slv)
672✔
1564
{
1565
  // clang-format off
1566
  // discrete ordinates k-eigen acceleration base
1567
  auto acceleration = py::class_<DiscreteOrdinatesKEigenAcceleration,
672✔
1568
                                 std::shared_ptr<DiscreteOrdinatesKEigenAcceleration>>(
1569
    slv,
1570
    "DiscreteOrdinatesKEigenAcceleration",
1571
    R"(
1572
    Base class for discrete ordinates k-eigenvalue acceleration methods.
1573

1574
    Wrapper of :cpp:class:`opensn::DiscreteOrdinatesKEigenAcceleration`.
1575
    )"
1576
  );
672✔
1577
  // SCDSA acceleration
1578
  auto scdsa_acceleration = py::class_<SCDSAAcceleration,
672✔
1579
                                       std::shared_ptr<SCDSAAcceleration>,
1580
                                       DiscreteOrdinatesKEigenAcceleration>(
1581
    slv,
1582
    "SCDSAAcceleration",
1583
    R"(
1584
    Construct an SCDSA accelerator for the power iteration k-eigenvalue solver.
1585

1586
    Wrapper of :cpp:class:`opensn::SCDSAAcceleration`.
1587
    )"
1588
  );
672✔
1589
  scdsa_acceleration.def(
1,344✔
1590
    py::init(
672✔
1591
      [](py::kwargs& params)
8✔
1592
      {
1593
        return SCDSAAcceleration::Create(kwargs_to_param_block(params));
8✔
1594
      }
1595
    ),
1596
    R"(
1597
    SCDSA acceleration for the power iteration k-eigenvalue solver.
1598

1599
    Parameters
1600
    ----------
1601
    problem: pyopensn.solver.LBSProblem
1602
        Existing DiscreteOrdinatesProblem instance.
1603
    l_abs_tol: float, defauilt=1.0e-10
1604
        Absolute residual tolerance.
1605
    max_iters: int, default=100
1606
        Maximum allowable iterations.
1607
    verbose: bool, default=False
1608
        If true, enables verbose output.
1609
    petsc_options: str, default="ssss"
1610
        Additional PETSc options.
1611
    pi_max_its: int, default=50
1612
        Maximum allowable iterations for inner power iterations.
1613
    pi_k_tol: float, default=1.0e-10
1614
        k-eigenvalue tolerance for the inner power iterations.
1615
    sdm: str, default="pwld"
1616
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1617
            - 'pwld' : Piecewise Linear Discontinuous
1618
            - 'pwlc' : Piecewise Linear Continuous
1619
    )"
1620
  );
1621
  // SMM acceleration
1622
  auto smm_acceleration = py::class_<SMMAcceleration,
672✔
1623
                                     std::shared_ptr<SMMAcceleration>,
1624
                                     DiscreteOrdinatesKEigenAcceleration>(
1625
    slv,
1626
    "SMMAcceleration",
1627
    R"(
1628
    Construct an SMM accelerator for the power iteration k-eigenvalue solver.
1629

1630
    Wrapper of :cpp:class:`opensn::SMMAcceleration`.
1631
    )"
1632
  );
672✔
1633
  smm_acceleration.def(
1,344✔
1634
    py::init(
672✔
1635
      [](py::kwargs& params)
4✔
1636
      {
1637
        return SMMAcceleration::Create(kwargs_to_param_block(params));
4✔
1638
      }
1639
    ),
1640
    R"(
1641
    SMM acceleration for the power iteration k-eigenvalue solver.
1642

1643
    Warnings
1644
    --------
1645
       SMM acceleration is **experimental** and should be used with caution!
1646
       SMM accleration only supports problems with isotropic scattering.
1647

1648
    Parameters
1649
    ----------
1650
    problem: pyopensn.solver.LBSProblem
1651
        Existing DiscreteOrdinatesProblem instance.
1652
    l_abs_tol: float, defauilt=1.0e-10
1653
        Absolute residual tolerance.
1654
    max_iters: int, default=100
1655
        Maximum allowable iterations.
1656
    verbose: bool, default=False
1657
        If true, enables verbose output.
1658
    petsc_options: str, default="ssss"
1659
        Additional PETSc options.
1660
    pi_max_its: int, default=50
1661
        Maximum allowable iterations for inner power iterations.
1662
    pi_k_tol: float, default=1.0e-10
1663
        k-eigenvalue tolerance for the inner power iterations.
1664
    sdm: str, default="pwld"
1665
        Spatial discretization method to use for the diffusion solver. Valid choices are:
1666
            - 'pwld' : Piecewise Linear Discontinuous
1667
            - 'pwlc' : Piecewise Linear Continuous
1668
    )"
1669
  );
1670
  // clang-format on
1671
}
672✔
1672

1673
// Wrap the solver components of OpenSn
1674
void
1675
py_solver(py::module& pyopensn)
63✔
1676
{
1677
  py::module slv = pyopensn.def_submodule("solver", "Solver module.");
63✔
1678
  WrapProblem(slv);
63✔
1679
  WrapSolver(slv);
63✔
1680
  WrapLBS(slv);
63✔
1681
  WrapSteadyState(slv);
63✔
1682
  WrapTransient(slv);
63✔
1683
  WrapNLKEigen(slv);
63✔
1684
  WrapDiscreteOrdinatesKEigenAcceleration(slv);
63✔
1685
  WrapPIteration(slv);
63✔
1686
}
63✔
1687

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

© 2026 Coveralls, Inc