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 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
@@ -0,0 +1,5 @@
1
+ """Capa CLI de spmkit (orquestación, sin lógica de análisis)."""
2
+
3
+ from spmkit.cli.app import app
4
+
5
+ __all__ = ["app"]
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()
@@ -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))