Repository: FaradayInstitution/BPX

Issue:
BPX schema cannot contain its own semantic version
The BPX standard has semantic versioning (_major.minor.patch_), so the current version is `0.4.0`. However, the `["Header"]["BPX"]` field is constrained to `float` by the schema. `0.4.0` is not and cannot be a float. Therefore a valid BPX file cannot correctly state its own version.

@martinjrobins @rtimms Any suggestions for how to fix this?

Hints from issue comments:
@valentinsulzer: Seems like it should just be changed to string@ejfdickinson: @pipliggins Would you be able to fix this bug while you're at it? It's just a single value float -> string conversion for one field in the validator. I think this would be a breaking change, but a desirable one.

@rtimms 

Solution patch:
diff --git a/bpx/schema.py b/bpx/schema.py
index 43d9eb7..ddaecaf 100644
--- a/bpx/schema.py
+++ b/bpx/schema.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import warnings
 from typing import Any, Literal, Union, get_args
 
 from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
@@ -18,10 +19,12 @@ class Header(ExtraBaseModel):
     title, description).
     """
 
-    bpx: float = Field(
+    bpx: str = Field(
         alias="BPX",
-        examples=[1.0],
+        examples=["1.0.0"],
         description="BPX format version",
+        # Update this pattern to make the patch number required once the deprecation warning is removed
+        pattern=r"^\d+\.\d+(?:\.\d+)?$",
     )
     title: str = Field(
         None,
@@ -47,6 +50,24 @@ class Header(ExtraBaseModel):
         description=('Model type ("SPM", "SPMe", "DFN")'),
     )
 
+    @field_validator("bpx", mode="before")
+    @classmethod
+    def _validate_bpx_version(cls, value: str | float) -> str:
+        """
+        Warns users with old files that 'bpx' should now be a string.
+        Temporarily converts float to string for compatibility.
+        """
+        if isinstance(value, float):
+            value = f"{value:.1f}"
+            warnings.warn(
+                "The 'bpx' field now expects the BPX semantic version as a string (e.g. '0.5.0'), not a float. "
+                "This format is temporarily supported but will be removed in a future version. "
+                "Please update your file accordingly.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+        return value
+
 
 class Cell(ExtraBaseModel):
     """
diff --git a/examples/lfp_18650_cell_BPX.json b/examples/lfp_18650_cell_BPX.json
index bc4aa47..34c0b0b 100644
--- a/examples/lfp_18650_cell_BPX.json
+++ b/examples/lfp_18650_cell_BPX.json
@@ -1,6 +1,6 @@
 {
       "Header": {
-            "BPX": 0.1,
+            "BPX": "0.1.0",
             "Title": "Parameterisation example of an LFP|graphite 2 Ah cylindrical 18650 cell.",
             "Description": "LFP|graphite 2 Ah cylindrical 18650 cell. Parameterisation by About:Energy Limited (aboutenergy.io), December 2022, based on cell cycling data, and electrode data gathered after cell teardown. Electrolyte properties from Nyman et al. 2008 (doi:10.1016/j.electacta.2008.04.023). Negative electrode entropic coefficient data are from O'Regan et al. 2022 (doi:10.1016/j.electacta.2022.140700). Positive electrode entropic coefficient data are from Gerver and Meyers 2011 (doi:10.1149/1.3591799). Other thermal properties are estimated.",
             "Model": "DFN"
diff --git a/examples/nmc_pouch_cell_BPX.json b/examples/nmc_pouch_cell_BPX.json
index bb26edb..1067cc3 100644
--- a/examples/nmc_pouch_cell_BPX.json
+++ b/examples/nmc_pouch_cell_BPX.json
@@ -1,6 +1,6 @@
 {
       "Header": {
-            "BPX": 0.1,
+            "BPX": "0.1.0",
             "Title": "Parameterisation example of an NMC111|graphite 12.5 Ah pouch cell",
             "Description": "NMC111|graphite 12.5 Ah pouch cell. Parameterisation by About:Energy Limited (aboutenergy.io), December 2022, based on cell cycling data, and electrode data gathered after cell teardown. Electrolyte properties from Nyman et al. 2008 (doi:10.1016/j.electacta.2008.04.023). Negative electrode entropic coefficient data are from O'Regan et al. 2022 (doi:10.1016/j.electacta.2022.140700). Positive electrode entropic coefficient data are from Viswanathan et al. 2010 (doi:10.1016/j.jpowsour.2009.11.103). Other thermal properties are estimated.",
             "Model": "DFN"
diff --git a/examples/nmc_pouch_cell_BPX_SPM.json b/examples/nmc_pouch_cell_BPX_SPM.json
index 666416b..4231cdc 100644
--- a/examples/nmc_pouch_cell_BPX_SPM.json
+++ b/examples/nmc_pouch_cell_BPX_SPM.json
@@ -1,6 +1,6 @@
 {
       "Header": {
-            "BPX": 0.4,
+            "BPX": "0.4.0",
             "Title": "Test case: Single Particle Model (SPM) parameterisation example based on nmc_pouch_cell_BPX.json from About:Energy open-source release.",
             "Description": "NMC111|graphite 12.5 Ah pouch cell. Parameterisation by About:Energy Limited (aboutenergy.io), December 2022, based on cell cycling data, and electrode data gathered after cell teardown. Negative electrode entropic coefficient data are from O'Regan et al. 2022 (doi:10.1016/j.electacta.2022.140700). Positive electrode entropic coefficient data are from Viswanathan et al. 2010 (doi:10.1016/j.jpowsour.2009.11.103). Other thermal properties are estimated.",
             "Model": "SPM"
diff --git a/examples/nmc_pouch_cell_BPX_blended_electrode.json b/examples/nmc_pouch_cell_BPX_blended_electrode.json
index c3dc590..d93e42c 100644
--- a/examples/nmc_pouch_cell_BPX_blended_electrode.json
+++ b/examples/nmc_pouch_cell_BPX_blended_electrode.json
@@ -1,6 +1,6 @@
 {
       "Header": {
-            "BPX": 0.4,
+            "BPX": "0.4.0",
             "Title": "Test case: blended electrode definition with two particle sizes but equivalent chemistry. Compare to nmc_pouch_cell_BPX.json in About:Energy open-source release.",
             "Model": "DFN"
       },
diff --git a/examples/nmc_pouch_cell_BPX_user-defined_hysteresis.json b/examples/nmc_pouch_cell_BPX_user-defined_hysteresis.json
index 9802051..ea731ca 100644
--- a/examples/nmc_pouch_cell_BPX_user-defined_hysteresis.json
+++ b/examples/nmc_pouch_cell_BPX_user-defined_hysteresis.json
@@ -1,6 +1,6 @@
 {
       "Header": {
-            "BPX": 0.4,
+            "BPX": "0.4.0",
             "Title": "Test case: user-defined 0th-order hysteresis for graphite-Si blend in negative electrode. Compare to nmc_pouch_cell_BPX.json in About:Energy open-source release.",
             "Model": "DFN"
       },


Test patch:
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
index efe43a2..b5dcc96 100644
--- a/tests/test_parsers.py
+++ b/tests/test_parsers.py
@@ -12,7 +12,7 @@ class TestParsers(unittest.TestCase):
         base = """
             {
                 "Header": {
-                        "BPX": 0.1,
+                        "BPX": "0.1.0",
                         "Title": "Parameterisation example of an NMC111|graphite 12.5 Ah pouch cell",
                         "Model": "DFN"
                 },
diff --git a/tests/test_schema.py b/tests/test_schema.py
index b7b2463..5282392 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -16,7 +16,7 @@ class TestSchema(unittest.TestCase):
     def setUp(self) -> None:
         self.base: dict[str, Any] = {
             "Header": {
-                "BPX": 1.0,
+                "BPX": "1.0.0",
                 "Model": "DFN",
             },
             "Parameterisation": {
@@ -91,7 +91,7 @@ class TestSchema(unittest.TestCase):
         # SPM parameter set
         self.base_spm = {
             "Header": {
-                "BPX": 1.0,
+                "BPX": "1.0.0",
                 "Model": "SPM",
             },
             "Parameterisation": {
@@ -149,7 +149,7 @@ class TestSchema(unittest.TestCase):
         # Non-blended electrodes
         self.base_non_blended = {
             "Header": {
-                "BPX": 1.0,
+                "BPX": "1.0.0",
                 "Model": "SPM",
             },
             "Parameterisation": {
@@ -389,6 +389,18 @@ class TestSchema(unittest.TestCase):
         with pytest.raises(TypeError):
             adapter.validate_python(test)
 
+    def test_deprecated_bpx_version(self) -> None:
+        test = copy.deepcopy(self.base)
+        test["Header"]["BPX"] = 0.4
+        with pytest.warns(DeprecationWarning, match="The 'bpx' field now expects the BPX semantic version as a string"):
+            adapter.validate_python(test)
+
+    def test_bad_bpx_version(self) -> None:
+        test = copy.deepcopy(self.base)
+        test["Header"]["BPX"] = "1.2.3.4"  # Invalid version format
+        with pytest.raises(ValidationError, match="String should match pattern"):
+            adapter.validate_python(test)
+
 
 if __name__ == "__main__":
     unittest.main()