Source code for bngsim._rounding
"""bngsim's single stochastic initial-count rounding policy (GH #51).
When a continuous molecule amount has to become a discrete integer count for a
stochastic engine, bngsim rounds **half-up** — to the nearest integer, ties away
from zero: ``floor(x + 0.5)`` for non-negative counts. This matches BNG2.pl
``run_network`` (Network3) SSA *and* COPASI's stochastic solvers, which both use
the same rule; see GH #51 for the cross-engine survey.
This module is the single source of that policy for every continuous→discrete
boundary bngsim controls from Python:
* the :class:`bngsim.NfsimSession` / :class:`bngsim.RuleMonkeySession` count
mutations (``add_molecules``, ``add_species``, ``remove_species``,
``set_species_count``) — warm continue, dosing, and parameter scans, and
* downstream hosts (e.g. the PyBNF NF bridge) that integerize a fractional
``setConcentration`` before handing a count to a network-free session.
Two more boundaries enforce the same rule in their own layers and must stay in
sync with this one:
* bngsim SSA/PSA setup, in C++ (``round_initial_population_to_storage`` in
``src/ssa_simulator.cpp``), and
* the cold-start seed concentrations bngsim rounds into the XML the network-free
engines parse (``round_half_up_count`` in ``src/seed_count_rounding.hpp``).
The array form of the same rule is :func:`bngsim.coupling.round_to_counts`
(``policy="nearest"`` rounds half away from zero, identical to this for the
non-negative populations that occur in practice).
"""
from __future__ import annotations
import math
__all__ = ["round_half_up"]
[docs]
def round_half_up(value: float) -> int:
"""Round a fractional molecule count to the nearest integer, ties away from zero.
bngsim's repo-wide stochastic initial-count policy (GH #51):
``floor(x + 0.5)`` for ``x >= 0`` and ``ceil(x - 0.5)`` for ``x < 0``. The
two branches differ only at negative half-integers, which never arise for
molecule populations. The rule is idempotent on integers
(``round_half_up(n) == n``), so warm-restart paths that re-round
already-integer state are no-ops.
Examples
--------
>>> [round_half_up(x) for x in (0.389, 0.10001, 5.7, 155.6747, 466.98)]
[0, 0, 6, 156, 467]
>>> [round_half_up(x) for x in (0.5, 1.5, 2.5)] # ties away from zero, not bankers'
[1, 2, 3]
Parameters
----------
value : float
Continuous molecule count, already in molecule-number space. Any
volume / concentration factors must be applied before calling.
Returns
-------
int
Nearest integer count.
Raises
------
ValueError
If *value* is not finite.
"""
x = float(value)
if not math.isfinite(x):
raise ValueError(f"cannot round non-finite molecule count {value!r}")
return int(math.floor(x + 0.5)) if x >= 0.0 else int(math.ceil(x - 0.5))