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.
- spmkit/__init__.py +16 -0
- spmkit/cli/__init__.py +5 -0
- spmkit/cli/app.py +233 -0
- spmkit/core/__init__.py +15 -0
- spmkit/core/analysis/__init__.py +21 -0
- spmkit/core/analysis/kpfm.py +65 -0
- spmkit/core/analysis/leveling.py +61 -0
- spmkit/core/analysis/mechanics.py +272 -0
- spmkit/core/analysis/profiles.py +76 -0
- spmkit/core/analysis/roughness.py +76 -0
- spmkit/core/batch.py +110 -0
- spmkit/core/export/__init__.py +5 -0
- spmkit/core/export/writers.py +90 -0
- spmkit/core/io/__init__.py +8 -0
- spmkit/core/io/gwy.py +97 -0
- spmkit/core/io/nhf.py +74 -0
- spmkit/core/io/nid.py +163 -0
- spmkit/core/io/registry.py +44 -0
- spmkit/core/models/__init__.py +5 -0
- spmkit/core/models/spmdata.py +106 -0
- spmkit/core/report.py +134 -0
- spmkit/core/viz/__init__.py +21 -0
- spmkit/core/viz/colormaps.py +97 -0
- spmkit/core/viz/figure.py +222 -0
- spmkit/gui/__init__.py +5 -0
- spmkit/gui/app.py +25 -0
- spmkit/gui/compare_tab.py +165 -0
- spmkit/gui/figure_tab.py +239 -0
- spmkit/gui/main_window.py +215 -0
- spmkit/gui/nanomech_tab.py +157 -0
- spmkit/gui/theme.py +187 -0
- spmkit/gui/viewer_tab.py +187 -0
- spmkit/gui/welcome.py +62 -0
- spmkit-0.1.0.dist-info/METADATA +206 -0
- spmkit-0.1.0.dist-info/RECORD +38 -0
- spmkit-0.1.0.dist-info/WHEEL +4 -0
- spmkit-0.1.0.dist-info/entry_points.txt +2 -0
- spmkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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))
|