Saving and loading models

In this notebook we demonstrate how to save SuperScreen models to disk and load them back into memory for further analysis.

Below we make heavy use of the tempfile.TemporaryDirectory class from the Python standard library to create and automatically clean up directories. Any place you see

with tempfile.TemporaryDirectory() as directory:
    ...

you would normally instead define an explicit path, e.g.

directory = "superscreen_results/solutions"
[1]:
%config InlineBackend.figure_formats = {"retina", "png"}
%matplotlib inline

import os
import inspect
import tempfile
from pathlib import Path

os.environ["OPENBLAS_NUM_THREADS"] = "1"

import matplotlib.pyplot as plt

plt.rcParams["figure.figsize"] = (5, 4)
plt.rcParams["font.size"] = 10

import superscreen as sc
from superscreen.geometry import box, circle

A few helper functions to help us visualize how SuperScreen objects are saved.

[2]:
def tree(
    dir_path: Path,
    prefix: str = "",
    space: str = "    ",
    branch: str = "│   ",
    tee: str = "├── ",
    last: str = "└── ",
):
    """https://stackoverflow.com/a/59109706/11655306

    A recursive generator, given a directory Path object
    will yield a visual tree structure line by line
    with each line prefixed by the same characters.
    """
    contents = sorted(dir_path.iterdir())
    # contents each get pointers that are ├── with a final └── :
    pointers = [tee] * (len(contents) - 1) + [last]
    for pointer, path in zip(pointers, contents):
        yield prefix + pointer + path.name
        if path.is_dir():  # extend the prefix and recurse:
            extension = branch if pointer == tee else space
            # i.e. space because last, └── , above so no more |
            yield from tree(path, prefix=prefix + extension)


def print_tree(dir_path: Path, prefix: str = ""):
    """Prints out visual representation of a directory tree."""
    for line in tree(dir_path, prefix=prefix):
        print(line)


def get_size(dir_path: Path):
    """Gets the total size of a directory in bytes."""
    return sum(file.stat().st_size for file in Path(dir_path).rglob("*"))


def print_doc(func: callable):
    """Prints the signature and docstring of a function or method."""
    print(f"{func.__qualname__}{inspect.signature(func)}:")
    print("\n  " + "\n  ".join(func.__doc__.splitlines()))

Generate a sample Device to use for these examples:

[3]:
def make_device(radius: float = 5.0, length_units: str = "um"):
    """Makes a simple device with two layers."""
    layers = [
        sc.Layer("layer0", Lambda=sc.Constant(2.0), z0=0),
        sc.Layer("layer1", Lambda=sc.Constant(0.5), z0=1),
    ]
    films = [
        sc.Polygon("disk", layer="layer0", points=circle(radius)),
        sc.Polygon("square", layer="layer1", points=box(radius)),
    ]
    abstract_regions = [
        sc.Polygon("bounding_box", layer="layer0", points=circle(1.25 * radius)),
    ]
    return sc.Device(
        "device",
        layers=layers,
        films=films,
        abstract_regions=abstract_regions,
        length_units=length_units,
    )
[4]:
device = make_device()
device.make_mesh(min_points=4000, smooth=50)
[5]:
fig, axes = device.draw(exclude="bounding_box", legend=True)
../_images/notebooks_io_8_0.png

Device I/O

A single Device instance can be saved to disk using the superscreen.Device.to_file() method. A saved Device consists of a standalone directory with JSON files specifying the device’s polygons and layers, and a numpy npz file containing the device’s mesh (if the option save_mesh=True is provided). If a layer’s penetration depth, layer.Lambda or layer.london_lambda, is a superscreen.Parameter (as opposed to a Python int or float), then it is saved in the dill binary format.

[6]:
print_doc(sc.Device.to_file)
Device.to_file(self, directory: str, save_mesh: bool = True, compressed: bool = True) -> None:

  Serializes the Device to disk.

          Args:
              directory: The name of the directory in which to save the Device
                  (must either be empty or not yet exist).
              save_mesh: Whether to save the full mesh to file.
              compressed: Whether to use numpy.savez_compressed rather than numpy.savez
                  when saving the mesh.

A new Device can be created from file using the class method superscreen.Device.from_file().

[7]:
print_doc(sc.Device.from_file)
Device.from_file(directory: str, compute_matrices: bool = False) -> 'Device':

  Creates a new Device from one serialized to disk.

          Args:
              directory: The directory from which to load the device.
              compute_matrices: Whether to compute the field-independent
                  matrices for the device if the mesh already exists.

          Returns:
              The loaded Device instance

[8]:
for save_mesh, compressed in [(False, False), (True, False), (True, True)]:
    with tempfile.TemporaryDirectory() as directory:
        header = f"save_mesh = {save_mesh}, compressed = {compressed}:"
        print(header + "\n" + "-" * len(header))

        # Save the Device to disk:
        device.to_file(directory, save_mesh=save_mesh, compressed=compressed)
        print(f"{device.name}/")

        # Print resulting directory tree and size:
        print_tree(Path(directory))
        directory_size_KB = get_size(directory) / 1024**1
        print(f"directory_size_KB = {directory_size_KB:.1f} KB")

        # Load the Device from disk and compar to the original:
        print(
            f"(sc.Device.from_file(directory) == device) = "
            f"{sc.Device.from_file(directory) == device}\n"
        )
save_mesh = False, compressed = False:
--------------------------------------
device/
├── layer0_constant.dill
├── layer1_constant.dill
├── layers.json
└── polygons.json
directory_size_KB = 34.0 KB
(sc.Device.from_file(directory) == device) = True

save_mesh = True, compressed = False:
-------------------------------------
device/
├── layer0_constant.dill
├── layer1_constant.dill
├── layers.json
├── mesh.npz
└── polygons.json
directory_size_KB = 292.5 KB
(sc.Device.from_file(directory) == device) = True

save_mesh = True, compressed = True:
------------------------------------
device/
├── layer0_constant.dill
├── layer1_constant.dill
├── layers.json
├── mesh.npz
└── polygons.json
directory_size_KB = 141.0 KB
(sc.Device.from_file(directory) == device) = True

Solution I/O

Individual superscreen.Solution objects can be saved to disk using superscreen.Solution.to_file().

[9]:
print_doc(sc.Solution.to_file)
Solution.to_file(self, directory: str, save_mesh: bool = True, compressed: bool = True, to_zip: bool = False) -> None:

  Saves a Solution to disk.

          Args:
              directory: The name of the directory in which to save the solution
                  (must either be empty or not yet exist).
              save_mesh: Whether to save the device mesh.
              compressed: Whether to use numpy.savez_compressed rather than numpy.savez.
              to_zip: Whether to save the Solution to a zip file.

A Solution can also be created/loaded from disk using the class method superscreen.Solution.from_file().

[10]:
print_doc(sc.Solution.from_file)
Solution.from_file(directory: str, compute_matrices: bool = False) -> 'Solution':

  Loads a Solution from file.

          Args:
              directory: The directory from which to load the solution.
              compute_matrices: Whether to compute the field-independent
                  matrices for the device if the mesh already exists.

          Returns:
              The loaded Solution instance

Let’s generate a set of Solutions and see how we can save them.

[11]:
solutions = sc.solve(
    device,
    applied_field=sc.sources.ConstantField(1),
    field_units="mT",
    current_units="mA",
    iterations=3,
)
print(f"len(solutions) = {len(solutions)}")
INFO:superscreen.solve:Calculating layer0 response to applied field.
INFO:superscreen.solve:Calculating layer1 response to applied field.
INFO:superscreen.solve:Caching 1 layer-to-layer kernel(s) (134 MB total) in memory.
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (1/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (1/3).
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (2/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (2/3).
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (3/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (3/3).
len(solutions) = 4
[12]:
for solution in solutions:
    fig, axes = solution.plot_fields(figsize=(6, 1.5), share_color_scale=True)
../_images/notebooks_io_20_0.png
../_images/notebooks_io_20_1.png
../_images/notebooks_io_20_2.png
../_images/notebooks_io_20_3.png
[13]:
for to_zip in [False, True]:
    with tempfile.TemporaryDirectory() as directory:
        header = f"to_zip = {to_zip}:"
        print(header + "\n" + "-" * len(header))
        path = os.path.join(directory, "solution")
        # Save the Solution to disk:
        solutions[-1].to_file(path, to_zip=to_zip)
        # Print resulting directory tree and size:
        if to_zip:
            path += ".zip"
            print("solution.zip")
            directory_size_KB = os.stat(path).st_size / 1024**1
        else:
            print(f"solution/")
            print_tree(Path(path))
            directory_size_KB = get_size(path) / 1024**1
        print(f"directory_size_KB = {directory_size_KB:.1f} KB")
        # Load the Solution from disk and compare to the original:
        print(
            f"(sc.Solution.from_file(path) == solutions[-1]) = "
            f"{(sc.Solution.from_file(path) == solutions[-1])}\n"
        )
to_zip = False:
---------------
solution/
├── applied_field.dill
├── device
│   ├── layer0_constant.dill
│   ├── layer1_constant.dill
│   ├── layers.json
│   ├── mesh.npz
│   └── polygons.json
├── layer0_arrays.npz
├── layer1_arrays.npz
└── metadata.json
directory_size_KB = 354.3 KB
(sc.Solution.from_file(path) == solutions[-1]) = True

to_zip = True:
--------------
solution.zip
directory_size_KB = 319.8 KB
(sc.Solution.from_file(path) == solutions[-1]) = True

We can see that when to_zip is False, the Solution is saved in its own directory, with the Device as a subdirectory. The applied field, a superscreen.Parameter, is saved in the dill binary format, the numeric results (stream function, screening field, and total field for each layer) are saved in the numpy npz format, and extra metadata is saved as JSON.

If to_zip is True, then the whole directory gets compressed into a single zip archive. This can save disk space and make it easy to share results with others.

Saving and loading multiple Solutions

Because superscreen.solve() returns a list of superscreen.Solution objects, it is convenient to be able to save/load all solutions in a single command. Each Solution is saved in its own subdirectory whose name is the index of the corresponding Solution in the original list of Solutions.

[14]:
print_doc(sc.save_solutions)
save_solutions(solutions: List[superscreen.solution.Solution], base_directory: os.PathLike, save_mesh: bool = True, return_paths: bool = False, to_zip: bool = False) -> Optional[List[os.PathLike]]:

  Saves a list of Solutions to disk.

      Args:
          base_directory: The name of the directory in which to save the solutions
              (must either be empty or not yet exist).
          save_mesh: Whether to save the full mesh.
          return_paths: Whether to return a list of resulting paths.
          to_zip: Whether to save Solutions as zip archives.

      Returns:
          If ``return_paths`` is True, returns a list of paths where each solution
          was saved.

The superscreen.load_solutions() function loads a set of Solutions from a directory. For solutions: list[superscreen.Solution], we have:

sc.save_solutions(solutions, path)
loaded_solutions = sc.load_solutions(path)
assert isinstance(loaded_solutions, list)
assert all(isinstance(item, sc.Solution) for item in loaded_solutions)
assert loaded_solutions == solutions
[15]:
for to_zip in [False, True]:
    with tempfile.TemporaryDirectory() as directory:
        header = f"to_zip = {to_zip}:"
        print(header + "\n" + "-" * len(header))
        path = os.path.join(directory, "solutions")
        # Save the solutions to disk:
        sc.save_solutions(solutions, path, to_zip=to_zip)
        # Print resulting directory tree and size:
        print(f"solutions/")
        print_tree(Path(path))
        directory_size_KB = get_size(path) / 1024**1
        print(f"directory_size_KB = {directory_size_KB:.1f} KB")
        # Load the solutions from disk and compare to the original:
        print(
            f"(sc.load_solutions(path) == solutions) = "
            f"{(sc.load_solutions(path) == solutions)}\n"
        )
to_zip = False:
---------------
solutions/
├── 0
│   ├── applied_field.dill
│   ├── device
│   │   ├── layer0_constant.dill
│   │   ├── layer1_constant.dill
│   │   ├── layers.json
│   │   ├── mesh.npz
│   │   └── polygons.json
│   ├── layer0_arrays.npz
│   ├── layer1_arrays.npz
│   └── metadata.json
├── 1
│   ├── applied_field.dill
│   ├── device
│   │   ├── layer0_constant.dill
│   │   ├── layer1_constant.dill
│   │   ├── layers.json
│   │   ├── mesh.npz
│   │   └── polygons.json
│   ├── layer0_arrays.npz
│   ├── layer1_arrays.npz
│   └── metadata.json
├── 2
│   ├── applied_field.dill
│   ├── device
│   │   ├── layer0_constant.dill
│   │   ├── layer1_constant.dill
│   │   ├── layers.json
│   │   ├── mesh.npz
│   │   └── polygons.json
│   ├── layer0_arrays.npz
│   ├── layer1_arrays.npz
│   └── metadata.json
└── 3
    ├── applied_field.dill
    ├── device
    │   ├── layer0_constant.dill
    │   ├── layer1_constant.dill
    │   ├── layers.json
    │   ├── mesh.npz
    │   └── polygons.json
    ├── layer0_arrays.npz
    ├── layer1_arrays.npz
    └── metadata.json
directory_size_KB = 1433.2 KB
(sc.load_solutions(path) == solutions) = True

to_zip = True:
--------------
solutions/
├── 0.zip
├── 1.zip
├── 2.zip
└── 3.zip
directory_size_KB = 1278.8 KB
(sc.load_solutions(path) == solutions) = True

Note that superscreen.load_solutions() will load all Solutions from a given directory into memory at once. In some cases, we may instead want to load and process each Solution one at a time. The superscreen.iload_solutions() function returns an iterator or generator that “lazily” loads a new Solution only when requested. One can request solutions from the generator by iterating over it:

for solution in sc.iload_solutions(path):
    # do something with solution

This lazy behavior can be helpful for large models, where each Solution could potentially be many megabytes. One can also request all Solutions at once by converting the generator to a list:

list_of_solutions = list(sc.iload_solutions(path))

In fact, this is exactly the implementation of superscreen.load_solutions():

[16]:
print(inspect.getsource(sc.load_solutions))
def load_solutions(base_directory: os.PathLike) -> List[Solution]:
    """Loads a sequence of Solutions from disk.

    Args:
        base_directory: The name of the directory from which to load the solutions.

    Returns:
        A list of Solutions
    """
    return list(iload_solutions(base_directory))

[17]:
with tempfile.TemporaryDirectory() as directory:
    path = os.path.join(directory, "solutions")
    # Save the solutions to disk:
    sc.save_solutions(solutions, path)
    print(f"sc.iload_solutions(path) = {sc.iload_solutions(path)}\n")

    # Load solutions one at a time and do something with each one:
    for solution in sc.iload_solutions(path):
        fig, _ = solution.plot_fields(figsize=(6, 1.75), share_color_scale=True)
        fig.suptitle(
            f"solution.time_created = {solution.time_created}", fontsize="small"
        )
        fig.subplots_adjust(top=0.75)
sc.iload_solutions(path) = <generator object iload_solutions at 0x7f0a6c8609e0>

../_images/notebooks_io_29_1.png
../_images/notebooks_io_29_2.png
../_images/notebooks_io_29_3.png
../_images/notebooks_io_29_4.png

Saving Solutions directly from solve()

If you want to automatically save all Solutions produced by a single call to superscreen.solve(), you can pass in a directory keyword argument.

# Save solutions to `directory` and return them:
solutions = sc.solve(..., directory=directory)

# Save solutions to `directory` and return None:
sc.solve(..., directory=directory, return_solutions=False)

Below we verify that the loaded solutions are equal to the original solutions produced above in every way other than their time_created attributes using the Solution.equals() method. solution.equals(other_solution) checks that the two Solutions have the same Device and boundary conditions (applied field, circulating currents, and vortices), and that their results are the same to within numerical precision. solution.equals(other_solution, require_same_timestamp=True) additionally requires that the time_created attributes of the two solutions match. Comparing two Solutions using the == operator, solution == other_solution, is simply shorthand for solution.equals(other_solution, require_same_timestamp=True).

[18]:
with tempfile.TemporaryDirectory() as directory:
    return_value = sc.solve(
        device,
        applied_field=sc.sources.ConstantField(1),
        field_units="mT",
        current_units="mA",
        iterations=3,
        directory=directory,
        return_solutions=False,
    )
    print(f"return_value = {return_value}")
    loaded_solutions = sc.load_solutions(directory)
    for i, (solution, loaded_solution) in enumerate(zip(solutions, loaded_solutions)):
        print(i)
        print(f"  (loaded_solution == solution) = {(loaded_solution == solution)}")
        print(
            f"  loaded_solution.equals(solution, require_same_timestamp=True) = "
            f"{loaded_solution.equals(solution, require_same_timestamp=True)}"
        )
        print(
            f"  loaded_solution.equals(solution) = {loaded_solution.equals(solution)}"
        )
INFO:superscreen.solve:Calculating layer0 response to applied field.
INFO:superscreen.solve:Calculating layer1 response to applied field.
INFO:superscreen.solve:Caching 1 layer-to-layer kernel(s) (134 MB total) in memory.
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (1/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (1/3).
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (2/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (2/3).
INFO:superscreen.solve:Calculating layer0 response to applied field and screening field from other layers (3/3).
INFO:superscreen.solve:Calculating layer1 response to applied field and screening field from other layers (3/3).
return_value = None
0
  (loaded_solution == solution) = False
  loaded_solution.equals(solution, require_same_timestamp=True) = False
  loaded_solution.equals(solution) = False
1
  (loaded_solution == solution) = False
  loaded_solution.equals(solution, require_same_timestamp=True) = False
  loaded_solution.equals(solution) = False
2
  (loaded_solution == solution) = False
  loaded_solution.equals(solution, require_same_timestamp=True) = False
  loaded_solution.equals(solution) = False
3
  (loaded_solution == solution) = False
  loaded_solution.equals(solution, require_same_timestamp=True) = False
  loaded_solution.equals(solution) = True
[19]:
sc.version_table()
[19]:
SoftwareVersion
SuperScreen0.8.0
Numpy1.24.1
SciPy1.10.0
matplotlib3.6.2
ray2.2.0
jax0.4.1
IPython8.8.0
Python3.9.15 (main, Oct 26 2022, 11:17:18) [GCC 9.3.0]
OSposix [linux]
Number of CPUsPhysical: 1, Logical: 2
BLAS InfoOPENBLAS
Wed Jan 11 19:33:15 2023 UTC
[ ]: