diff --git a/assets/lfp_18650_cell_BPX.json b/assets/lfp_18650_cell_BPX.json new file mode 100644 index 0000000..d4a22b9 --- /dev/null +++ b/assets/lfp_18650_cell_BPX.json @@ -0,0 +1,75 @@ +{ + "Header": { + "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" + }, + "Parameterisation": { + "Cell": { + "Ambient temperature [K]": 298.15, + "Initial temperature [K]": 298.15, + "Reference temperature [K]": 298.15, + "Lower voltage cut-off [V]": 2.0, + "Upper voltage cut-off [V]": 3.65, + "Nominal cell capacity [A.h]": 2, + "Specific heat capacity [J.K-1.kg-1]": 999, + "Thermal conductivity [W.m-1.K-1]": 1.89, + "Density [kg.m-3]": 1940, + "Electrode area [m2]": 0.08959998, + "Number of electrode pairs connected in parallel to make a cell": 1, + "External surface area [m2]": 0.00431, + "Volume [m3]": 1.7e-05 + }, + "Electrolyte": { + "Initial concentration [mol.m-3]": 1000, + "Cation transference number": 0.259, + "Conductivity [S.m-1]": "0.1297 * (x / 1000) ** 3 - 2.51 * (x / 1000) ** 1.5 + 3.329 * (x / 1000)", + "Diffusivity [m2.s-1]": "8.794e-11 * (x / 1000) ** 2 - 3.972e-10 * (x / 1000) + 4.862e-10", + "Conductivity activation energy [J.mol-1]": 17100, + "Diffusivity activation energy [J.mol-1]": 17100 + }, + "Negative electrode": { + "Particle radius [m]": 4.8e-06, + "Thickness [m]": 4.44e-05, + "Diffusivity [m2.s-1]": 9.6e-15, + "OCP [V]": "5.29210878e+01 * exp(-1.72699386e+02 * x) - 1.17963399e+03 + 1.20956356e+03 * tanh(6.72033948e+01 * (x + 2.44746396e-02)) + 4.52430314e-02 * tanh(-1.47542326e+01 * (x - 1.62746053e-01)) + 2.01855800e+01 * tanh(-2.46666302e+01 * (x - 1.12986136e+00)) + 2.01708039e-02 * tanh(-1.19900231e+01 * (x - 5.49773440e-01)) + 4.99616805e+01 * tanh(-6.11370883e+01 * (x + 4.69382558e-03))", + "Entropic change coefficient [V.K-1]": "(-0.1112 * x + 0.02914 + 0.3561 * exp(-((x - 0.08309) ** 2) / 0.004616)) / 1000", + "Conductivity [S.m-1]": 7.46, + "Surface area per unit volume [m-1]": 473004, + "Porosity": 0.20666, + "Transport efficiency": 0.09395, + "Reaction rate constant [mol.m-2.s-1]": 6.872e-06, + "Minimum stoichiometry": 0.0016261, + "Maximum stoichiometry": 0.82258, + "Maximum concentration [mol.m-3]": 31400, + "Diffusivity activation energy [J.mol-1]": 30000, + "Reaction rate constant activation energy [J.mol-1]": 55000 + }, + "Positive electrode": { + "Particle radius [m]": 5e-07, + "Thickness [m]": 6.43e-05, + "Diffusivity [m2.s-1]": 6.873e-17, + "OCP [V]": "3.41285712e+00 - 1.49721852e-02 * x + 3.54866018e+14 * exp(-3.95729493e+02 * x) - 1.45998465e+00 * exp(-1.10108622e+02 * (1 - x))", + "Entropic change coefficient [V.K-1]": { + "x": [0, 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1], + "y": [0.0001, 4.7145e-05, 3.7666e-05, 2.0299e-05, 5.9833e-06, -4.6859e-06, -1.3966e-05, -2.3528e-05, -3.3593e-05, -4.3433e-05, -5.2311e-05, -6.0211e-05, -6.8006e-05, -7.6939e-05, -8.7641e-05, -9.913e-05, -0.00010855, -0.00011266, -0.00011238, -0.00010921, -0.00022539] + }, + "Conductivity [S.m-1]": 0.80, + "Surface area per unit volume [m-1]": 4418460, + "Porosity": 0.20359, + "Transport efficiency": 0.09186, + "Reaction rate constant [mol.m-2.s-1]": 9.736e-07, + "Minimum stoichiometry": 0.0875, + "Maximum stoichiometry": 0.95038, + "Maximum concentration [mol.m-3]": 21200, + "Diffusivity activation energy [J.mol-1]": 80000, + "Reaction rate constant activation energy [J.mol-1]": 35000 + }, + "Separator": { + "Thickness [m]": 2e-05, + "Porosity": 0.47, + "Transport efficiency": 0.3222 + } + } +} diff --git a/assets/nmc_pouch_cell_BPX.json b/assets/nmc_pouch_cell_BPX.json new file mode 100644 index 0000000..f5f81b7 --- /dev/null +++ b/assets/nmc_pouch_cell_BPX.json @@ -0,0 +1,86 @@ +{ + "Header": { + "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" + }, + "Parameterisation": { + "Cell": { + "Ambient temperature [K]": 298.15, + "Initial temperature [K]": 298.15, + "Reference temperature [K]": 298.15, + "Lower voltage cut-off [V]": 2.7, + "Upper voltage cut-off [V]": 4.2, + "Nominal cell capacity [A.h]": 12.5, + "Specific heat capacity [J.K-1.kg-1]": 913, + "Thermal conductivity [W.m-1.K-1]": 2.04, + "Density [kg.m-3]": 1847, + "Electrode area [m2]": 0.016808, + "Number of electrode pairs connected in parallel to make a cell": 34, + "External surface area [m2]": 0.0379, + "Volume [m3]": 0.000128 + }, + "Electrolyte": { + "Initial concentration [mol.m-3]": 1000, + "Cation transference number": 0.2594, + "Conductivity [S.m-1]": "0.1297 * (x / 1000) ** 3 - 2.51 * (x / 1000) ** 1.5 + 3.329 * (x / 1000)", + "Diffusivity [m2.s-1]": "8.794e-11 * (x / 1000) ** 2 - 3.972e-10 * (x / 1000) + 4.862e-10", + "Conductivity activation energy [J.mol-1]": 17100, + "Diffusivity activation energy [J.mol-1]": 17100 + }, + "Negative electrode": { + "Particle radius [m]": 4.12e-06, + "Thickness [m]": 5.62e-05, + "Diffusivity [m2.s-1]": 2.728e-14, + "OCP [V]": "9.47057878e-01 * exp(-1.59418743e+02 * x) - 3.50928033e+04 + 1.64230269e-01 * tanh(-4.55509094e+01 * (x - 3.24116012e-02 )) + 3.69968491e-02 * tanh(-1.96718868e+01 * (x - 1.68334476e-01)) + 1.91517003e+04 * tanh(3.19648312e+00 * (x - 1.85139824e+00)) + 5.42448511e+04 * tanh(-3.19009848e+00 * (x - 2.01660395e+00))", + "Entropic change coefficient [V.K-1]": "(-0.1112 * x + 0.02914 + 0.3561 * exp(-((x - 0.08309) ** 2) / 0.004616)) / 1000", + "Conductivity [S.m-1]": 0.222, + "Surface area per unit volume [m-1]": 499522, + "Porosity": 0.253991, + "Transport efficiency": 0.128, + "Reaction rate constant [mol.m-2.s-1]": 5.199e-06, + "Minimum stoichiometry": 0.005504, + "Maximum stoichiometry": 0.75668, + "Maximum concentration [mol.m-3]": 29730, + "Diffusivity activation energy [J.mol-1]": 30000, + "Reaction rate constant activation energy [J.mol-1]": 55000 + }, + "Positive electrode": { + "Particle radius [m]": 4.6e-06, + "Thickness [m]": 5.23e-05, + "Diffusivity [m2.s-1]": 3.2e-14, + "OCP [V]": "-3.04420906 * x + 10.04892207 - 0.65637536 * tanh(-4.02134095 * (x - 0.80063948)) + 4.24678547 * tanh(12.17805062 * (x - 7.57659337)) - 0.3757068 * tanh(59.33067782 * (x - 0.99784492))", + "Entropic change coefficient [V.K-1]": -1e-4, + "Conductivity [S.m-1]": 0.789, + "Surface area per unit volume [m-1]": 432072, + "Porosity": 0.277493, + "Transport efficiency": 0.1462, + "Reaction rate constant [mol.m-2.s-1]": 2.305e-05, + "Minimum stoichiometry": 0.42424, + "Maximum stoichiometry": 0.96210, + "Maximum concentration [mol.m-3]": 46200, + "Diffusivity activation energy [J.mol-1]": 15000, + "Reaction rate constant activation energy [J.mol-1]": 35000 + }, + "Separator": { + "Thickness [m]": 2e-05, + "Porosity": 0.47, + "Transport efficiency": 0.3222 + } + }, + "Validation": { + "C/20 discharge": { + "Time [s]": [0, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000, 11000, 12000, 13000, 14000, 15000, 16000, 17000, 18000, 19000, 20000, 21000, 22000, 23000, 24000, 25000, 26000, 27000, 28000, 29000, 30000, 31000, 32000, 33000, 34000, 35000, 36000, 37000, 38000, 39000, 40000, 41000, 42000, 43000, 44000, 45000, 46000, 47000, 48000, 49000, 50000, 51000, 52000, 53000, 54000, 55000, 56000, 57000, 58000, 59000, 60000, 61000, 62000, 63000, 64000, 65000, 66000, 67000, 68000, 69000, 70000, 71000, 72000, 73000, 74000, 75000], + "Current [A]": [-0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625, -0.625], + "Voltage [V]": [4.19367569, 4.1677888, 4.14976386, 4.13250593, 4.11582327, 4.09952412, 4.08360848, 4.06788459, 4.05254422, 4.03720384, 4.02186346, 4.00690659, 3.99194973, 3.97699286, 3.96222774, 3.94727088, 3.93250576, 3.9173572, 3.90240027, 3.8874434, 3.87191127, 3.85637914, 3.8404635, 3.82493136, 3.80978274, 3.79482587, 3.78082778, 3.76740495, 3.75455738, 3.74286035, 3.73173857, 3.72176733, 3.71237135, 3.70393414, 3.69607219, 3.68859376, 3.68169059, 3.67517093, 3.66922653, 3.66309038, 3.65752958, 3.65235212, 3.64717474, 3.64257263, 3.637587, 3.63298489, 3.62819102, 3.62339716, 3.61879504, 3.61380942, 3.6088238, 3.60345467, 3.59770202, 3.59175763, 3.58542972, 3.57891014, 3.57200689, 3.56472021, 3.55685827, 3.54822931, 3.53864157, 3.52828682, 3.5173568, 3.5071938, 3.49894834, 3.49204517, 3.48590902, 3.47938936, 3.47152742, 3.4605974, 3.44218895, 3.39981122, 3.33749094, 3.25407757, 3.13308034, 2.89472934], + "Temperature [K]": [298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15] + }, + "1C discharge": { + "Time [s]": [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000, 2100, 2200, 2300, 2400, 2500, 2600, 2700, 2800, 2900, 3000, 3100, 3200, 3300, 3400, 3500, 3600, 3700], + "Current [A]": [-12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5, -12.5], + "Voltage [V]": [4.1936757, 4.0487091, 4.0107418, 3.9762259, 3.9426688, 3.9094952, 3.8763218, 3.8433398, 3.8113168, 3.7802525, 3.7505305, 3.7221509, 3.695497, 3.6703771, 3.6467912, 3.6247394, 3.6042217, 3.5858132, 3.5685555, 3.5532149, 3.5382581, 3.5238765, 3.5102619, 3.4974143, 3.4849502, 3.4728697, 3.4609809, 3.4485169, 3.4352858, 3.4216712, 3.4070979, 3.3907987, 3.3720067, 3.3474621, 3.3121793, 3.2569541, 3.1589672, 2.9047014], + "Temperature [K]": [298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15, 298.15] + } + } +} diff --git a/main.py b/main.py index d9005e6..8b726f5 100644 --- a/main.py +++ b/main.py @@ -72,40 +72,40 @@ def input_parser(): input_types = list(set(types) - {"jsonld"}) output_types = types - parser.add_argument("-input-file", required=True, help="Input filename") + parser.add_argument("--input-file", required=True, help="Input filename") parser.add_argument( - "-input-type", + "--input-type", required=True, help=f"Input type string (must be one of {input_types})", ) parser.add_argument( - "-output-file", required=False, default="output.jsonld", help="Output filename" + "--output-file", required=False, default="output.jsonld", help="Output filename" ) parser.add_argument( - "-output-type", + "--output-type", required=False, default="jsonld", help=f"Output type string (must be one of {output_types})", ) parser.add_argument( - "-cell-id", + "--cell-id", required=False, default="Cell ID", help="Cell ID (eg BattMo) for JSON-LD output", ) parser.add_argument( - "-cell-type", + "--cell-type", required=False, default="Pouch", help="Cell Type (eg Pouch) for JSON-LD output", ) parser.add_argument( - "-ontology-ref", + "--ontology-ref", default="assets/battery-model-lithium-ion.ttl", help="Ontology file path", ) parser.add_argument( - "-template-ref", default="assets/bpx_template.json", help="Template file path" + "--template-ref", default="assets/bpx_template.json", help="Template file path" ) return parser, input_types, output_types diff --git a/scripts/extract_validation_data.py b/scripts/extract_validation_data.py new file mode 100644 index 0000000..ec18eff --- /dev/null +++ b/scripts/extract_validation_data.py @@ -0,0 +1,132 @@ +"""Utility script to extract validation data from a BPX JSON file. + +The BPX format allows an optional "Validation" section containing experimental +discharge curves (e.g. C/20 discharge, 1C discharge). This script extracts +those curves and saves each experiment as a CSV file. + +Usage:: + + python scripts/extract_validation_data.py assets/nmc_pouch_cell_BPX.json + +This will create one CSV file per experiment found in the Validation section, +named after the experiment (spaces replaced by underscores), e.g.: + nmc_pouch_cell_BPX_C_20_discharge.csv + nmc_pouch_cell_BPX_1C_discharge.csv + +Each CSV contains the columns present in the experiment data, e.g.: + Time [s], Current [A], Voltage [V], Temperature [K] + +Options +------- +--output-dir Directory in which to write output CSV files (default: same + directory as the input file). +--list List available experiments without writing any files. +""" + +import argparse +import csv +import json +import os +import sys + + +def load_bpx(path): + """Load a BPX JSON file and return the parsed dict.""" + with open(path) as f: + return json.load(f) + + +def list_experiments(data): + """Return the names of all experiments in the Validation section.""" + validation = data.get("Validation", {}) + return list(validation.keys()) + + +def extract_experiment(data, experiment_name): + """Return the rows (list of dicts) for a single experiment. + + Parameters + ---------- + data : dict + Parsed BPX data. + experiment_name : str + Key inside the ``Validation`` section. + + Returns + ------- + list[dict] + List of row dicts with column names as keys. + """ + experiment = data["Validation"][experiment_name] + columns = list(experiment.keys()) + n_rows = len(experiment[columns[0]]) + rows = [] + for i in range(n_rows): + row = {col: experiment[col][i] for col in columns} + rows.append(row) + return rows, columns + + +def safe_filename(name): + """Convert an experiment name to a safe filename fragment.""" + return name.replace("/", "_").replace(" ", "_").replace("\\", "_") + + +def write_csv(rows, columns, path): + """Write rows to a CSV file.""" + with open(path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns) + writer.writeheader() + writer.writerows(rows) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Extract validation data from a BPX JSON file into CSV files." + ) + parser.add_argument("bpx_file", help="Path to the BPX JSON file.") + parser.add_argument( + "--output-dir", + default=None, + help="Directory for output CSV files (default: same directory as input file).", + ) + parser.add_argument( + "--list", + action="store_true", + help="List available experiments and exit without writing files.", + ) + args = parser.parse_args(argv) + + bpx_path = args.bpx_file + if not os.path.isfile(bpx_path): + print(f"Error: file not found: {bpx_path}", file=sys.stderr) + sys.exit(1) + + data = load_bpx(bpx_path) + + experiments = list_experiments(data) + if not experiments: + print("No Validation section found in the BPX file.", file=sys.stderr) + sys.exit(0) + + if args.list: + print("Available experiments:") + for name in experiments: + print(f" {name}") + return + + output_dir = args.output_dir or os.path.dirname(os.path.abspath(bpx_path)) + os.makedirs(output_dir, exist_ok=True) + + base = os.path.splitext(os.path.basename(bpx_path))[0] + + for experiment_name in experiments: + rows, columns = extract_experiment(data, experiment_name) + out_name = f"{base}_{safe_filename(experiment_name)}.csv" + out_path = os.path.join(output_dir, out_name) + write_csv(rows, columns, out_path) + print(f"Wrote {len(rows)} rows to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/test/test_bpx_github_files.py b/test/test_bpx_github_files.py new file mode 100644 index 0000000..719a57b --- /dev/null +++ b/test/test_bpx_github_files.py @@ -0,0 +1,485 @@ +"""Tests for BPX GitHub example files and Chen 2020 round-trip conversion. + +This module tests: +1. Conversion of the BPX GitHub example files (NMC pouch cell and LFP 18650) + to all supported output formats (battmo.m, battmo.jl, jsonld). +2. Round-trip consistency between the NMC BPX file and its BattMo counterpart, + which represent the same cell parameterisation (the About:Energy NMC111|graphite + dataset, also known as the "Chen 2020" example used in BPX and BattMo). + +Coverage policy +--------------- +Every BPX input field is examined: +* If the ontology has a mapping for the field, its value in the converted output + is asserted to be correct (hard failure on mismatch). +* If the ontology has **no** mapping for the field, a ``UserWarning`` is issued + so the gap is visible in the test output without causing a test failure. +""" + +import json +import math +import os +import warnings + +import pytest + +import BatteryModelMapper as bmm + +ASSETS_DIR = os.path.join(os.path.dirname(__file__), "..", "assets") +ONTOLOGY_PATH = os.path.join(ASSETS_DIR, "battery-model-lithium-ion.ttl") +BPX_TEMPLATE = os.path.join(ASSETS_DIR, "bpx_template.json") +BATTMO_TEMPLATE = os.path.join(ASSETS_DIR, "battmo_template.json") + +# BPX GitHub example files +NMC_BPX = os.path.join(ASSETS_DIR, "nmc_pouch_cell_BPX.json") +LFP_BPX = os.path.join(ASSETS_DIR, "lfp_18650_cell_BPX.json") + +# Existing sample files that represent the same NMC dataset in BattMo format +SAMPLE_BATTMO = os.path.join(ASSETS_DIR, "sample_battmo_input.json") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_nested_value(data, path): + """Traverse a nested dict/list using a tuple of keys; return None if absent.""" + try: + for key in path: + if isinstance(data, dict): + data = data[key] + elif isinstance(data, list): + data = data[int(key)] + else: + return None + return data + except (KeyError, IndexError, TypeError, ValueError): + return None + + +def _check_all_mapped_fields(bpx_raw, converted_output, mappings, output_type): + """Assert every mapped BPX field appears correctly in *converted_output*. + + For each field present in ``bpx_raw["Parameterisation"]``: + + * If the ontology *has* a mapping for it, assert the corresponding key + exists in ``converted_output`` and that the value matches to within a + relative tolerance of 1 ppm for numeric values. String / function + fields are checked structurally (expression non-empty). Any mismatch + is collected and reported together as a hard failure at the end. + + * If the ontology has *no* mapping for the field, emit a ``UserWarning`` + so the gap is visible in the test output without causing a failure. + + Parameters + ---------- + bpx_raw : dict + The raw (pre-preprocessed) BPX data dict. + converted_output : dict + The result of the conversion. + mappings : dict + ``{input_path_tuple: output_path_tuple}`` returned by + ``OntologyParser.get_mappings()``. + output_type : str + ``"battmo.m"``, ``"battmo.jl"``, or ``"bpx"``. + """ + param = bpx_raw.get("Parameterisation", {}) + all_input_paths = {} + for section, contents in param.items(): + if isinstance(contents, dict): + for field, value in contents.items(): + all_input_paths[("Parameterisation", section, field)] = value + + failures = [] + + for input_path in sorted(all_input_paths): + input_value = all_input_paths[input_path] + + if input_path not in mappings: + warnings.warn( + f"BPX field {input_path!r} has no mapping to {output_type!r} " + "and will not appear in the converted output.", + UserWarning, + stacklevel=2, + ) + continue + + output_path = mappings[input_path] + actual = _get_nested_value(converted_output, output_path) + + if actual is None: + failures.append( + f"KEY MISMATCH: BPX field {input_path!r} is mapped to " + f"{output_path!r} but that key is absent in the converted output." + ) + continue + + # --- value comparison --- + if isinstance(input_value, (int, float)): + if not isinstance(actual, (int, float)): + failures.append( + f"TYPE MISMATCH at {output_path!r}: " + f"expected numeric, got {type(actual).__name__} = {actual!r}" + ) + elif not math.isclose(float(actual), float(input_value), rel_tol=1e-6): + failures.append( + f"VALUE MISMATCH at {output_path!r}: " + f"expected {input_value!r}, got {actual!r}" + ) + + elif isinstance(input_value, str): + # BPX string expressions → BattMo function objects or kept as strings + if isinstance(actual, dict): + expr = actual.get("expression", "") + if not expr: + failures.append( + f"VALUE MISMATCH at {output_path!r}: " + f"expected a dict with non-empty 'expression', got {actual!r}" + ) + elif not isinstance(actual, str): + failures.append( + f"TYPE MISMATCH at {output_path!r}: " + f"expected str or function dict, got {type(actual).__name__}" + ) + + # dict values (e.g. tabulated LFP entropic coefficient) arriving here + # means they were mapped; key presence is sufficient since the mapper + # does not convert tabulated data structurally. + + if failures: + pytest.fail( + "Field conversion failures:\n" + "\n".join(f" - {f}" for f in failures) + ) + + +def _check_round_trip(converted, reference, mapped_output_paths): + """Verify that every mapped output field in *converted* matches *reference*. + + For each path in *mapped_output_paths* (the output side of the mappings): + + * If the path is present in *reference* and the values match → pass silently. + * If the path is present in *reference* but the values differ → ``UserWarning``. + * If the path is absent from *converted* but present in *reference* → + ``UserWarning``. + + Parameters + ---------- + converted : dict + Conversion result to check. + reference : dict + Ground-truth / reference data. + mapped_output_paths : iterable of tuples + The output key paths produced by the ontology mappings. + """ + for output_path in sorted(mapped_output_paths): + ref_val = _get_nested_value(reference, output_path) + if ref_val is None: + # Reference doesn't have this field; nothing to compare. + continue + + conv_val = _get_nested_value(converted, output_path) + if conv_val is None: + warnings.warn( + f"ROUND-TRIP KEY MISMATCH: path {output_path!r} is present in the " + "reference but absent in the converted output.", + UserWarning, + stacklevel=2, + ) + continue + + if isinstance(ref_val, (int, float)) and isinstance(conv_val, (int, float)): + if not math.isclose(float(conv_val), float(ref_val), rel_tol=1e-6): + warnings.warn( + f"ROUND-TRIP VALUE MISMATCH at {output_path!r}: " + f"reference={ref_val!r}, converted={conv_val!r}", + UserWarning, + stacklevel=2, + ) + elif isinstance(ref_val, str) and isinstance(conv_val, str): + if conv_val != ref_val: + warnings.warn( + f"ROUND-TRIP VALUE MISMATCH at {output_path!r}: " + f"reference={ref_val!r}, converted={conv_val!r}", + UserWarning, + stacklevel=2, + ) + elif isinstance(ref_val, str) and isinstance(conv_val, dict): + # BPX string → BattMo function object: compare expression + expr = conv_val.get("expression", "") + if not expr: + warnings.warn( + f"ROUND-TRIP VALUE MISMATCH at {output_path!r}: " + f"expected non-empty expression, got {conv_val!r}", + UserWarning, + stacklevel=2, + ) + elif isinstance(ref_val, dict) and isinstance(conv_val, str): + # BattMo function object → BPX string: compare expression + expr = ref_val.get("expression", "") + if expr and conv_val != expr: + warnings.warn( + f"ROUND-TRIP VALUE MISMATCH at {output_path!r}: " + f"reference expression={expr!r}, converted={conv_val!r}", + UserWarning, + stacklevel=2, + ) + + +@pytest.fixture(scope="module") +def ontology(): + return bmm.OntologyParser(ONTOLOGY_PATH) + + +def _convert(ontology, input_data, input_type, output_type, input_file): + """Run a conversion and return the output dict.""" + mappings = ontology.get_mappings(input_type, output_type) + if output_type in ("battmo.m", "battmo.jl"): + template = bmm.JSONLoader.load(BATTMO_TEMPLATE) + else: + template = bmm.JSONLoader.load(BPX_TEMPLATE) + template.pop("Validation", None) + mapper = bmm.ParameterMapper( + mappings, template, input_file, output_type, input_type + ) + return mapper.map_parameters(input_data) + + +# --------------------------------------------------------------------------- +# NMC pouch cell BPX file (from BPX GitHub) +# --------------------------------------------------------------------------- +class TestNMCBPXToBattMo: + """Convert the NMC pouch cell BPX file to BattMo format. + + All BPX fields are examined. Mapped fields are hard-asserted to be present + with the correct value. Unmapped fields produce UserWarnings. + """ + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + self.bpx_raw = bmm.JSONLoader.load(NMC_BPX) + processed = bmm.PreprocessInput("bpx", self.bpx_raw).process() + self.mappings = ontology.get_mappings("bpx", "battmo.m") + self.result = _convert(ontology, processed, "bpx", "battmo.m", NMC_BPX) + + def test_top_level_sections_present(self): + for section in ("NegativeElectrode", "PositiveElectrode", "Separator", "Electrolyte"): + assert section in self.result, f"Missing top-level section: {section}" + + def test_all_fields(self): + """Check every BPX field; warn about unmapped ones, assert mapped ones.""" + _check_all_mapped_fields( + self.bpx_raw, self.result, self.mappings, "battmo.m" + ) + + +class TestNMCBPXToBattMoJl: + """battmo.m and battmo.jl produce identical output for the NMC file.""" + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + data = bmm.JSONLoader.load(NMC_BPX) + data = bmm.PreprocessInput("bpx", data).process() + self.result_m = _convert(ontology, data, "bpx", "battmo.m", NMC_BPX) + self.result_jl = _convert(ontology, data, "bpx", "battmo.jl", NMC_BPX) + + def test_same_output(self): + assert json.dumps(self.result_m, sort_keys=True) == json.dumps( + self.result_jl, sort_keys=True + ) + + +class TestNMCBPXToJSONLD: + """Convert the NMC pouch cell BPX file to JSON-LD.""" + + @pytest.fixture(autouse=True) + def _setup(self, ontology, tmp_path): + data = bmm.JSONLoader.load(NMC_BPX) + data = bmm.PreprocessInput("bpx", data).process() + outpath = str(tmp_path / "nmc_bpx.jsonld") + bmm.export_jsonld( + ontology, "bpx", data, outpath, + cell_id="NMCCell", cell_type="PouchCell", + ) + with open(outpath) as f: + self.result = json.load(f) + + def test_has_context(self): + assert "@context" in self.result + + def test_has_graph(self): + assert "@graph" in self.result + + def test_has_properties(self): + assert len(self.result["@graph"]["hasProperty"]) > 0 + + +# --------------------------------------------------------------------------- +# LFP 18650 BPX file (from BPX GitHub) +# --------------------------------------------------------------------------- +class TestLFPBPXToBattMo: + """Convert the LFP 18650 BPX file to BattMo format. + + All BPX fields are examined. Mapped fields are hard-asserted to be present + with the correct value. Unmapped fields produce UserWarnings. + """ + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + self.bpx_raw = bmm.JSONLoader.load(LFP_BPX) + processed = bmm.PreprocessInput("bpx", self.bpx_raw).process() + self.mappings = ontology.get_mappings("bpx", "battmo.m") + self.result = _convert(ontology, processed, "bpx", "battmo.m", LFP_BPX) + + def test_top_level_sections_present(self): + for section in ("NegativeElectrode", "PositiveElectrode", "Separator", "Electrolyte"): + assert section in self.result, f"Missing top-level section: {section}" + + def test_all_fields(self): + """Check every BPX field; warn about unmapped ones, assert mapped ones.""" + _check_all_mapped_fields( + self.bpx_raw, self.result, self.mappings, "battmo.m" + ) + + +class TestLFPBPXToBattMoJl: + """battmo.m and battmo.jl produce identical output for the LFP file.""" + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + data = bmm.JSONLoader.load(LFP_BPX) + data = bmm.PreprocessInput("bpx", data).process() + self.result_m = _convert(ontology, data, "bpx", "battmo.m", LFP_BPX) + self.result_jl = _convert(ontology, data, "bpx", "battmo.jl", LFP_BPX) + + def test_same_output(self): + assert json.dumps(self.result_m, sort_keys=True) == json.dumps( + self.result_jl, sort_keys=True + ) + + +class TestLFPBPXToJSONLD: + """Convert the LFP 18650 BPX file to JSON-LD.""" + + @pytest.fixture(autouse=True) + def _setup(self, ontology, tmp_path): + data = bmm.JSONLoader.load(LFP_BPX) + data = bmm.PreprocessInput("bpx", data).process() + outpath = str(tmp_path / "lfp_bpx.jsonld") + bmm.export_jsonld( + ontology, "bpx", data, outpath, + cell_id="LFPCell", cell_type="CylindricalCell", + ) + with open(outpath) as f: + self.result = json.load(f) + + def test_has_context(self): + assert "@context" in self.result + + def test_has_graph(self): + assert "@graph" in self.result + + def test_has_properties(self): + assert len(self.result["@graph"]["hasProperty"]) > 0 + + +# --------------------------------------------------------------------------- +# Chen 2020 round-trip: NMC BPX ↔ BattMo +# +# The nmc_pouch_cell_BPX.json (BPX GitHub) and sample_battmo_input.json +# represent the same NMC111|graphite parameterisation in two formats. +# These tests verify that converting between them yields consistent values. +# +# Mismatches (key or value) produce UserWarnings so that gaps are visible +# without causing hard failures. +# --------------------------------------------------------------------------- +class TestChen2020BPXToBattMo: + """NMC BPX → BattMo conversion round-trip against the reference BattMo file. + + Every output path produced by the BPX→BattMo mappings is compared against + the corresponding value in the reference BattMo file. Mismatches (missing + keys or differing values) are reported as UserWarnings. + """ + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + bpx_raw = bmm.JSONLoader.load(NMC_BPX) + processed = bmm.PreprocessInput("bpx", bpx_raw).process() + self.mappings = ontology.get_mappings("bpx", "battmo.m") + self.converted = _convert(ontology, processed, "bpx", "battmo.m", NMC_BPX) + self.reference = bmm.JSONLoader.load(SAMPLE_BATTMO) + + def test_all_mapped_fields_present_in_output(self): + """Every mapped BPX field must produce a key in the BattMo output.""" + bpx_raw = bmm.JSONLoader.load(NMC_BPX) + missing = [] + for bpx_path, battmo_path in self.mappings.items(): + input_val = _get_nested_value( + bpx_raw.get("Parameterisation", {}), + bpx_path[1:], # strip leading 'Parameterisation' + ) + if input_val is None: + continue + if _get_nested_value(self.converted, battmo_path) is None: + missing.append(f"{bpx_path!r} -> {battmo_path!r}") + if missing: + pytest.fail( + "The following mapped fields are absent from the converted output:\n" + + "\n".join(f" - {m}" for m in missing) + ) + + def test_round_trip_values_match_reference(self): + """Converted values match the reference BattMo file (warns on mismatch).""" + _check_round_trip( + self.converted, + self.reference, + self.mappings.values(), + ) + + +class TestChen2020BattMoToBPX: + """BattMo sample → BPX conversion round-trip against the NMC BPX reference. + + Every output path produced by the BattMo→BPX mappings is compared against + the corresponding value in the reference NMC BPX file. Mismatches (missing + keys or differing values) are reported as UserWarnings. + """ + + @pytest.fixture(autouse=True) + def _setup(self, ontology): + battmo_raw = bmm.JSONLoader.load(SAMPLE_BATTMO) + processed = bmm.PreprocessInput("battmo.m", battmo_raw).process() + self.mappings = ontology.get_mappings("battmo.m", "bpx") + self.converted = _convert( + ontology, processed, "battmo.m", "bpx", SAMPLE_BATTMO + ) + self.reference = bmm.JSONLoader.load(NMC_BPX) + + def test_all_mapped_fields_present_in_output(self): + """Every mapped BattMo field must produce a key in the BPX output.""" + battmo_raw = bmm.JSONLoader.load(SAMPLE_BATTMO) + missing = [] + for battmo_path, bpx_path in self.mappings.items(): + input_val = _get_nested_value(battmo_raw, battmo_path) + if input_val is None: + continue + if _get_nested_value(self.converted, bpx_path) is None: + missing.append(f"{battmo_path!r} -> {bpx_path!r}") + if missing: + pytest.fail( + "The following mapped fields are absent from the converted output:\n" + + "\n".join(f" - {m}" for m in missing) + ) + + def test_round_trip_values_match_reference(self): + """Converted values match the reference NMC BPX file (warns on mismatch).""" + # BattMo→BPX output paths start with 'Parameterisation'. Strip that + # prefix so we can compare within the Parameterisation sub-dict of both + # the converted output and the reference file. + _check_round_trip( + self.converted.get("Parameterisation", {}), + self.reference.get("Parameterisation", {}), + [ + p[1:] # strip leading 'Parameterisation' + for p in self.mappings.values() + ], + )