Metric – defining what to optimize from the output datafiles#
A nextnano simulation produces physical data – band profiles, potentials,
energy spectra, alloy profiles, etc. An optimizer, on the other hand, works with
plain numbers: it needs one objective value per design, or a few of them for a
multi-objective search.
Metric is the mediator between the simulation tools and the optimizer.
It contains the definition of the optimization objective: one or more numerical values that are extracted from the simulation results.
Connection to IO#
IO runs the simulation and loads the files listed in
target_output_paths as nextnanopy.DataFile objects. Metric receives
exactly those datafiles and turns them into objective values:
DataFiles from IO
|
extraction function
|
objective value(s)
|
optimizer
The datafiles arrive in the same order as the target_output_paths given to
IO. That order is the only link between the two objects.
Therefore, the extraction function relies on precisely that order.
Extraction function#
For each optimization problem, you need to define how the simulation results are converted into an objective value.
You define this conversion in the extraction_function.
It is a normal Python function with a simple contract:
input -> nextnanopy.DataFile objects
output -> numpy.ndarray of objective values
A simple example of the extraction function reads as follows:
import numpy as np
def extract_target(datafiles):
datafile = datafiles[0]
potential = datafile.variables["potential"].value
return np.array([potential.max() - potential.min()])
In the example above the objective is the potential drop across the device.
datafiles[0], datafiles[1], … are the output files defined in
target_output_paths, in the order they were listed there. For example, with
target_output_paths = [
("bias_00000", "potential.dat"),
("Structure", "AlloyConcentration.dat"),
]
the extraction function receives two datafiles: datafiles[0] is
potential.dat and datafiles[1] is AlloyConcentration.dat.
Reading a datafile#
A nextnanopy.DataFile contains data read from nextnano simulation output files,
such as .dat files for 1D simulation output, .fld or .vtr files for 2D and 3D simulation output.
Variables and coordinates#
The physical quantities are stored in variables, while the grid on which they are defined is stored in coords.
For example, for a 1D potential profile the stored variable is potential, and the coordinate is x:
potential = datafile.variables["potential"]
x = datafile.coords["x"]
For a 2D potential profile, the stored variable is potential, and the coordinates are x and y:
potential = datafile.variables["potential"]
x = datafile.coords["x"]
y = datafile.coords["y"]
Each coordinate and variable has a name, unit and value:
potential.name # string, e.g. "potential"
potential.unit # string, e.g. "Volt"
potential.value # numpy.ndarray, e.g. array([-0.1, -0.2, -0.3])
Processing the data#
For an extraction function, value is usually the important part. It is a
regular numpy.ndarray containing the numerical data, so it can be processed
directly with NumPy:
objective = np.array([potential.value.max() - potential.value.min()])
Coordinates can be used together with variable values when the computation of the objective requires the numerical grid. For example, a one-dimensional integral can be computed from the variable values and the corresponding grid values:
x = datafile.coords["x"].value
density = datafile.variables["density"].value
integral = np.trapezoid(density, x)
More details can be found on the page Reading simulation output.
Input and output shape#
Besides the extraction function, Metric is given two additional parameters: input_shape,
how many datafiles the extraction function receives, and output_shape, how
many objective values it returns. Both are checked on every evaluation:
from nextnano_optimizers.metric import Metric
metric = Metric(
input_shape=(2,), # two datafiles per evaluation
output_shape=(1,), # one objective value per evaluation
extraction_function=extract_target,
)
Note
input_length and output_length are accepted as shorthand for the
one-dimensional case: Metric(input_length=1, output_length=1, ...) is the
same as Metric(input_shape=(1,), output_shape=(1,), ...).
Input shape#
input_shape describes how many DataFile objects are passed to the extraction_function and how they are arranged.
In the usual case input_shape=(k,), where k is the
number of output files configured in target_output_paths of IO – one datafile per
target.
io = IO(
input_file_path="device.nnp",
variable_names=["well_width", "barrier_width"],
target_output_paths=[
("bias_00000", "potential.dat"),
("bias_00000", "electric_field.dat"),
("Structure", "AlloyConcentration.dat"),
],
)
def extract_target(datafiles):
potential = datafiles[0] # bias_00000/potential.dat
electric_field = datafiles[1] # bias_00000/electric_field.dat
concentration = datafiles[2] # Structure/AlloyConcentration.dat
...
metric = Metric(
input_shape=(3,),
output_shape=(1,),
extraction_function=extract_target,
)
In the example above, 3 entries in target_output_paths mean that the extraction function receives 3
datafiles, so input_shape=(3,). input_shape has to match what IO actually hands over.
Input shape with sweeps#
If the IO uses sweep to vary additional input variables for each objective evaluation,
the input_shape will have one axis per sweep key, in the order the keys were given: (k, n1, n2, ...), where n1, n2, ... are the numbers of swept values.
For example, with sweep={"temperature": [250, 300, 350], "voltage": [1, 2]}, the extraction function receives k datafiles for every combination of sweep values.
The resulting array has a shape input_shape=(k, 3, 2).
io = IO(
input_file_path="device.nnp",
variable_names=["well_width", "barrier_width"],
target_output_paths=[
("bias_00000", "potential.dat"),
("bias_00000", "electric_field.dat"),
("Structure", "AlloyConcentration.dat"),
],
sweep={"temperature": [250, 300, 350], "voltage": [1, 2]},
)
def extract_target(datafiles):
datafiles.shape # (3, 3, 2)
datafiles[0, 0, 0] # potential.dat at 250 K, 1 V
datafiles[0, 2, 1] # potential.dat at 350 K, 2 V
datafiles[2, 1, 0] # AlloyConcentration.dat at 300 K, 1 V
...
metric = Metric(
input_shape=(3, 3, 2),
output_shape=(1,),
extraction_function=extract_target,
)
Output shape#
output_shape is the shape of the objective array the extraction function returns.
(1,) for a single-objective optimization; (p,) for a multi-objective optimization with p objectives.
The Metric expects the extraction_function to return an array of that shape, and will raise an error if it does not.
def extract_target(datafiles):
potential = datafiles[0].variables["potential"].value
density = datafiles[1].variables["density"].value
potential_drop = potential.max() - potential.min()
max_density = density.max()
return np.array([potential_drop, max_density]) # shape (2,)
metric = Metric(
input_shape=(2,),
output_shape=(2,),
extraction_function=extract_target,
)
In the example above, the extraction_function returns two objective values, so output_shape=(2,).
Testing the Metric setup#
During an optimization, the optimizer automatically runs the nextnano simulations through IO and evaluates the resulting objective values through Metric.
You can perform these steps manually before starting a full optimization run to make sure that the extraction function works correctly with real simulation output:
io.run_simulation([7.0, 3.0])
datafiles = io.load_output_datafiles()
objective = metric.extract(datafiles)
print(objective)
Use metric.extract(datafiles) rather than calling the extraction function
directly: it is the same entry point the optimizer uses, and it checks that the
datafiles match input_shape and that what comes back matches
output_shape.
Any mismatch can be detected here instead of the first
evaluation in the optimization workflow.
Using input variables in the objective#
Sometimes the objective depends not only on the simulation output but also on
the input values that produced it.
For example, the objective may combine a simulated quantity with the layer width used in the simulation.
To achieve this, the Metric needs access to the input values.
The nextnano solvers output provides a way to retrieve them.
The simulators write the input variables and their values to an additional output file.
This file can be added to target_output_paths and read by the extraction
function like any other datafile. For nextnano++, the file is variables_input.txt:
io = IO(
input_file_path="device.nnp",
variable_names=["well_width"],
target_output_paths=[
("bias_00000", "density.dat"),
("variables_input.txt",),
],
)
def extract_target(datafiles):
density = datafiles[0].variables["density"].value
x = datafiles[0].coords["x"].value
well_width = datafiles[1].variables["well_width"].value
integrated_density = np.trapezoid(density, x)
return np.array([integrated_density / well_width])
metric = Metric(
input_shape=(2,),
output_shape=(1,),
extraction_function=extract_target,
)
The example above first computes the integrated density from the simulation output, then divides it by the well width read from the input file to compute average density.
Additional information#
See Metric for the exact argument types, the validation
rules and the attributes of Metric.