Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Features

- [#873](https://github.com/pybop-team/PyBOP/pull/873) - Adds methods for saving result and reconstructing result from saved data. `result.save`: saves entire python object using pickle. `result.save_data`: saves primarily the logger data and any other data required to reconstruct the result from the problem or the sampler (for `SamplingResult`). `Result.load_result`: reconstructs the `Result` object based on the underlying problem (or sampler for `SamplingResult`) and the data saved to file.
- [#862](https://github.com/pybop-team/PyBOP/pull/862) - Adds pybop.MarginalDistribution, pybop.MultivariateLogNormal.
- [#889](https://github.com/pybop-team/PyBOP/pull/889) - Adds methods for setting the initial state from a voltage to the grouped models.
- [#869](https://github.com/pybop-team/PyBOP/issues/869) - Adds methods for pre-processing current data for linear interpolation.
Expand Down
2 changes: 1 addition & 1 deletion pybop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
#
# Utilities
#
from ._utils import add_spaces, is_numeric
from ._utils import add_spaces, is_numeric, load_data_dict, save_data_dict

#
# Dataset class
Expand Down
150 changes: 149 additions & 1 deletion pybop/_result.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import pickle
import types

import numpy as np

from pybop import plot
from pybop._logging import Logger
from pybop._utils import load_data_dict, save_data_dict
from pybop.problems.problem import Problem


Expand Down Expand Up @@ -35,7 +39,7 @@ def __init__(
self._problem = problem
self._minimising = problem.minimising
self.method_name = method_name
self.n_runs = 0
self.n_runs = 1
self._best_run = None
self._x = [logger.x_model_best]
self._x_model = [logger.x_model]
Expand Down Expand Up @@ -316,3 +320,147 @@ def plot_contour(self, **kwargs):
Valid Plotly layout keys and their values.
"""
return plot.contour(call_object=self, **kwargs)

def save(self, filename) -> None:
"""Save the whole result using pickle"""
with open(filename, "wb") as f:
pickle.dump(self, f, pickle.HIGHEST_PROTOCOL)

@staticmethod
def load(filename: str) -> "Result":
"""Load a saved Result."""
with open(filename, "rb") as f:
result = pickle.load(f)
return result

def data_dict(self) -> dict:
"""return result data as dictionary for saving to file"""

return {
"minimising": self._minimising,
"method_name": self.method_name,
"n_runs": self.n_runs,
"best_run": self._best_run,
"x": self._x,
"x_model": self._x_model,
"x0": self._x0,
"best_cost": self._best_cost,
"cost": self._cost,
"initial_cost": self._initial_cost,
"n_iterations": self._n_iterations,
"iteration_number": self._iteration_number,
"n_evaluations": self._n_evaluations,
"message": self._message,
"scipy_result": [0 if x is None else x for x in self._scipy_result],
"time": self._time,
}

def save_data(
self,
filename=None,
to_format="pickle",
) -> str | None:
"""
Save result data

Based on pybamm.Solution.save_data

Parameters
----------
filename : str, optional
The name of the file to save data to. If None, then a str is returned
for json format or an error is thrown for pickle/matlab.
to_format : str, optional
The format to save to. Options are:

- 'pickle' (default): creates a pickle file with the data dictionary
- 'matlab': creates a .mat file, for loading in matlab
- 'json': creates a json file

Returns
-------
data : str, optional
str if 'json' is chosen and filename is None, otherwise None
"""

data = self.data_dict()
return save_data_dict(data, filename=filename, to_format=to_format)

@staticmethod
def load_data(filename: str, file_format: str = "pickle") -> dict:
"""
Load results data as dictionary from a given file. Restores data saved with
save_data.

Calls load_data_dict defined in _utils.py and provides the keys of
data that is 0-d and 1-d to ensure consistent data dimensions.

Parameters
----------
filename : str
The name of the file containing the data.
file_format : str, optional
The format the data was save to. Options are:
- 'pickle' (default)
- 'matlab'
- 'csv'
- 'json'

Returns
-------
data_dict :
python dictionary containing the data in the file.
"""
data = load_data_dict(
filename,
file_format=file_format,
data_keys_0d=["_minimising", "n_runs", "best_run"],
data_keys_1d=[
"method_name",
"best_cost",
"initial_cost",
"n_iterations",
"n_evaluations",
"message",
"scipy_result",
"time",
],
)

# Create a dummy problem
problem = types.SimpleNamespace()
problem.minimising = data["minimising"]

# Create one logging result for each run
n_runs = data["n_runs"]
list_of_results = []
for i in range(n_runs):
# Create a dummy logger
logger = types.SimpleNamespace()
for logger_key, result_key in [
("x_model_best", "x"),
("x_model", "x_model"),
("x0", "x0"),
("cost_best", "best_cost"),
("cost_convergence", "cost"),
("iteration", "n_iterations"),
("iteration_number", "iteration_number"),
("evaluations", "n_evaluations"),
]:
setattr(logger, logger_key, data[result_key][i])
logger.cost = [data["initial_cost"][i]]

list_of_results.append(
Result(
problem=problem,
logger=logger,
time=data["time"][i],
method_name=data["method_name"],
message=data["message"][i],
scipy_result=data["scipy_result"][i]
if data["scipy_result"][i] != 0
else None,
)
)

return Result.combine(results=list_of_results)
172 changes: 172 additions & 0 deletions pybop/_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import json
import pickle
import re

import numpy as np
import pandas as pd
from scipy.io import loadmat, savemat


class NumpyEncoder(json.JSONEncoder):
"""
Numpy serialiser helper class that converts numpy arrays to a list.
Numpy arrays cannot be directly converted to JSON, so the arrays are
converted to python list objects before encoding.
"""

def default(self, obj):
if isinstance(obj, np.ndarray):
return obj.tolist()
# won't be called since we only need to convert numpy arrays
return json.JSONEncoder.default(self, obj) # pragma: no cover


def add_spaces(string):
Expand All @@ -17,3 +35,157 @@ def is_numeric(x):
Check if a variable is numeric.
"""
return isinstance(x, int | float | np.number)


def save_data_dict(
data_dict: dict,
filename: str | None = None,
to_format: str = "pickle",
) -> str | None:
"""
Save data from given data dictionary

Based on pybamm.Solution.save_data

Parameters
----------
filename : str, optional
The name of the file to save data to. If None, then a str is returned
to_format : str, optional
The format to save to. Options are:

- 'pickle' (default): creates a pickle file with the data dictionary
- 'matlab': creates a .mat file, for loading in matlab
- 'csv': creates a csv file (0D variables only)
- 'json': creates a json file

Returns
-------
data : str, optional
str if 'json' is chosen and filename is None, otherwise None
"""

if to_format == "pickle":
if filename is None:
raise ValueError("pickle format must be written to a file")
with open(filename, "wb") as f:
pickle.dump(data_dict, f, pickle.HIGHEST_PROTOCOL)
elif to_format == "matlab":
if filename is None:
raise ValueError("matlab format must be written to a file")
# Check all the variable names only contain a-z, A-Z or _ or numbers
for name in data_dict.keys():
# Check the string only contains the following ASCII:
# a-z (97-122)
# A-Z (65-90)
# _ (95)
# 0-9 (48-57) but not in the first position
for i, s in enumerate(name):
if not (
97 <= ord(s) <= 122
or 65 <= ord(s) <= 90
or ord(s) == 95
or (i > 0 and 48 <= ord(s) <= 57)
):
raise ValueError(
f"Invalid character '{s}' found in '{name}'. "
"MATLAB variable names must only contain a-z, A-Z, _, "
"or 0-9 (except the first position). "
)
savemat(filename, data_dict)
elif to_format == "csv":
# use copy to avoid modifying input
data_dict_copy = data_dict.copy()
for name, var in data_dict.items():
var = np.asarray(var)
if var.ndim == 0:
data_dict_copy[name] = [var]
elif var.ndim >= 2:
raise ValueError(
f"only 0D variables can be saved to csv, but '{name}' is {var.ndim - 1}D"
)
df = pd.DataFrame(data_dict_copy)
return df.to_csv(filename, index=False)
elif to_format == "json":
if filename is None:
return json.dumps(data_dict, cls=NumpyEncoder)
else:
with open(filename, "w") as outfile:
json.dump(data_dict, outfile, cls=NumpyEncoder)
else:
raise ValueError(f"format '{to_format}' is not supported")


def load_data_dict(
filename: str,
file_format: str = "pickle",
data_keys_0d: list[str] | None = None,
data_keys_1d: list[str] | None = None,
) -> dict:
"""
Load data as dictionary from a given file. Restores data saved with
save_data_dict.

Parameters
----------
filename : str
The name of the file containing the data.
file_format : str, optional
The format the data was save to. Options are:
- 'pickle' (default)
- 'matlab'
- 'csv'
- 'json'
data_keys_0d: list[str], optional
A list of keys for which the data is a scalar/0-dimensional.
This is only needed for file_format='matlab' or file_format = 'csv'.
scipy.io.savemat turns any data into a multi-dimensional array with at least 2 dimensions.
If provided, data dimensions will be consistent with the original data.
data_keys_1d: list[str], optional
A list of keys for which the data is a 1-dimensional list or array.
This is only needed for file_format='matlab'. scipy.io.savemat turns any
data into a multi-dimensional array with at least 2 dimensions.
If provided, data dimensions will be consistent with the original data.

Returns
-------
data_dict :
python dictionary containing the data in the file.
"""

# Load data using appropriate method according to the file_format
if file_format == "pickle":
with open(filename, "rb") as f:
data_dict = pickle.load(f)

elif file_format == "matlab":
data_dict = {}
loadmat(filename, mdict=data_dict)

# fix dimensions for 0-d and 1-d data
data_keys_0d = data_keys_0d or []
data_keys_1d = data_keys_1d or []
for key in data_keys_0d:
if key in data_dict.keys():
data_dict[key] = data_dict[key][0, 0]
for key in data_keys_1d:
if key in data_dict.keys():
data_dict[key] = data_dict[key].flatten()

elif file_format == "json":
with open(filename) as f:
data_dict = json.load(f)

elif file_format == "csv":
data_dict = pd.read_csv(filename).to_dict(orient="list")

# fix dimensions for 0-d data
data_keys_0d = data_keys_0d or []
for key in data_keys_0d:
if key in data_dict.keys():
data_dict[key] = data_dict[key][0]

else:
raise ValueError(f"format '{file_format}' is not supported.")

return data_dict
1 change: 1 addition & 0 deletions pybop/plot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
from .problem import problem
from .nyquist import nyquist
from .voronoi import surface
from .samples import trace, chains, posterior, summary_table
Loading