import functools
import numpy as np
from .afm_group import AFMGroup
from .meta import META_FIELDS
__all__ = ["AFMQMap", "qmap_feature", "unit_scales"]
[docs]def qmap_feature(name, unit, cache=False):
"""Decorator for labeling AFMQMap features
The name and unit are stored as properties of the wrapped function.
In addition, the return value of the function can be cached (see
`cache` argument).
Parameters
----------
name: str
Name of the feature
unit: str
Unit of the returned feature
cache: bool or callable
If boolean, determines whether the feature data should be
cached or not. If callable, the callable gets an instance
of AFMData as an argument and should return an identifier
(str) for the current value. If that identifier is the
same as in the cache, then the cached value is used.
"""
def attribute_setter(func):
"""Decorator that sets the necessary attributes
The outer decorator is used to obtain the attributes.
This inner decorator returns the actual function that
wraps the feature function.
"""
func.name = name
func.unit = unit
if isinstance(cache, bool):
if cache:
# cache everything once
func.cache_mode = "static"
func.cache_values = {}
else:
# disable caching
func.cache_mode = "disabled"
else:
assert isinstance(cache, callable)
func.cache_mode = "variable"
func.cache_values = {}
func.cache_ids = {}
func.cache_getid = cache
@functools.wraps(func)
def cached_func(afmdata):
afmdataid = hex(id(afmdata))
if func.cache_mode == "disabled":
# no caching
return func(afmdata)
elif func.cache_mode == "static":
# caching is done only once
if afmdataid not in func.cache_values:
func.cache_values[afmdataid] = func(afmdata)
return func.cache_values[afmdataid]
else:
# we have to check whether we have to recompute
cid = func.cache_getid(afmdata)
assert cid, "Must not be empty string"
if func.cache_ids.get(afmdataid, "") != cid:
# recompute the value
func.cache_values[afmdataid] = func(afmdata)
func.cache_ids[afmdataid] = cid
return func.cache_values[afmdataid]
return cached_func
return attribute_setter
[docs]class AFMQMap:
"""Management of quantitative AFM data on a grid"""
def __init__(self, path_or_group, meta_override=None, callback=None,
modality=None, data_classes_by_modality=None):
"""
Parameters
----------
path_or_group: str or pathlib.Path or afmformats.afm_group.AFMGroup
The path to the data file or an instance of `AFMGroup`
meta_override: dict
Dictionary with metadata that is used when loading the data
in `path`.
callback: callable or None
A method that accepts a float between 0 and 1
to externally track the process of loading the data.
data_classes_by_modality: dict
Override the default AFMData class to use for managing the data
(see :data:`default_data_classes_by_modality`): This is e.g.
used by :ref:`nanite:index` to pass `Indentation` (which is a
subclass of the default `AFMForceDistance`) for handling
"force-indentation" data.
"""
if isinstance(path_or_group, AFMGroup):
group = path_or_group
if meta_override is not None:
raise ValueError(
"Specifying `meta_override` for an AFMGroup instance "
"that is already populated is meaningless.")
else:
group = AFMGroup(path=path_or_group,
meta_override=meta_override,
callback=callback,
modality=modality,
data_classes_by_modality=data_classes_by_modality)
#: AFM data (instance of :class:`afmformats.afm_group.AFMGroup`)
self.group = group
# sanity check (make sure that all necessary metadata are available)
missing_keys = []
for key in META_FIELDS["qmap"]:
if key not in self.group[0].metadata:
missing_keys.append(key)
if missing_keys:
raise ValueError(f"QMap metadata missing for '{self.group.path}': "
+ ", ".join(missing_keys))
# Register feature functions
#: Available features
self.features = []
self._feature_funcs = {}
self._feature_units = {}
fnames = [f for f in dir(self) if f.startswith("feat_")]
for fn in fnames:
func = getattr(self, fn)
assert func.name not in self._feature_funcs
assert func.name not in self._feature_units
self._feature_funcs[func.name] = func
self._feature_units[func.name] = func.unit
self.features.append(func.name)
def _map_grid(self, coords, map_data):
"""Create a 2D map from 1D coordinates and data
The .jpk-force-map file format stores the map data in a
seemingly arbitrary way. This method converts a set of
coordinates and map data values to a 2D map.
Parameters
----------
coords: list-like (length N) with tuple of ints
The x- and y-coordinates [px].
map_data: list-like (length N)
The data to be mapped.
Returns
-------
x, y: 1d ndarrays
The x- and y-values that label the axes of the map
map2d: 2d ndarray
The ordered map data.
"""
shape = self.shape
extent = self.extent
coords = np.array(coords)
map_data = np.array(map_data)
xn, yn = int(shape[0]), int(shape[1])
# Axes ticks
x, dx = np.linspace(extent[0], extent[1], xn,
endpoint=False, retstep=True)
y, dy = np.linspace(extent[2], extent[3], yn,
endpoint=False, retstep=True)
x += dx/2
y += dy/2
# Output map
map2d = np.zeros((yn, xn), dtype=float)*np.nan
for ii in range(map_data.shape[0]):
# Determine the coordinate in the output array
xi, yi = coords[ii]
# Write to the output array
map2d[yi, xi] = map_data[ii]
return x, y, map2d
@property
@functools.lru_cache(maxsize=32)
def extent(self):
"""extent (x1, x2, y1, y2) [µm]"""
afmdata0 = self.group[0]
# get extent of the map
sx = afmdata0.metadata["grid size x"] * 1e6
sy = afmdata0.metadata["grid size y"] * 1e6
cx = afmdata0.metadata["grid center x"] * 1e6
cy = afmdata0.metadata["grid center y"] * 1e6
extent = (cx - sx/2, cx + sx/2,
cy - sy/2, cy + sy/2,
)
return extent
@property
@functools.lru_cache(maxsize=32)
def shape(self):
"""shape of the map [px]"""
afmdata0 = self.group[0]
# get shape of the map
shape = (afmdata0.metadata["grid shape x"],
afmdata0.metadata["grid shape y"]
)
return shape
[docs] @staticmethod
@qmap_feature(name="data: height base point",
unit="µm",
cache=True)
def feat_core_data_height_base_point_um(afmdata):
"""Compute the lowest height (measured)"""
height = np.min(afmdata["height (measured)"])
value = height / unit_scales["µ"]
return value
[docs] @staticmethod
@qmap_feature(name="data: piezo range",
unit="µm",
cache=True)
def feat_core_data_piezo_range_um(afmdata):
"""Compute peak-to-peak piezo range"""
return afmdata.metadata["z range"] / unit_scales["µ"]
[docs] @staticmethod
@qmap_feature(name="data: scan order",
unit="",
cache=True)
def feat_core_data_scan_order(afmdata):
"""Return the enumeration of the dataset"""
return afmdata.enum
[docs] @functools.lru_cache(maxsize=32)
def get_coords(self, which="px"):
"""Get the qmap coordinates for each curve in `AFMQMap.group`
Parameters
----------
which: str
"px" for pixels or "um" for microns.
"""
if which not in ["px", "um"]:
raise ValueError("`which` must be 'px' or 'um'!")
if which == "px":
kx = "grid index x"
ky = "grid index y"
mult = 1
else:
kx = "position x"
ky = "position y"
mult = 1e6
coords = []
for afmdata in self.group:
# We assume that kx and ky are given. This has to be
# ensured by the file format reader for qmaps.
cc = [afmdata.metadata[kx] * mult, afmdata.metadata[ky] * mult]
coords.append(cc)
return np.array(coords)
[docs] def get_qmap(self, feature, qmap_only=False):
"""Return the quantitative map for a feature
Parameters
----------
feature: str
Feature to compute map for (see :data:`QMap.features`)
qmap_only:
Only return the quantitative map data,
not the coordinates
Returns
-------
x, y: 1d ndarray
Only returned if `qmap_only` is False; Pixel grid
coordinates along x and y
qmap: 2d ndarray
Quantitative map
"""
coords = self.get_coords(which="px")
map_data = []
ffunc = self._feature_funcs[feature]
for afmdata in self.group:
val = ffunc(afmdata)
map_data.append(val)
x, y, qmap = self._map_grid(coords=coords, map_data=map_data)
if qmap_only:
return qmap
else:
return x, y, qmap
#: Scale conversion helper
unit_scales = {
"": 1,
"k": 1e3,
"m": 1e-3,
"µ": 1e-6,
"n": 1e-9,
"p": 1e-12
}