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
spmkit/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""spmkit: analizador open-source de datos AFM/KPFM para SPM.
|
|
2
|
+
|
|
3
|
+
API de conveniencia de nivel superior::
|
|
4
|
+
|
|
5
|
+
from spmkit import load
|
|
6
|
+
data = load("scan.nid")
|
|
7
|
+
ch = data["Z-Axis"]
|
|
8
|
+
|
|
9
|
+
Para el análisis usa los submódulos de :mod:`spmkit.core.analysis`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from spmkit.core import SPMChannel, SPMData, load
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
__all__ = ["load", "SPMData", "SPMChannel", "__version__"]
|
spmkit/cli/__init__.py
ADDED
spmkit/cli/app.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Interfaz de línea de comandos de spmkit.
|
|
2
|
+
|
|
3
|
+
Esta capa SOLO orquesta: parsea argumentos, llama al ``core`` y presenta
|
|
4
|
+
resultados. No contiene lógica de análisis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from spmkit import __version__, load
|
|
16
|
+
from spmkit.core.analysis import kpfm, leveling, roughness
|
|
17
|
+
from spmkit.core.export import to_csv, to_json
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
name="spmkit",
|
|
21
|
+
help="Analizador open-source de datos AFM/KPFM (SPM Lab UTFSM).",
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=False,
|
|
24
|
+
)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _version_callback(value: bool) -> None:
|
|
29
|
+
if value:
|
|
30
|
+
console.print(f"spmkit {__version__}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.callback()
|
|
35
|
+
def main(
|
|
36
|
+
_version: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--version",
|
|
39
|
+
"-V",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
help="Muestra la versión y sale.",
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""spmkit: análisis de microscopía de sonda de barrido."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf")) -> None:
|
|
50
|
+
"""Muestra metadatos y canales del archivo."""
|
|
51
|
+
data = load(file)
|
|
52
|
+
table = Table(title=f"{file.name} · formato {data.metadata.get('format', '?')}")
|
|
53
|
+
table.add_column("Canal", style="cyan")
|
|
54
|
+
table.add_column("Dirección")
|
|
55
|
+
table.add_column("Forma")
|
|
56
|
+
table.add_column("Unidad")
|
|
57
|
+
table.add_column("Tamaño X·Y", justify="right")
|
|
58
|
+
for ch in data.channels:
|
|
59
|
+
table.add_row(
|
|
60
|
+
ch.name,
|
|
61
|
+
ch.direction,
|
|
62
|
+
f"{ch.shape[0]}×{ch.shape[1]}",
|
|
63
|
+
ch.unit,
|
|
64
|
+
f"{ch.x_range * 1e6:.2f}×{ch.y_range * 1e6:.2f} µm",
|
|
65
|
+
)
|
|
66
|
+
console.print(table)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command(name="roughness")
|
|
70
|
+
def roughness_cmd(
|
|
71
|
+
file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"),
|
|
72
|
+
channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"),
|
|
73
|
+
level: str = typer.Option("plane", "--level", "-l", help="Nivelación: plane|poly|none"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Calcula parámetros de rugosidad (ISO 25178) de un canal."""
|
|
76
|
+
data = load(file)
|
|
77
|
+
ch = data[channel]
|
|
78
|
+
ch = _apply_level(ch, level)
|
|
79
|
+
result = roughness.statistics(ch)
|
|
80
|
+
table = Table(title=f"Rugosidad · {channel} ({result.unit})")
|
|
81
|
+
table.add_column("Parámetro", style="cyan")
|
|
82
|
+
table.add_column("Valor", justify="right")
|
|
83
|
+
for key, value in result.to_dict().items():
|
|
84
|
+
if isinstance(value, float):
|
|
85
|
+
table.add_row(key, f"{value:.4g}")
|
|
86
|
+
else:
|
|
87
|
+
table.add_row(key, str(value))
|
|
88
|
+
console.print(table)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def analyze(
|
|
93
|
+
file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"),
|
|
94
|
+
output: Path = typer.Option(Path("./results"), "--output", "-o", help="Carpeta de salida"),
|
|
95
|
+
channel: str = typer.Option("Z-Axis", "--channel", "-c"),
|
|
96
|
+
cpd_channel: str = typer.Option("CPD", "--cpd-channel"),
|
|
97
|
+
level: str = typer.Option("plane", "--level", "-l"),
|
|
98
|
+
tip_work_function: float | None = typer.Option(
|
|
99
|
+
None, "--tip-wf", help="Función de trabajo de la punta (eV) para KPFM"
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Pipeline completo: rugosidad (+ KPFM si hay canal) → CSV + JSON."""
|
|
103
|
+
data = load(file)
|
|
104
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
stem = file.stem
|
|
106
|
+
|
|
107
|
+
ch = _apply_level(data[channel], level)
|
|
108
|
+
rough = roughness.statistics(ch)
|
|
109
|
+
to_csv(rough, output / f"{stem}_roughness.csv")
|
|
110
|
+
to_json(rough, output / f"{stem}_roughness.json")
|
|
111
|
+
console.print(f"[green]✓[/] Rugosidad → {output / (stem + '_roughness.csv')}")
|
|
112
|
+
|
|
113
|
+
if cpd_channel in data.names:
|
|
114
|
+
cpd = kpfm.statistics(data[cpd_channel], tip_work_function=tip_work_function)
|
|
115
|
+
to_csv(cpd, output / f"{stem}_kpfm.csv")
|
|
116
|
+
to_json(cpd, output / f"{stem}_kpfm.json")
|
|
117
|
+
console.print(f"[green]✓[/] KPFM → {output / (stem + '_kpfm.csv')}")
|
|
118
|
+
else:
|
|
119
|
+
console.print(f"[yellow]·[/] Sin canal CPD ({cpd_channel!r}); se omite KPFM.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def nanomech(
|
|
124
|
+
file: Path = typer.Argument(..., exists=True, help="Archivo .nid con espectroscopía"),
|
|
125
|
+
channel: str = typer.Option("Deflection", "--channel", "-c", help="Canal de fuerza (N)"),
|
|
126
|
+
curve: int = typer.Option(-1, "--curve", help="Índice de curva (-1 = la del medio)"),
|
|
127
|
+
tip_radius: float = typer.Option(10e-9, "--tip-radius", help="Radio de punta (m)"),
|
|
128
|
+
model: str = typer.Option("sphere", "--model", help="sphere|paraboloid|cone"),
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Ajusta una curva fuerza-distancia (Hertz) y estima el módulo de Young."""
|
|
131
|
+
from spmkit.core.analysis import mechanics
|
|
132
|
+
|
|
133
|
+
data = load(file)
|
|
134
|
+
ch = data[channel]
|
|
135
|
+
curves = mechanics.extract_curves(ch)
|
|
136
|
+
if not curves:
|
|
137
|
+
console.print("[red]No se encontraron curvas en el canal.[/]")
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
idx = len(curves) // 2 if curve < 0 else curve
|
|
140
|
+
result = mechanics.fit_hertz(curves[idx], tip_radius=tip_radius, model=model)
|
|
141
|
+
table = Table(title=f"Nanomecánica · curva {idx}/{len(curves)} · {model}")
|
|
142
|
+
table.add_column("Parámetro", style="cyan")
|
|
143
|
+
table.add_column("Valor", justify="right")
|
|
144
|
+
table.add_row("Módulo de Young", f"{result.young_modulus / 1e6:.4g} MPa")
|
|
145
|
+
table.add_row("Punto de contacto", f"{result.contact_point * 1e9:.2f} nm")
|
|
146
|
+
table.add_row("Adhesión", f"{result.adhesion * 1e9:.3g} nN")
|
|
147
|
+
table.add_row("RMSE", f"{result.rmse:.3e}")
|
|
148
|
+
console.print(table)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def batch(
|
|
153
|
+
folder: Path = typer.Argument(..., exists=True, file_okay=False, help="Carpeta con archivos"),
|
|
154
|
+
channel: str = typer.Option("Z-Axis", "--channel", "-c"),
|
|
155
|
+
output: Path = typer.Option(Path("batch_summary.csv"), "--output", "-o"),
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Procesa todos los archivos SPM de una carpeta → tabla resumen CSV."""
|
|
158
|
+
from spmkit.core import batch as batch_mod
|
|
159
|
+
|
|
160
|
+
files = batch_mod.find_files(folder)
|
|
161
|
+
if not files:
|
|
162
|
+
console.print("[yellow]No hay archivos SPM soportados en la carpeta.[/]")
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
result = batch_mod.process(files, channel=channel)
|
|
165
|
+
result.to_csv(output)
|
|
166
|
+
console.print(f"[green]✓[/] {result.n_ok} ok, {result.n_failed} con error → {output}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def figure(
|
|
171
|
+
file: Path = typer.Argument(..., exists=True, help="Archivo .nid/.nhf/.gwy"),
|
|
172
|
+
channel: str = typer.Option("Z-Axis", "--channel", "-c"),
|
|
173
|
+
output: Path = typer.Option(Path("figure.png"), "--output", "-o", help="png|svg|pdf"),
|
|
174
|
+
colormap: str = typer.Option("batlow", "--colormap"),
|
|
175
|
+
title: str = typer.Option("", "--title"),
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Exporta una figura de publicación (con scale bar y colormap científico)."""
|
|
178
|
+
from spmkit.core.viz import FigureSpec, save_figure
|
|
179
|
+
|
|
180
|
+
data = load(file)
|
|
181
|
+
ch = data[channel]
|
|
182
|
+
spec = FigureSpec(
|
|
183
|
+
title=title or ch.name, colormap=colormap, colorbar_label=f"{ch.name} ({ch.unit})"
|
|
184
|
+
)
|
|
185
|
+
save_figure(ch, spec, output)
|
|
186
|
+
console.print(f"[green]✓[/] Figura → {output}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def convert(
|
|
191
|
+
file: Path = typer.Argument(..., exists=True, help="Archivo de entrada"),
|
|
192
|
+
output: Path = typer.Argument(..., help="Archivo de salida (.gwy o .h5)"),
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Convierte entre formatos (p.ej. .nid → .gwy para abrir en Gwyddion)."""
|
|
195
|
+
data = load(file)
|
|
196
|
+
suffix = output.suffix.lower()
|
|
197
|
+
if suffix == ".gwy":
|
|
198
|
+
from spmkit.core.io import save_gwy
|
|
199
|
+
|
|
200
|
+
save_gwy(data, output)
|
|
201
|
+
elif suffix in (".h5", ".hdf5"):
|
|
202
|
+
from spmkit.core.export import to_hdf5
|
|
203
|
+
|
|
204
|
+
to_hdf5(data, output)
|
|
205
|
+
else:
|
|
206
|
+
raise typer.BadParameter("Formato de salida soportado: .gwy, .h5")
|
|
207
|
+
console.print(f"[green]✓[/] {file.name} → {output}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@app.command()
|
|
211
|
+
def gui() -> None:
|
|
212
|
+
"""Lanza la interfaz gráfica (requiere el extra 'gui')."""
|
|
213
|
+
try:
|
|
214
|
+
from spmkit.gui.app import run
|
|
215
|
+
|
|
216
|
+
run()
|
|
217
|
+
except ImportError:
|
|
218
|
+
console.print("[red]La GUI requiere PyQt6. Instala con:[/] pip install 'spmkit[gui]'")
|
|
219
|
+
raise typer.Exit(code=1) from None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _apply_level(ch, level: str): # type: ignore[no-untyped-def]
|
|
223
|
+
if level == "plane":
|
|
224
|
+
return leveling.plane_fit(ch)
|
|
225
|
+
if level == "poly":
|
|
226
|
+
return leveling.polynomial(ch, order=2)
|
|
227
|
+
if level == "none":
|
|
228
|
+
return ch
|
|
229
|
+
raise typer.BadParameter("level debe ser plane|poly|none")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
app()
|
spmkit/core/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Backend de spmkit: puro Python, sin dependencias de UI.
|
|
2
|
+
|
|
3
|
+
Reúne las cuatro sub-capas del núcleo:
|
|
4
|
+
|
|
5
|
+
* :mod:`spmkit.core.io` — lectura de formatos (``.nid``, ``.nhf``)
|
|
6
|
+
* :mod:`spmkit.core.models` — modelos de datos del dominio
|
|
7
|
+
* :mod:`spmkit.core.analysis` — análisis numérico
|
|
8
|
+
* :mod:`spmkit.core.export` — exportación a formatos abiertos
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from spmkit.core import analysis, batch, export, io, models, viz
|
|
12
|
+
from spmkit.core.io import load
|
|
13
|
+
from spmkit.core.models import SPMChannel, SPMData
|
|
14
|
+
|
|
15
|
+
__all__ = ["io", "models", "analysis", "export", "viz", "batch", "load", "SPMData", "SPMChannel"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Análisis numérico de datos SPM."""
|
|
2
|
+
|
|
3
|
+
from spmkit.core.analysis import kpfm, leveling, mechanics, profiles, roughness
|
|
4
|
+
from spmkit.core.analysis.kpfm import CPDResult
|
|
5
|
+
from spmkit.core.analysis.mechanics import ForceCurve, IndentationResult, MechanicalMap
|
|
6
|
+
from spmkit.core.analysis.profiles import Profile
|
|
7
|
+
from spmkit.core.analysis.roughness import RoughnessResult
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"leveling",
|
|
11
|
+
"roughness",
|
|
12
|
+
"profiles",
|
|
13
|
+
"kpfm",
|
|
14
|
+
"mechanics",
|
|
15
|
+
"RoughnessResult",
|
|
16
|
+
"Profile",
|
|
17
|
+
"CPDResult",
|
|
18
|
+
"ForceCurve",
|
|
19
|
+
"IndentationResult",
|
|
20
|
+
"MechanicalMap",
|
|
21
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Análisis KPFM: potencial de contacto (CPD) y función de trabajo.
|
|
2
|
+
|
|
3
|
+
En KPFM se mide la diferencia de potencial de contacto (CPD, *Contact
|
|
4
|
+
Potential Difference*) entre la punta y la muestra::
|
|
5
|
+
|
|
6
|
+
V_CPD = (phi_tip - phi_sample) / e
|
|
7
|
+
|
|
8
|
+
de donde la función de trabajo de la muestra es::
|
|
9
|
+
|
|
10
|
+
phi_sample = phi_tip - e * V_CPD
|
|
11
|
+
|
|
12
|
+
Si se trabaja en eV y V_CPD en voltios, ``e * V_CPD`` (en eV) es
|
|
13
|
+
numéricamente igual a ``V_CPD`` (en V).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from spmkit.core.models import SPMChannel
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CPDResult:
|
|
27
|
+
"""Estadísticas del canal de potencial de contacto (CPD)."""
|
|
28
|
+
|
|
29
|
+
mean: float
|
|
30
|
+
std: float
|
|
31
|
+
minimum: float
|
|
32
|
+
maximum: float
|
|
33
|
+
contrast: float
|
|
34
|
+
unit: str
|
|
35
|
+
work_function: float | None = None
|
|
36
|
+
work_function_unit: str = "eV"
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def statistics(channel: SPMChannel, tip_work_function: float | None = None) -> CPDResult:
|
|
43
|
+
"""Estadísticas del canal CPD y, si se da ``tip_work_function``, la phi de la muestra.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
channel: Canal de CPD/potencial (unidad típica ``V``).
|
|
47
|
+
tip_work_function: Función de trabajo de la punta en eV. Si se entrega,
|
|
48
|
+
se calcula ``phi_sample = phi_tip - V_CPD_medio``.
|
|
49
|
+
"""
|
|
50
|
+
v = np.asarray(channel.data, dtype=np.float64).ravel()
|
|
51
|
+
v = v[np.isfinite(v)]
|
|
52
|
+
mean = float(v.mean())
|
|
53
|
+
work_function = None
|
|
54
|
+
if tip_work_function is not None:
|
|
55
|
+
work_function = float(tip_work_function - mean)
|
|
56
|
+
|
|
57
|
+
return CPDResult(
|
|
58
|
+
mean=mean,
|
|
59
|
+
std=float(v.std()),
|
|
60
|
+
minimum=float(v.min()),
|
|
61
|
+
maximum=float(v.max()),
|
|
62
|
+
contrast=float(v.max() - v.min()),
|
|
63
|
+
unit=channel.unit,
|
|
64
|
+
work_function=work_function,
|
|
65
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Nivelación / corrección de fondo de imágenes SPM.
|
|
2
|
+
|
|
3
|
+
La topografía cruda suele venir con inclinación (tilt) del piezo o del
|
|
4
|
+
montaje de la muestra. Estas funciones la corrigen antes de calcular
|
|
5
|
+
rugosidad o perfiles.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from spmkit.core.models import SPMChannel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plane_fit(channel: SPMChannel) -> SPMChannel:
|
|
16
|
+
"""Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``.
|
|
17
|
+
|
|
18
|
+
Es la corrección de inclinación más común para topografía AFM.
|
|
19
|
+
"""
|
|
20
|
+
z = channel.data
|
|
21
|
+
rows, cols = z.shape
|
|
22
|
+
yy, xx = np.mgrid[0:rows, 0:cols]
|
|
23
|
+
a_mat = np.column_stack([xx.ravel(), yy.ravel(), np.ones(z.size)])
|
|
24
|
+
coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None)
|
|
25
|
+
plane = (a_mat @ coeffs).reshape(z.shape)
|
|
26
|
+
return channel.with_data(z - plane)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel:
|
|
30
|
+
"""Resta una superficie polinómica 2D de grado ``order``.
|
|
31
|
+
|
|
32
|
+
Útil cuando hay curvatura (bow) además de inclinación.
|
|
33
|
+
"""
|
|
34
|
+
if order < 1:
|
|
35
|
+
raise ValueError("order debe ser >= 1")
|
|
36
|
+
z = channel.data
|
|
37
|
+
rows, cols = z.shape
|
|
38
|
+
yy, xx = np.mgrid[0:rows, 0:cols]
|
|
39
|
+
x = xx.ravel().astype(np.float64)
|
|
40
|
+
y = yy.ravel().astype(np.float64)
|
|
41
|
+
terms = [(x**i) * (y**j) for i in range(order + 1) for j in range(order + 1 - i)]
|
|
42
|
+
a_mat = np.column_stack(terms)
|
|
43
|
+
coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None)
|
|
44
|
+
surface = (a_mat @ coeffs).reshape(z.shape)
|
|
45
|
+
return channel.with_data(z - surface)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def align_rows(channel: SPMChannel, method: str = "median") -> SPMChannel:
|
|
49
|
+
"""Alinea filas restando su estadístico (corrige saltos línea a línea).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
method: ``"median"`` (robusto) o ``"mean"``.
|
|
53
|
+
"""
|
|
54
|
+
z = channel.data
|
|
55
|
+
if method == "median":
|
|
56
|
+
baseline = np.median(z, axis=1, keepdims=True)
|
|
57
|
+
elif method == "mean":
|
|
58
|
+
baseline = np.mean(z, axis=1, keepdims=True)
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError("method debe ser 'median' o 'mean'")
|
|
61
|
+
return channel.with_data(z - baseline)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Análisis de nanomecánica a partir de curvas fuerza-distancia (force-distance).
|
|
2
|
+
|
|
3
|
+
Flujo típico:
|
|
4
|
+
|
|
5
|
+
1. :func:`extract_curves` saca las curvas individuales de un canal de
|
|
6
|
+
espectroscopía (grupos ``Spec`` de NanoSurf: ``Lines`` curvas ×
|
|
7
|
+
``Points`` muestras, con eje Z en ``Dim0`` y fuerza en ``Dim2``).
|
|
8
|
+
2. :func:`baseline_correct` quita la línea base (zona sin contacto).
|
|
9
|
+
3. :func:`find_contact_point` detecta el punto de contacto.
|
|
10
|
+
4. :func:`fit_hertz` ajusta un modelo de contacto y estima el módulo de Young.
|
|
11
|
+
|
|
12
|
+
Convención: ``z`` creciente acerca la punta a la muestra; el contacto ocurre
|
|
13
|
+
a ``z`` alto. En el régimen de contacto, la indentación es
|
|
14
|
+
``delta = (z - z0) - deflexión``; con cantiléver rígido o sin constante de
|
|
15
|
+
resorte se aproxima ``delta ≈ z - z0``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from spmkit.core.models import SPMChannel
|
|
25
|
+
|
|
26
|
+
#: Modelos de contacto soportados y su exponente de indentación.
|
|
27
|
+
_MODELS = {
|
|
28
|
+
"sphere": 1.5, # Hertz esférico / paraboloide (R = radio de punta)
|
|
29
|
+
"paraboloid": 1.5,
|
|
30
|
+
"cone": 2.0, # Sneddon cónico (alpha = semiángulo)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ForceCurve:
|
|
36
|
+
"""Una curva fuerza-distancia individual."""
|
|
37
|
+
|
|
38
|
+
z: np.ndarray
|
|
39
|
+
force: np.ndarray
|
|
40
|
+
index: int = 0
|
|
41
|
+
direction: str = "forward"
|
|
42
|
+
z_unit: str = "m"
|
|
43
|
+
force_unit: str = "N"
|
|
44
|
+
metadata: dict = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return int(self.z.size)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class IndentationResult:
|
|
52
|
+
"""Resultado del ajuste de una curva fuerza-indentación."""
|
|
53
|
+
|
|
54
|
+
young_modulus: float
|
|
55
|
+
contact_point: float
|
|
56
|
+
adhesion: float
|
|
57
|
+
model: str
|
|
58
|
+
rmse: float
|
|
59
|
+
unit_modulus: str = "Pa"
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict:
|
|
62
|
+
return asdict(self)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_curves(channel: SPMChannel) -> list[ForceCurve]:
|
|
66
|
+
"""Extrae las curvas fuerza-distancia de un canal de espectroscopía.
|
|
67
|
+
|
|
68
|
+
El eje Z se reconstruye de los metadatos ``Dim0Min``/``Dim0Range`` del
|
|
69
|
+
canal; la fuerza son los datos del canal (una curva por fila).
|
|
70
|
+
"""
|
|
71
|
+
data = np.atleast_2d(np.asarray(channel.data, dtype=np.float64))
|
|
72
|
+
n_curves, n_points = data.shape
|
|
73
|
+
meta = channel.metadata
|
|
74
|
+
z_min = float(meta.get("Dim0Min", 0.0))
|
|
75
|
+
z_range = float(meta.get("Dim0Range", float(n_points)))
|
|
76
|
+
z = z_min + np.linspace(0.0, z_range, n_points)
|
|
77
|
+
z_unit = meta.get("Dim0Unit", "m")
|
|
78
|
+
return [
|
|
79
|
+
ForceCurve(
|
|
80
|
+
z=z,
|
|
81
|
+
force=data[i],
|
|
82
|
+
index=i,
|
|
83
|
+
direction=channel.direction,
|
|
84
|
+
z_unit=z_unit,
|
|
85
|
+
force_unit=channel.unit or "N",
|
|
86
|
+
metadata={"source_channel": channel.name},
|
|
87
|
+
)
|
|
88
|
+
for i in range(n_curves)
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def baseline_correct(curve: ForceCurve, fraction: float = 0.3) -> ForceCurve:
|
|
93
|
+
"""Resta la línea base ajustada a la zona sin contacto (``z`` bajo)."""
|
|
94
|
+
if not 0 < fraction < 1:
|
|
95
|
+
raise ValueError("fraction debe estar en (0, 1)")
|
|
96
|
+
n = max(2, int(curve.z.size * fraction))
|
|
97
|
+
coeffs = np.polyfit(curve.z[:n], curve.force[:n], 1)
|
|
98
|
+
baseline = np.polyval(coeffs, curve.z)
|
|
99
|
+
return ForceCurve(
|
|
100
|
+
z=curve.z,
|
|
101
|
+
force=curve.force - baseline,
|
|
102
|
+
index=curve.index,
|
|
103
|
+
direction=curve.direction,
|
|
104
|
+
z_unit=curve.z_unit,
|
|
105
|
+
force_unit=curve.force_unit,
|
|
106
|
+
metadata=dict(curve.metadata),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def find_contact_point(curve: ForceCurve, fraction: float = 0.3, k: float = 5.0) -> float:
|
|
111
|
+
"""Devuelve el ``z`` del punto de contacto (primer cruce sobre el ruido).
|
|
112
|
+
|
|
113
|
+
Usa la desviación estándar de la línea base como umbral (``k`` sigmas).
|
|
114
|
+
Asume que ``curve`` ya está corregida de base.
|
|
115
|
+
"""
|
|
116
|
+
n = max(2, int(curve.z.size * fraction))
|
|
117
|
+
threshold = k * float(np.std(curve.force[:n]))
|
|
118
|
+
above = np.flatnonzero(curve.force > threshold)
|
|
119
|
+
if above.size == 0:
|
|
120
|
+
return float(curve.z[-1])
|
|
121
|
+
return float(curve.z[above[0]])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def fit_hertz(
|
|
125
|
+
curve: ForceCurve,
|
|
126
|
+
tip_radius: float,
|
|
127
|
+
poisson: float = 0.3,
|
|
128
|
+
model: str = "sphere",
|
|
129
|
+
contact_point: float | None = None,
|
|
130
|
+
spring_constant: float | None = None,
|
|
131
|
+
half_angle: float = np.deg2rad(20.0),
|
|
132
|
+
) -> IndentationResult:
|
|
133
|
+
"""Ajusta un modelo de contacto a la curva y estima el módulo de Young.
|
|
134
|
+
|
|
135
|
+
* ``sphere``/``paraboloid``: ``F = (4/3) E* sqrt(R) delta^1.5``
|
|
136
|
+
* ``cone`` (Sneddon): ``F = (2/pi) E* tan(alpha) delta^2``
|
|
137
|
+
|
|
138
|
+
con ``E* = E / (1 - nu^2)``.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
tip_radius: Radio de la punta ``R`` (m) para modelos esféricos.
|
|
142
|
+
poisson: Coeficiente de Poisson de la muestra.
|
|
143
|
+
model: ``"sphere"``, ``"paraboloid"`` o ``"cone"``.
|
|
144
|
+
contact_point: ``z0`` (m). Si es ``None`` se detecta automáticamente.
|
|
145
|
+
spring_constant: Constante del cantiléver (N/m) para corregir la
|
|
146
|
+
indentación por la deflexión. Si es ``None``, ``delta ≈ z - z0``.
|
|
147
|
+
half_angle: Semiángulo de la punta cónica (rad), solo para ``cone``.
|
|
148
|
+
"""
|
|
149
|
+
if model not in _MODELS:
|
|
150
|
+
raise ValueError(f"model debe ser uno de {sorted(_MODELS)}")
|
|
151
|
+
|
|
152
|
+
corrected = baseline_correct(curve)
|
|
153
|
+
z0 = find_contact_point(corrected) if contact_point is None else contact_point
|
|
154
|
+
|
|
155
|
+
mask = corrected.z >= z0
|
|
156
|
+
z_c = corrected.z[mask]
|
|
157
|
+
f_c = corrected.force[mask]
|
|
158
|
+
if z_c.size < 3:
|
|
159
|
+
raise ValueError("Muy pocos puntos en contacto para ajustar")
|
|
160
|
+
|
|
161
|
+
delta = z_c - z0
|
|
162
|
+
if spring_constant is not None and spring_constant > 0:
|
|
163
|
+
delta = delta - f_c / spring_constant
|
|
164
|
+
valid = delta > 0
|
|
165
|
+
delta, f_c = delta[valid], f_c[valid]
|
|
166
|
+
if delta.size < 3:
|
|
167
|
+
raise ValueError("Indentación insuficiente tras la corrección")
|
|
168
|
+
|
|
169
|
+
exponent = _MODELS[model]
|
|
170
|
+
basis = delta**exponent
|
|
171
|
+
stiffness = float(np.sum(basis * f_c) / np.sum(basis**2)) # k tal que F = k·delta^n
|
|
172
|
+
|
|
173
|
+
e_star = _e_star_from_stiffness(stiffness, model, tip_radius, half_angle)
|
|
174
|
+
young = e_star * (1.0 - poisson**2)
|
|
175
|
+
|
|
176
|
+
predicted = stiffness * basis
|
|
177
|
+
rmse = float(np.sqrt(np.mean((f_c - predicted) ** 2)))
|
|
178
|
+
|
|
179
|
+
return IndentationResult(
|
|
180
|
+
young_modulus=young,
|
|
181
|
+
contact_point=float(z0),
|
|
182
|
+
adhesion=adhesion(curve),
|
|
183
|
+
model=model,
|
|
184
|
+
rmse=rmse,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def adhesion(curve: ForceCurve) -> float:
|
|
189
|
+
"""Fuerza de adhesión: magnitud del mínimo de fuerza (pull-off)."""
|
|
190
|
+
fmin = float(np.min(curve.force))
|
|
191
|
+
return -fmin if fmin < 0 else 0.0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class MechanicalMap:
|
|
196
|
+
"""Mapas de propiedades mecánicas a partir de un conjunto de curvas.
|
|
197
|
+
|
|
198
|
+
Cada arreglo tiene la forma de la grilla espacial (``rows × cols``); los
|
|
199
|
+
ajustes fallidos quedan como ``NaN``.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
young_modulus: np.ndarray
|
|
203
|
+
adhesion: np.ndarray
|
|
204
|
+
contact_point: np.ndarray
|
|
205
|
+
grid_shape: tuple[int, int]
|
|
206
|
+
n_curves: int
|
|
207
|
+
n_failed: int
|
|
208
|
+
unit_modulus: str = "Pa"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _grid_shape(n: int, grid: tuple[int, int] | None) -> tuple[int, int]:
|
|
212
|
+
if grid is not None:
|
|
213
|
+
if grid[0] * grid[1] != n:
|
|
214
|
+
raise ValueError(f"grid {grid} no coincide con {n} curvas")
|
|
215
|
+
return grid
|
|
216
|
+
root = int(round(n**0.5))
|
|
217
|
+
if root * root == n:
|
|
218
|
+
return (root, root)
|
|
219
|
+
return (1, n) # sin grilla cuadrada: tira 1×N
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def fit_all(
|
|
223
|
+
channel: SPMChannel,
|
|
224
|
+
tip_radius: float,
|
|
225
|
+
poisson: float = 0.3,
|
|
226
|
+
model: str = "sphere",
|
|
227
|
+
spring_constant: float | None = None,
|
|
228
|
+
grid: tuple[int, int] | None = None,
|
|
229
|
+
) -> MechanicalMap:
|
|
230
|
+
"""Ajusta todas las curvas de un canal y arma mapas de módulo y adhesión.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
grid: Forma ``(rows, cols)`` de la grilla espacial. Si es ``None`` se
|
|
234
|
+
infiere una grilla cuadrada cuando es posible; si no, queda ``1×N``.
|
|
235
|
+
"""
|
|
236
|
+
curves = extract_curves(channel)
|
|
237
|
+
n = len(curves)
|
|
238
|
+
shape = _grid_shape(n, grid)
|
|
239
|
+
young = np.full(n, np.nan)
|
|
240
|
+
adh = np.full(n, np.nan)
|
|
241
|
+
contact = np.full(n, np.nan)
|
|
242
|
+
n_failed = 0
|
|
243
|
+
for i, curve in enumerate(curves):
|
|
244
|
+
try:
|
|
245
|
+
r = fit_hertz(
|
|
246
|
+
curve,
|
|
247
|
+
tip_radius=tip_radius,
|
|
248
|
+
poisson=poisson,
|
|
249
|
+
model=model,
|
|
250
|
+
spring_constant=spring_constant,
|
|
251
|
+
)
|
|
252
|
+
young[i], adh[i], contact[i] = r.young_modulus, r.adhesion, r.contact_point
|
|
253
|
+
except (ValueError, ZeroDivisionError):
|
|
254
|
+
n_failed += 1
|
|
255
|
+
return MechanicalMap(
|
|
256
|
+
young_modulus=young.reshape(shape),
|
|
257
|
+
adhesion=adh.reshape(shape),
|
|
258
|
+
contact_point=contact.reshape(shape),
|
|
259
|
+
grid_shape=shape,
|
|
260
|
+
n_curves=n,
|
|
261
|
+
n_failed=n_failed,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _e_star_from_stiffness(
|
|
266
|
+
stiffness: float, model: str, tip_radius: float, half_angle: float
|
|
267
|
+
) -> float:
|
|
268
|
+
"""Despeja el módulo reducido E* de la rigidez ajustada ``F = k·delta^n``."""
|
|
269
|
+
if model in ("sphere", "paraboloid"):
|
|
270
|
+
return stiffness / ((4.0 / 3.0) * np.sqrt(tip_radius))
|
|
271
|
+
# cone (Sneddon)
|
|
272
|
+
return stiffness * np.pi / (2.0 * np.tan(half_angle))
|