Repository: opencobra/cobrapy

Issue:
[BUG] `check_mass_balance` fails for floating point coefficients
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Problem description

The `check_mass_balance` function for reactions gives an incorrect result for reactions with floating point coefficients.

Specifically, scaling the coefficients by a float (like in the example below) returns a dictionary of near-zero values. A non-empty dictionary implies the reaction is unbalanced, when it's really just a floating-point error.

A simple fix is to filter out near-zero values with something like `numpy.isclose`.

### Code sample

Code run:

```python
from cobra import Model, Metabolite, Reaction

# setup
model = Model("model")

adp = Metabolite("adp", formula="C10H12N5O10P2", charge=-3)
atp = Metabolite("atp", formula="C10H12N5O13P3", charge=-4)
h = Metabolite("h", formula="H", charge=1)
h2o = Metabolite("h2o", formula="H2O", charge=0)
pi = Metabolite("pi", formula="HO4P", charge=-2)
model.add_metabolites([adp, atp, h, h2o, pi])

reaction = Reaction("reaction")
reaction.add_metabolites({atp: -1, h2o: -1, adp: 1, pi: 1, h: 1})
model.add_reactions([reaction])

print(reaction.check_mass_balance())
# {}

# float coefficients return a mass balance dict of near-zero values
reaction_scaled = reaction * 1.3
print(reaction_scaled.check_mass_balance())
# {'charge': -2.220446049250313e-16, 'H': -1.3322676295501878e-15, 'O': -2.6645352591003757e-15, 'P': -2.220446049250313e-16}

```

### Environment

<details>

</details>


### Anything else?

_No response_

Discussion:

Solution patch:
diff --git a/release-notes/next-release.md b/release-notes/next-release.md
index 65d76e29..8a5a5cd3 100644
--- a/release-notes/next-release.md
+++ b/release-notes/next-release.md
@@ -9,6 +9,8 @@ Fixes failures of GPR.copy() in Python 3.13.
 Fix compartment not being stored for metabolites created during
 reaction.build_reaction_from_string
 
+Fix `reaction.check_mass_balance` giving incorrect results for reactions with floating point coefficients.
+
 ## Other
 
 ## Deprecated features
diff --git a/src/cobra/core/reaction.py b/src/cobra/core/reaction.py
index 9b1755df..b6951ffd 100644
--- a/src/cobra/core/reaction.py
+++ b/src/cobra/core/reaction.py
@@ -5,7 +5,7 @@ import re
 from collections import defaultdict
 from copy import copy, deepcopy
 from functools import partial
-from math import isinf
+from math import isclose, isinf
 from operator import attrgetter
 from typing import (
     TYPE_CHECKING,
@@ -1451,7 +1451,11 @@ class Reaction(Object):
             for element, amount in metabolite.elements.items():
                 reaction_element_dict[element] += coefficient * amount
         # filter out 0 values
-        return {k: v for k, v in reaction_element_dict.items() if v != 0}
+        return {
+            k: v
+            for k, v in reaction_element_dict.items()
+            if not isclose(v, 0.0, abs_tol=config.tolerance)
+        }
 
     @property
     def compartments(self) -> Set:


Test patch:
diff --git a/tests/test_core/test_core_reaction.py b/tests/test_core/test_core_reaction.py
index d4813599..40068ba3 100644
--- a/tests/test_core/test_core_reaction.py
+++ b/tests/test_core/test_core_reaction.py
@@ -333,6 +333,9 @@ def test_mass_balance(model: Model) -> None:
     imbalance = reaction.check_mass_balance()
     assert "H" in imbalance
     assert imbalance["H"] == 1
+    # Should be balanced even when coefficients are non-integer
+    reaction_scaled = model.reactions.get_by_id("ATPS4r") * 1.3
+    assert len(reaction_scaled.check_mass_balance()) == 0
 
 
 def test_build_from_string(model: Model) -> None: