spmkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ """Perfiles de línea sobre imágenes SPM.
2
+
3
+ Extrae el perfil de altura a lo largo de un segmento arbitrario usando
4
+ interpolación bilineal, devolviendo distancia física vs altura.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+
13
+ from spmkit.core.models import SPMChannel
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Profile:
18
+ """Perfil de línea: distancia (m) vs altura (unidad del canal)."""
19
+
20
+ distance: np.ndarray
21
+ height: np.ndarray
22
+ unit: str
23
+ distance_unit: str = "m"
24
+
25
+ def __len__(self) -> int:
26
+ return int(self.distance.size)
27
+
28
+
29
+ def _bilinear(z: np.ndarray, r: np.ndarray, c: np.ndarray) -> np.ndarray:
30
+ """Muestreo bilineal de ``z`` en coordenadas fraccionarias (fila, col)."""
31
+ rows, cols = z.shape
32
+ r0 = np.clip(np.floor(r).astype(int), 0, rows - 1)
33
+ c0 = np.clip(np.floor(c).astype(int), 0, cols - 1)
34
+ r1 = np.clip(r0 + 1, 0, rows - 1)
35
+ c1 = np.clip(c0 + 1, 0, cols - 1)
36
+ dr = r - r0
37
+ dc = c - c0
38
+ top = z[r0, c0] * (1 - dc) + z[r0, c1] * dc
39
+ bot = z[r1, c0] * (1 - dc) + z[r1, c1] * dc
40
+ return top * (1 - dr) + bot * dr
41
+
42
+
43
+ def line(
44
+ channel: SPMChannel,
45
+ p0: tuple[float, float],
46
+ p1: tuple[float, float],
47
+ n: int | None = None,
48
+ ) -> Profile:
49
+ """Extrae un perfil entre dos puntos en **coordenadas de píxel** ``(col, row)``.
50
+
51
+ Args:
52
+ channel: Canal de origen.
53
+ p0: Punto inicial ``(col, row)`` en píxeles.
54
+ p1: Punto final ``(col, row)`` en píxeles.
55
+ n: Número de muestras. Por defecto, la longitud del segmento en píxeles.
56
+
57
+ Returns:
58
+ Un :class:`Profile` con distancia física acumulada y altura.
59
+ """
60
+ z = np.asarray(channel.data, dtype=np.float64)
61
+ (x0, y0), (x1, y1) = p0, p1
62
+ seg_px = float(np.hypot(x1 - x0, y1 - y0))
63
+ if n is None:
64
+ n = max(2, int(round(seg_px)) + 1)
65
+
66
+ cols = np.linspace(x0, x1, n)
67
+ rows = np.linspace(y0, y1, n)
68
+ height = _bilinear(z, rows, cols)
69
+
70
+ # Distancia física: escala media de píxel ponderada por la dirección.
71
+ dx = (x1 - x0) * channel.pixel_size_x
72
+ dy = (y1 - y0) * channel.pixel_size_y
73
+ total = float(np.hypot(dx, dy))
74
+ distance = np.linspace(0.0, total, n)
75
+
76
+ return Profile(distance=distance, height=height, unit=channel.unit)
@@ -0,0 +1,76 @@
1
+ """Parámetros de rugosidad superficial (ISO 25178 areal y ISO 4287 de perfil).
2
+
3
+ Las funciones esperan datos **ya nivelados** (ver :mod:`spmkit.core.analysis.leveling`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict, dataclass
9
+
10
+ import numpy as np
11
+
12
+ from spmkit.core.models import SPMChannel
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class RoughnessResult:
17
+ """Parámetros de rugosidad areal (ISO 25178).
18
+
19
+ Attributes:
20
+ Sa: Altura media aritmética (media de |z|).
21
+ Sq: Altura media cuadrática (RMS).
22
+ Sz: Altura máxima (pico-valle): ``Sp + |Sv|``.
23
+ Sp: Altura máxima de pico.
24
+ Sv: Profundidad máxima de valle.
25
+ Ssk: Asimetría (skewness) de la distribución de alturas.
26
+ Sku: Curtosis (kurtosis) de la distribución de alturas.
27
+ unit: Unidad de las magnitudes de altura.
28
+ n_points: Número de puntos usados.
29
+ """
30
+
31
+ Sa: float
32
+ Sq: float
33
+ Sz: float
34
+ Sp: float
35
+ Sv: float
36
+ Ssk: float
37
+ Sku: float
38
+ unit: str
39
+ n_points: int
40
+
41
+ def to_dict(self) -> dict:
42
+ return asdict(self)
43
+
44
+
45
+ def statistics(channel: SPMChannel) -> RoughnessResult:
46
+ """Calcula los parámetros de rugosidad areal de un canal nivelado."""
47
+ z = np.asarray(channel.data, dtype=np.float64)
48
+ flat = z.ravel()
49
+ flat = flat[np.isfinite(flat)]
50
+ mean = flat.mean()
51
+ dev = flat - mean
52
+
53
+ sq = float(np.sqrt(np.mean(dev**2)))
54
+ sa = float(np.mean(np.abs(dev)))
55
+ sp = float(dev.max())
56
+ sv = float(dev.min())
57
+ sz = float(sp - sv)
58
+ # Momentos normalizados (con guardia para superficie plana)
59
+ if sq > 0:
60
+ ssk = float(np.mean(dev**3) / sq**3)
61
+ sku = float(np.mean(dev**4) / sq**4)
62
+ else:
63
+ ssk = 0.0
64
+ sku = 0.0
65
+
66
+ return RoughnessResult(
67
+ Sa=sa,
68
+ Sq=sq,
69
+ Sz=sz,
70
+ Sp=sp,
71
+ Sv=sv,
72
+ Ssk=ssk,
73
+ Sku=sku,
74
+ unit=channel.unit,
75
+ n_points=int(flat.size),
76
+ )
spmkit/core/batch.py ADDED
@@ -0,0 +1,110 @@
1
+ """Procesamiento por lotes de múltiples archivos SPM.
2
+
3
+ Recorre una carpeta (o lista de archivos), aplica el pipeline de análisis a
4
+ cada uno y devuelve una tabla resumen, ideal para procesar campañas completas
5
+ de medidas del lab.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv as _csv
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from spmkit.core.analysis import kpfm, leveling, roughness
16
+ from spmkit.core.io import load, supported_extensions
17
+
18
+
19
+ @dataclass
20
+ class BatchRow:
21
+ """Resumen de un archivo procesado."""
22
+
23
+ file: str
24
+ channel: str
25
+ ok: bool
26
+ Sa: float | None = None
27
+ Sq: float | None = None
28
+ Sz: float | None = None
29
+ cpd_mean: float | None = None
30
+ error: str = ""
31
+
32
+ def to_dict(self) -> dict:
33
+ return {
34
+ "file": self.file,
35
+ "channel": self.channel,
36
+ "ok": self.ok,
37
+ "Sa": self.Sa,
38
+ "Sq": self.Sq,
39
+ "Sz": self.Sz,
40
+ "cpd_mean": self.cpd_mean,
41
+ "error": self.error,
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class BatchResult:
47
+ """Resultado de un lote: filas + conteo de éxitos/errores."""
48
+
49
+ rows: list[BatchRow] = field(default_factory=list)
50
+
51
+ @property
52
+ def n_ok(self) -> int:
53
+ return sum(r.ok for r in self.rows)
54
+
55
+ @property
56
+ def n_failed(self) -> int:
57
+ return sum(not r.ok for r in self.rows)
58
+
59
+ def to_csv(self, path: str | Path) -> Path:
60
+ path = Path(path)
61
+ with path.open("w", newline="", encoding="utf-8") as fh:
62
+ writer = _csv.DictWriter(fh, fieldnames=list(BatchRow("", "", True).to_dict()))
63
+ writer.writeheader()
64
+ for row in self.rows:
65
+ writer.writerow(row.to_dict())
66
+ return path
67
+
68
+
69
+ def find_files(folder: str | Path) -> list[Path]:
70
+ """Lista archivos SPM soportados en una carpeta (no recursivo)."""
71
+ folder = Path(folder)
72
+ exts = set(supported_extensions())
73
+ return sorted(p for p in folder.iterdir() if p.suffix.lower() in exts)
74
+
75
+
76
+ def process(
77
+ files: Iterable[str | Path],
78
+ channel: str = "Z-Axis",
79
+ cpd_channel: str = "CPD",
80
+ level: str = "plane",
81
+ ) -> BatchResult:
82
+ """Analiza una colección de archivos y devuelve la tabla resumen."""
83
+ result = BatchResult()
84
+ for f in files:
85
+ f = Path(f)
86
+ try:
87
+ data = load(f)
88
+ ch = data[channel]
89
+ if level == "plane":
90
+ ch = leveling.plane_fit(ch)
91
+ elif level == "poly":
92
+ ch = leveling.polynomial(ch, order=2)
93
+ stats = roughness.statistics(ch)
94
+ cpd_mean = None
95
+ if cpd_channel in data.names:
96
+ cpd_mean = kpfm.statistics(data[cpd_channel]).mean
97
+ result.rows.append(
98
+ BatchRow(
99
+ file=f.name,
100
+ channel=channel,
101
+ ok=True,
102
+ Sa=stats.Sa,
103
+ Sq=stats.Sq,
104
+ Sz=stats.Sz,
105
+ cpd_mean=cpd_mean,
106
+ )
107
+ )
108
+ except Exception as exc: # noqa: BLE001 - registrar fallo y continuar
109
+ result.rows.append(BatchRow(file=f.name, channel=channel, ok=False, error=str(exc)))
110
+ return result
@@ -0,0 +1,5 @@
1
+ """Exportación de datos y resultados a formatos abiertos."""
2
+
3
+ from spmkit.core.export.writers import to_csv, to_hdf5, to_json
4
+
5
+ __all__ = ["to_csv", "to_hdf5", "to_json"]
@@ -0,0 +1,90 @@
1
+ """Exportación de datos y resultados procesados a formatos abiertos.
2
+
3
+ Soporta CSV y JSON (siempre disponibles) y HDF5 (requiere el extra
4
+ ``hdf5``). Las funciones aceptan tanto los dataclasses de resultados
5
+ (``RoughnessResult``, ``CPDResult``, ``Profile``) como objetos
6
+ :class:`SPMData` completos.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv as _csv
12
+ import json as _json
13
+ from dataclasses import asdict, is_dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from spmkit.core.analysis.profiles import Profile
20
+ from spmkit.core.models import SPMData
21
+
22
+
23
+ def _to_serializable(obj: Any) -> Any:
24
+ """Convierte dataclasses / ndarrays a tipos JSON-serializables."""
25
+ if is_dataclass(obj) and not isinstance(obj, type):
26
+ return {k: _to_serializable(v) for k, v in asdict(obj).items()}
27
+ if isinstance(obj, np.ndarray):
28
+ return obj.tolist()
29
+ if isinstance(obj, np.generic):
30
+ return obj.item()
31
+ if isinstance(obj, dict):
32
+ return {k: _to_serializable(v) for k, v in obj.items()}
33
+ if isinstance(obj, (list, tuple)):
34
+ return [_to_serializable(v) for v in obj]
35
+ return obj
36
+
37
+
38
+ def to_json(result: Any, path: str | Path) -> Path:
39
+ """Escribe cualquier resultado (dataclass/dict) a JSON."""
40
+ path = Path(path)
41
+ path.write_text(
42
+ _json.dumps(_to_serializable(result), indent=2, ensure_ascii=False),
43
+ encoding="utf-8",
44
+ )
45
+ return path
46
+
47
+
48
+ def to_csv(result: Any, path: str | Path) -> Path:
49
+ """Escribe un resultado a CSV.
50
+
51
+ * Para :class:`Profile`: dos columnas ``distance,height``.
52
+ * Para dataclasses escalares (rugosidad, CPD): formato ``key,value``.
53
+ """
54
+ path = Path(path)
55
+ with path.open("w", newline="", encoding="utf-8") as fh:
56
+ writer = _csv.writer(fh)
57
+ if isinstance(result, Profile):
58
+ writer.writerow([f"distance[{result.distance_unit}]", f"height[{result.unit}]"])
59
+ for d, h in zip(result.distance, result.height, strict=True):
60
+ writer.writerow([d, h])
61
+ elif is_dataclass(result) and not isinstance(result, type):
62
+ writer.writerow(["key", "value"])
63
+ for key, value in asdict(result).items():
64
+ writer.writerow([key, value])
65
+ else:
66
+ raise TypeError(f"No sé exportar a CSV: {type(result).__name__}")
67
+ return path
68
+
69
+
70
+ def to_hdf5(data: SPMData, path: str | Path) -> Path:
71
+ """Escribe un :class:`SPMData` completo a HDF5 (un dataset por canal)."""
72
+ try:
73
+ import h5py
74
+ except ImportError as exc: # pragma: no cover - depende del entorno
75
+ raise ImportError(
76
+ "Exportar a HDF5 requiere h5py. Instala con: pip install 'spmkit[hdf5]'"
77
+ ) from exc
78
+
79
+ path = Path(path)
80
+ with h5py.File(path, "w") as f:
81
+ f.attrs["source_path"] = data.source_path
82
+ f.attrs["format"] = str(data.metadata.get("format", ""))
83
+ for i, ch in enumerate(data.channels):
84
+ dset = f.create_dataset(f"{ch.group}/{ch.name}_{i}", data=ch.data)
85
+ dset.attrs["name"] = ch.name
86
+ dset.attrs["unit"] = ch.unit
87
+ dset.attrs["x_range"] = ch.x_range
88
+ dset.attrs["y_range"] = ch.y_range
89
+ dset.attrs["direction"] = ch.direction
90
+ return path
@@ -0,0 +1,8 @@
1
+ """Lectura de formatos SPM."""
2
+
3
+ from spmkit.core.io.gwy import load_gwy, save_gwy
4
+ from spmkit.core.io.nhf import load_nhf
5
+ from spmkit.core.io.nid import load_nid
6
+ from spmkit.core.io.registry import load, supported_extensions
7
+
8
+ __all__ = ["load", "load_nid", "load_nhf", "load_gwy", "save_gwy", "supported_extensions"]
spmkit/core/io/gwy.py ADDED
@@ -0,0 +1,97 @@
1
+ """Interop con Gwyddion: lectura y escritura del formato ``.gwy``.
2
+
3
+ Usa la librería pura-Python ``gwyfile`` (no requiere tener Gwyddion
4
+ instalado), de modo que los archivos viajan sin fricción entre spmkit y el
5
+ flujo de trabajo del lab en Gwyddion.
6
+
7
+ Necesita el extra ``gwy`` (``pip install 'spmkit[gwy]'``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from spmkit.core.models import SPMChannel, SPMData
17
+
18
+ _DIRECTIONS = ("forward", "backward")
19
+
20
+
21
+ def _import_gwyfile(): # type: ignore[no-untyped-def]
22
+ try:
23
+ import gwyfile
24
+ except ImportError as exc: # pragma: no cover - depende del entorno
25
+ raise ImportError(
26
+ "La interop .gwy requiere gwyfile. Instala con: pip install 'spmkit[gwy]'"
27
+ ) from exc
28
+ return gwyfile
29
+
30
+
31
+ def _split_direction(title: str) -> tuple[str, str]:
32
+ """Separa ``'Z-Axis forward'`` → ``('Z-Axis', 'forward')``."""
33
+ for direction in _DIRECTIONS:
34
+ if title.endswith(direction):
35
+ return title[: -len(direction)].strip(), direction
36
+ return title, "forward"
37
+
38
+
39
+ def load_gwy(path: str | Path) -> SPMData:
40
+ """Lee un archivo Gwyddion ``.gwy`` y devuelve un :class:`SPMData`."""
41
+ gwyfile = _import_gwyfile()
42
+ from gwyfile.util import get_datafields
43
+
44
+ path = Path(path)
45
+ container = gwyfile.load(str(path))
46
+ datafields = get_datafields(container)
47
+
48
+ channels: list[SPMChannel] = []
49
+ for title, df in datafields.items():
50
+ name, direction = _split_direction(title)
51
+ unit = getattr(getattr(df, "si_unit_z", None), "unitstr", "") or ""
52
+ channels.append(
53
+ SPMChannel(
54
+ name=name,
55
+ data=np.asarray(df.data, dtype=np.float64),
56
+ unit=unit,
57
+ x_range=float(df.xreal),
58
+ y_range=float(df.yreal),
59
+ direction=direction,
60
+ group=title,
61
+ metadata={"gwy_title": title},
62
+ )
63
+ )
64
+ if not channels:
65
+ raise ValueError(f"No se encontraron canales en el .gwy: {path}")
66
+ return SPMData(channels=tuple(channels), metadata={"format": "gwy"}, source_path=str(path))
67
+
68
+
69
+ def save_gwy(data: SPMData, path: str | Path) -> Path:
70
+ """Escribe un :class:`SPMData` a ``.gwy`` (abrible directamente en Gwyddion)."""
71
+ _import_gwyfile()
72
+ from gwyfile.objects import GwyContainer, GwyDataField, GwySIUnit
73
+
74
+ path = Path(path)
75
+ container = GwyContainer()
76
+ seen: dict[str, int] = {}
77
+ for i, ch in enumerate(data.channels):
78
+ df = GwyDataField(
79
+ np.ascontiguousarray(ch.data, dtype=np.float64),
80
+ xreal=ch.x_range or 1.0,
81
+ yreal=ch.y_range or 1.0,
82
+ si_unit_xy=GwySIUnit(unitstr="m"),
83
+ si_unit_z=GwySIUnit(unitstr=ch.unit or ""),
84
+ )
85
+ container[f"/{i}/data"] = df
86
+ container[f"/{i}/data/title"] = _unique_title(f"{ch.name} {ch.direction}".strip(), seen)
87
+ container.tofile(str(path))
88
+ return path
89
+
90
+
91
+ def _unique_title(title: str, seen: dict[str, int]) -> str:
92
+ """Garantiza títulos únicos (Gwyddion colapsa títulos repetidos)."""
93
+ if title not in seen:
94
+ seen[title] = 1
95
+ return title
96
+ seen[title] += 1
97
+ return f"{title} ({seen[title]})"
spmkit/core/io/nhf.py ADDED
@@ -0,0 +1,74 @@
1
+ """Parser del formato NanoSurf ``.nhf`` (nuevo, basado en HDF5).
2
+
3
+ El formato ``.nhf`` es un contenedor HDF5. Esta implementación recorre el
4
+ árbol HDF5 de forma genérica: cada *dataset* 2D se interpreta como un canal,
5
+ tomando unidades/escala de los atributos cuando están disponibles.
6
+
7
+ .. note::
8
+ Implementación inicial best-effort. Requiere validación contra archivos
9
+ ``.nhf`` reales del lab. Necesita el extra ``hdf5`` (``pip install
10
+ spmkit[hdf5]``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+
20
+ from spmkit.core.models import SPMChannel, SPMData
21
+
22
+
23
+ def _attr(obj: Any, *keys: str, default: Any = None) -> Any:
24
+ """Devuelve el primer atributo presente entre ``keys``."""
25
+ for key in keys:
26
+ if key in obj.attrs:
27
+ val = obj.attrs[key]
28
+ if isinstance(val, bytes):
29
+ return val.decode("utf-8", "replace")
30
+ return val
31
+ return default
32
+
33
+
34
+ def load_nhf(path: str | Path) -> SPMData:
35
+ """Lee un archivo NanoSurf ``.nhf`` (HDF5) y devuelve un :class:`SPMData`."""
36
+ try:
37
+ import h5py
38
+ except ImportError as exc: # pragma: no cover - depende del entorno
39
+ raise ImportError(
40
+ "Leer .nhf requiere h5py. Instala con: pip install 'spmkit[hdf5]'"
41
+ ) from exc
42
+
43
+ path = Path(path)
44
+ channels: list[SPMChannel] = []
45
+
46
+ with h5py.File(path, "r") as f:
47
+
48
+ def visit(name: str, obj: Any) -> None:
49
+ if not isinstance(obj, h5py.Dataset):
50
+ return
51
+ if obj.ndim != 2:
52
+ return
53
+ data = np.asarray(obj[()], dtype=np.float64)
54
+ channels.append(
55
+ SPMChannel(
56
+ name=_attr(obj, "name", "Name", default=name.split("/")[-1]),
57
+ data=data,
58
+ unit=_attr(obj, "unit", "Unit", "base_unit", default=""),
59
+ x_range=float(_attr(obj, "x_range", "image_size_x", default=0.0)),
60
+ y_range=float(_attr(obj, "y_range", "image_size_y", default=0.0)),
61
+ direction=_attr(obj, "direction", default="forward"),
62
+ group=name.rsplit("/", 1)[0],
63
+ metadata={k: _attr(obj, k) for k in obj.attrs},
64
+ )
65
+ )
66
+
67
+ f.visititems(visit)
68
+ root_meta = {k: _attr(f, k) for k in f.attrs}
69
+
70
+ if not channels:
71
+ raise ValueError(f"No se encontraron canales 2D en el .nhf: {path}")
72
+
73
+ metadata = {"format": "nhf", "info": root_meta}
74
+ return SPMData(channels=tuple(channels), metadata=metadata, source_path=str(path))