diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 895a34b..e1992ff 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,7 +12,8 @@ (cono) → módulo de Young, adhesión. ### Formatos e interop -- Lectura `.nid` (validada con archivos del lab), `.nhf` (HDF5), `.gwy`. +- Lectura `.nid` (validada externamente), `.nhf` experimental (contrato HDF5 + sintético) y `.gwy` (round-trip). - Escritura `.gwy` (round-trip con Gwyddion) + "Abrir en Gwyddion". - Exportación CSV / JSON / HDF5 / PNG / SVG / PDF. @@ -45,7 +46,7 @@ - [ ] FFT / análisis de PSD de rugosidad. ### Formatos -- [ ] Validar `.nhf` con archivos reales (o delegar en NSFopen). +- [ ] Validar `.nhf` con un oráculo externo y evaluar una integración futura con NSFopen. - [ ] Soporte de más formatos (Bruker, Asylum) vía AFMReader. ### Infra @@ -56,7 +57,7 @@ | Proyecto | Uso potencial | |----------|---------------| -| [NSFopen](https://pypi.org/project/NSFopen/) | Lector `.nid`/`.nhf` validado (fallback) | +| [NSFopen](https://pypi.org/project/NSFopen/) | Integración futura evaluable para `.nid`/`.nhf` | | [gwyfile](https://pypi.org/project/gwyfile/) | Interop `.gwy` (adoptado) | | [cmcrameri](https://github.com/callumrollo/cmcrameri) | Colormaps perceptuales (adoptado) | | [matplotlib-scalebar](https://pypi.org/project/matplotlib-scalebar/) | Barra de escala (adoptado) | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index a3018eb..3d60e52 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -5,6 +5,18 @@ del instrumento de forma correcta, para dar confianza científica a los resultados. Las pruebas viven en `tests/validation/` y se ejecutan con `pytest tests/validation` (se omiten si no están los archivos del lab). +## Alcance por formato + +| Formato | Evidencia disponible | Estado | +|---------|----------------------|--------| +| `.nid` | Comparación externa contra exportaciones de Gwyddion | Validado externamente | +| `.gwy` | Escritura y relectura con igualdad de datos | Round-trip reproducible | +| `.nhf` | Archivos HDF5 sintéticos que ejercitan el contrato público | Experimental | + +El lector `.nhf` conserva datos y atributos del esquema genérico que recibe, +ignora datasets que no son 2D y reporta archivos ilegibles. Estas pruebas no +constituyen validación contra un instrumento ni contra un oráculo externo. + ## 1. Validación contra ground truth (Gwyddion) Se comparó la lectura del `.nid` (crudo del instrumento) contra el `.gwy` diff --git a/docs/api.md b/docs/api.md index 4be13f3..1f9ad9c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,8 +6,12 @@ spmkit expone una API pública limpia en `spmkit.core`. La CLI y la GUI solo orq ```bash pip install spmkit +pip install "spmkit[gwy,hdf5]" ``` +La instalación base cubre `.nid`. Los ejemplos `.gwy` requieren el extra `gwy`; el extra +`hdf5` habilita el lector experimental `.nhf` y la exportación HDF5. + --- ## Carga de datos — `spmkit.load` @@ -18,7 +22,7 @@ Punto de entrada principal. Detecta el formato automáticamente. from spmkit import load data = load("scan.nid") # NanoSurf clásico -data = load("scan.nhf") # NanoSurf HDF5 +data = load("scan.nhf") # NanoSurf HDF5 (lector experimental) data = load("scan.gwy") # Gwyddion ``` @@ -30,17 +34,25 @@ El objeto devuelto por `load()`. # Ver canales disponibles print(data.names) # ['Z-Axis', 'CPD', 'Phase', ...] -# Acceder a un canal (devuelve SPMChannel) -ch = data["Z-Axis"] +# Selección estricta: debe quedar exactamente una coincidencia +ch = data.select("Z-Axis", direction="forward") +ch = data.select("Z-Axis", direction="forward", group="Topography forward") -# Acceder con dirección explícita -ch = data["Z-Axis", "forward"] -ch = data["Z-Axis", "backward"] +# Compatibilidad: acceso no estricto por nombre +ch = data.get("Z-Axis") +ch = data["Z-Axis"] # Metadatos del barrido print(data.metadata) # dict con parámetros del instrumento ``` +`select(name, direction=..., group=...)` lanza `KeyError` si no encuentra coincidencias y +`ValueError` si encuentra más de una. Es la opción recomendada cuando hay nombres duplicados. + +`get(name, direction="forward")` conserva el acceso histórico: busca esa dirección y, si no +existe, devuelve la primera coincidencia por nombre. `data[name]` equivale a `get(name)`; +ninguna de estas dos formas detecta ambigüedad. + ### `SPMChannel` Representa un canal 2D en unidades físicas. @@ -55,6 +67,8 @@ ch.y_range # float, rango vertical en metros ch.shape # tuple (rows, cols) ch.name # str, nombre del canal ch.direction # "forward" | "backward" +ch.group # str, grupo de origen +ch.metadata # dict, metadatos crudos del canal ``` --- @@ -80,21 +94,22 @@ Todas las funciones devuelven un nuevo `SPMChannel` (inmutable). ## Rugosidad — `spmkit.core.analysis.roughness` -Parámetros ISO 25178. +Estadísticas de rugosidad areal. ```python from spmkit.core.analysis import roughness result = roughness.statistics(flat) -result.sa # rugosidad media aritmética -result.sq # rugosidad RMS -result.sz # altura máxima (Sp + Sv) -result.sp # altura máxima de picos -result.sv # profundidad máxima de valles -result.ssk # asimetría (skewness) -result.sku # curtosis (kurtosis) +result.Sa # rugosidad media aritmética +result.Sq # rugosidad RMS +result.Sz # altura máxima (Sp + |Sv|) +result.Sp # altura máxima de picos +result.Sv # profundidad máxima de valles (valor negativo) +result.Ssk # asimetría (skewness) +result.Sku # curtosis (kurtosis) result.unit # unidad del canal ("m", "nm", …) +result.n_points # puntos finitos usados # Convertir a dict d = result.to_dict() @@ -110,18 +125,50 @@ Potencial de contacto y función de trabajo. from spmkit.core.analysis import kpfm # Estadísticas básicas del canal CPD -result = kpfm.statistics(data["CPD"]) +cpd_channel = data.select("CPD", direction="forward") +result = kpfm.statistics(cpd_channel) -result.mean_cpd # CPD medio (V) -result.std_cpd # desviación estándar (V) -result.min_cpd # mínimo (V) -result.max_cpd # máximo (V) +result.mean # CPD medio +result.std # desviación estándar +result.minimum # mínimo +result.maximum # máximo +result.contrast # máximo - mínimo +result.unit # unidad del canal (debe ser V) +result.work_function # None: no se proporcionó la función de trabajo de la punta # Con función de trabajo de la punta (eV) -result = kpfm.statistics(data["CPD"], tip_work_function=4.8) +result = kpfm.statistics(cpd_channel, tip_work_function=4.7) result.work_function # función de trabajo de la muestra (eV) +result.work_function_unit # "eV" +``` + +La relación implementada es `phi_sample = phi_tip - mean(CPD)`. Sin +`tip_work_function`, el resultado conserva `work_function=None`. + +--- + +## Perfiles — `spmkit.core.analysis.profiles` + +```python +from spmkit.core.analysis import profiles + +profile = profiles.line( + ch, + (0.5, 0.5), # (columna, fila) inicial en píxeles + (5.5, 3.5), # (columna, fila) final en píxeles + n=3, +) + +profile.distance # ndarray, distancia física +profile.height # ndarray, valores interpolados del canal +profile.distance_unit # "m" +profile.unit # unidad de altura del canal +len(profile) # número de muestras ``` +Ambos extremos deben estar dentro de la imagen. `n=None` elige el número de muestras a +partir de la longitud del segmento en píxeles. + --- ## Nanomecánica — `spmkit.core.analysis.mechanics` @@ -239,7 +286,7 @@ from spmkit.core.viz import FigureSpec, save_figure spec = FigureSpec( title="Topografía AFM", - colormap="batlow", # colormaps Crameri + colormap="gold", # valor por defecto colorbar_label="Z-Axis (nm)", ) @@ -249,7 +296,7 @@ save_figure(flat, spec, "topografia.svg") save_figure(flat, spec, "topografia.pdf") ``` -**Colormaps disponibles** (Crameri perceptualmente uniformes): +Además de `gold`, hay colormaps de matplotlib y Crameri cuando están instalados, por ejemplo: `batlow`, `tokyo`, `oslo`, `vik`, `davos`, `hawaii`, `lapaz`, `roma`, `turku`, `acton` @@ -258,12 +305,17 @@ save_figure(flat, spec, "topografia.pdf") ## Exportación — `spmkit.core.export` ```python +from spmkit.core.analysis import roughness from spmkit.core.export import to_csv, to_json, to_hdf5 # Exportar resultados de rugosidad +roughness_result = roughness.statistics(flat) to_csv(roughness_result, "roughness.csv") to_json(roughness_result, "roughness.json") +# Exportar un perfil: distance[m],height[unidad] +to_csv(profile, "profile.csv") + # Exportar datos completos a HDF5 to_hdf5(data, "scan.h5") ``` diff --git a/docs/cli.md b/docs/cli.md index 9fa5c43..c87cb2c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -2,6 +2,8 @@ spmkit expone una CLI construida con [Typer](https://typer.tiangolo.com/) y [Rich](https://github.com/Textualize/rich). +Los comandos de imagen aceptan `.nid`, `.gwy` y `.nhf`; el lector `.nhf` es experimental. + ```bash spmkit --help ``` @@ -29,7 +31,7 @@ spmkit info FILE | Argumento | Descripción | |-----------|-------------| -| `FILE` | Ruta al archivo `.nid` o `.nhf` | +| `FILE` | Ruta al archivo `.nid`, `.gwy` o `.nhf` experimental | **Ejemplo:** @@ -41,21 +43,20 @@ Salida (tabla Rich): ``` scan.nid · formato nid -┌──────────────────┬───────────┬───────────┬────────┬────────────────┐ -│ Canal │ Dirección │ Forma │ Unidad │ Tamaño X·Y │ -├──────────────────┼───────────┼───────────┼────────┼────────────────┤ -│ Z-Axis │ forward │ 256×256 │ m │ 5.00×5.00 µm │ -│ Z-Axis │ backward │ 256×256 │ m │ 5.00×5.00 µm │ -│ Phase │ forward │ 256×256 │ ° │ 5.00×5.00 µm │ -│ CPD │ forward │ 256×256 │ V │ 5.00×5.00 µm │ -└──────────────────┴───────────┴───────────┴────────┴────────────────┘ +┌────────┬───────────┬─────────────────────┬─────────┬────────┬────────────────┐ +│ Canal │ Dirección │ Grupo │ Forma │ Unidad │ Tamaño X·Y │ +├────────┼───────────┼─────────────────────┼─────────┼────────┼────────────────┤ +│ Z-Axis │ forward │ Topography forward │ 256×256 │ m │ 5.00×5.00 µm │ +│ Z-Axis │ backward │ Topography backward │ 256×256 │ m │ 5.00×5.00 µm │ +│ CPD │ forward │ Potential forward │ 256×256 │ V │ 5.00×5.00 µm │ +└────────┴───────────┴─────────────────────┴─────────┴────────┴────────────────┘ ``` --- ## `spmkit roughness` -Calcula parámetros de rugosidad areal (ISO 25178). +Calcula estadísticas de rugosidad areal sobre el canal seleccionado. ```bash spmkit roughness FILE [OPTIONS] @@ -65,36 +66,61 @@ spmkit roughness FILE [OPTIONS] | Argumento | Descripción | |-----------|-------------| -| `FILE` | Archivo `.nid` o `.nhf` | +| `FILE` | Archivo `.nid`, `.gwy` o `.nhf` experimental | **Opciones:** | Opción | Por defecto | Descripción | |--------|-------------|-------------| | `--channel`, `-c` | `Z-Axis` | Canal a analizar | -| `--level`, `-l` | `plane` | Nivelación: `plane` \| `poly` \| `none` | +| `--direction` | — | Dirección del canal | +| `--group` | — | Grupo del canal | +| `--level`, `-l` | `plane` | Nivelación: `plane` \| `poly` \| `rows` \| `none` | **Ejemplo:** ```bash -spmkit roughness scan.nid -c Z-Axis --level plane +spmkit roughness scan.gwy -c Z-Axis --direction forward --level plane ``` -Salida: +La tabla usa los nombres reales del resultado: `Sa`, `Sq`, `Sz`, `Sp`, `Sv`, `Ssk`, `Sku`, +`unit` y `n_points`. + +Si el nombre identifica más de un canal, añade `--direction`; si la selección continúa +ambigua, añade `--group`. Consulta ambos valores con `spmkit info FILE`. + +--- +## `spmkit profile` + +Extrae un perfil entre dos coordenadas `(X, Y)` de píxel y lo guarda como CSV. + +```bash +spmkit profile FILE --x1 X --y1 Y [OPTIONS] ``` - Rugosidad · Z-Axis (m) -┌───────────┬──────────────┐ -│ Parámetro │ Valor │ -├───────────┼──────────────┤ -│ sa │ 2.388e-08 │ -│ sq │ 3.901e-08 │ -│ sz │ 2.205e-07 │ -│ ssk │ 2.4395 │ -│ sku │ 8.2629 │ -└───────────┴──────────────┘ + +**Opciones:** + +| Opción | Por defecto | Descripción | +|--------|-------------|-------------| +| `--channel`, `-c` | `Z-Axis` | Canal a analizar | +| `--direction` | — | Dirección del canal | +| `--group` | — | Grupo del canal | +| `--x0`, `--y0` | `0.0`, `0.0` | Punto inicial en píxeles | +| `--x1`, `--y1` | requeridos | Punto final en píxeles | +| `--n` | auto | Número de muestras | +| `--level`, `-l` | `plane` | `plane` \| `poly` \| `rows` \| `none` | +| `--output`, `-o` | `profile.csv` | CSV de salida | + +```bash +spmkit profile scan.gwy --direction forward \ + --x0 0.5 --y0 0.5 --x1 5.5 --y1 3.5 --n 3 \ + --level none --output profile.csv ``` +Los extremos deben quedar dentro de la imagen. El encabezado del archivo es +`distance[m],height[unidad]`, donde `unidad` es la unidad física del canal. + --- ## `spmkit analyze` @@ -111,14 +137,20 @@ spmkit analyze FILE [OPTIONS] |--------|-------------|-------------| | `--output`, `-o` | `./results` | Carpeta de salida | | `--channel`, `-c` | `Z-Axis` | Canal de topografía | +| `--direction` | — | Dirección del canal de topografía | +| `--group` | — | Grupo del canal de topografía | | `--cpd-channel` | `CPD` | Canal KPFM | -| `--level`, `-l` | `plane` | Nivelación | +| `--cpd-direction` | — | Dirección del canal CPD | +| `--cpd-group` | — | Grupo del canal CPD | +| `--level`, `-l` | `plane` | `plane` \| `poly` \| `rows` \| `none` | | `--tip-wf` | — | Función de trabajo de la punta (eV) | **Ejemplo:** ```bash -spmkit analyze scan.nid --output ./out --tip-wf 4.8 +spmkit analyze scan.gwy --output ./out \ + --channel Z-Axis --direction forward \ + --cpd-channel CPD --cpd-direction forward --tip-wf 4.7 ``` Genera: @@ -210,7 +242,7 @@ spmkit batch FOLDER [OPTIONS] | Argumento | Descripción | |-----------|-------------| -| `FOLDER` | Carpeta con archivos `.nid`, `.nhf` o `.gwy` | +| `FOLDER` | Carpeta con `.nid`, `.gwy` o `.nhf` experimental | **Opciones:** @@ -240,14 +272,16 @@ spmkit figure FILE [OPTIONS] | Opción | Por defecto | Descripción | |--------|-------------|-------------| | `--channel`, `-c` | `Z-Axis` | Canal a visualizar | +| `--direction` | — | Dirección del canal | +| `--group` | — | Grupo del canal | | `--output`, `-o` | `figure.png` | Archivo de salida (`.png`, `.svg`, `.pdf`) | -| `--colormap` | `batlow` | Colormap (colormaps Crameri disponibles) | +| `--colormap` | `gold` | Colormap | | `--title` | (nombre del canal) | Título de la figura | **Ejemplo:** ```bash -spmkit figure scan.nid -c Z-Axis -o topografia.svg --colormap tokyo +spmkit figure scan.gwy -c Z-Axis --direction forward -o topografia.svg ``` --- @@ -318,11 +352,13 @@ spmkit psd FILE [OPTIONS] | Opción | Por defecto | Descripción | |--------|-------------|-------------| | `--channel`, `-c` | `Z-Axis` | Canal a analizar | +| `--direction` | — | Dirección del canal | +| `--group` | — | Grupo del canal | **Ejemplo:** ```bash -spmkit psd scan.nid -c Z-Axis +spmkit psd scan.gwy -c Z-Axis --direction forward ``` Salida: @@ -347,8 +383,10 @@ Salida: Lanza la interfaz gráfica (requiere el extra `gui`). ```bash -spmkit gui +spmkit gui [FILE] ``` +`FILE` es opcional; cuando se indica, Fathom intenta abrirlo al arrancar. + !!! tip Si ves `ImportError`, instala el extra: `pip install "spmkit[gui]"` diff --git a/docs/getting-started.md b/docs/getting-started.md index 933ce2a..935e668 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,7 +2,7 @@ ## Requisitos -- **Python ≥ 3.11** +- **Python 3.11–3.12** - pip o [uv](https://github.com/astral-sh/uv) --- @@ -23,7 +23,8 @@ El paquete base incluye la CLI (`spmkit`) y el core de análisis (sin dependenci pip install "spmkit[gui]" ``` -Añade PyQt6, pyqtgraph y matplotlib (necesario para las 7 pestañas de la GUI). +Añade PyQt6, pyqtgraph y matplotlib para abrir Fathom, el workspace organizado por +perspectivas de análisis. ### Extras disponibles @@ -32,10 +33,9 @@ Añade PyQt6, pyqtgraph y matplotlib (necesario para las 7 pestañas de la GUI). | `gui` | Interfaz gráfica (PyQt6 + pyqtgraph) | | `viz` | Figuras de publicación (matplotlib, cmcrameri, scale bar) | | `gwy` | Interoperabilidad con Gwyddion (`.gwy`) | -| `hdf5` | Lectura / exportación HDF5 | +| `hdf5` | Lectura experimental `.nhf` y exportación HDF5 (h5py) | | `grains` | Detección de granos y partículas (scipy) | | `report` | Reportes HTML/PDF (Jinja2) | -| `nanosurf` | Lector `.nhf` validado (NSFopen) | Instalar varios extras: @@ -66,45 +66,58 @@ spmkit --version Resultado esperado: ``` -spmkit 0.1.0 +spmkit 0.1.4 ``` --- -## Primer uso — CLI +## Primer uso — journey de imagen -### Ver metadatos de un archivo +Para recorrer el flujo completo usa un archivo `.gwy` con topografía y, si quieres KPFM, +un canal CPD. Instala `gwy` para leerlo, `viz` para exportar la figura y `gui` para abrirlo +en Fathom: ```bash -spmkit info scan.nid +pip install "spmkit[gwy,viz,gui]" ``` -Muestra los canales disponibles, sus dimensiones y unidades. - -### Calcular rugosidad +Primero inspecciona los nombres, direcciones, grupos, formas y unidades disponibles: ```bash -spmkit roughness scan.nid -c Z-Axis +spmkit info scan.gwy ``` -Parámetros ISO 25178: Sa, Sq, Sz, Ssk, Sku. - -### Pipeline completo +Luego calcula estadísticas areales de la topografía nivelada, extrae un perfil entre +coordenadas `(X, Y)` de píxel y ejecuta el análisis de topografía y CPD: ```bash -spmkit analyze scan.nid --output ./results +spmkit roughness scan.gwy --channel Z-Axis --direction forward --level plane +spmkit profile scan.gwy --channel Z-Axis --direction forward \ + --x0 0.5 --y0 0.5 --x1 5.5 --y1 3.5 --n 3 --level plane \ + --output profile.csv +spmkit analyze scan.gwy --output ./results \ + --channel Z-Axis --direction forward \ + --cpd-channel CPD --cpd-direction forward --tip-wf 4.7 ``` -Genera `results/scan_roughness.csv`, `results/scan_roughness.json` y (si hay canal CPD) los archivos KPFM equivalentes. +Las coordenadas del perfil deben quedar dentro de la forma mostrada por `info`. El CSV +resultante tiene las columnas `distance[m]` y `height[unidad del canal]`; `analyze` genera +CSV y JSON separados para rugosidad y, cuando existe el canal seleccionado, KPFM. -### Abrir la GUI +Finalmente exporta una figura y abre el mismo archivo en Fathom: ```bash -spmkit gui +spmkit figure scan.gwy --channel Z-Axis --direction forward \ + --output topography.png +spmkit gui scan.gwy ``` -!!! note "Requisito extra" - La GUI requiere el extra `gui`. Si ves un error, instala con `pip install "spmkit[gui]"`. +Si un nombre está duplicado, debes añadir `--direction`; si todavía hay más de una +coincidencia, añade también `--group`. `spmkit info` muestra todos los registros y ambas +columnas para que puedas elegir sin depender del orden del archivo. + +`roughness` entrega estadísticas areales como `Sa`, `Sq`, `Sz`, `Sp`, `Sv`, `Ssk` y `Sku`; +este subconjunto no se presenta como cumplimiento integral de ISO 25178. --- @@ -112,28 +125,34 @@ spmkit gui ```python from spmkit import load -from spmkit.core.analysis import leveling, roughness, kpfm +from spmkit.core.analysis import kpfm, leveling, profiles, roughness -# Cargar un archivo .nid -data = load("scan.nid") +data = load("scan.gwy") -# Ver canales disponibles -print(data.names) +# select() exige una coincidencia única +raw = data.select("Z-Axis", direction="forward") +flat = leveling.plane_fit(raw) -# Seleccionar canal y nivelar -ch = data["Z-Axis"] -flat = leveling.plane_fit(ch) # corrige inclinación de plano +profile = profiles.line(flat, (0.5, 0.5), (5.5, 3.5), n=3) +print(profile.distance, profile.height) -# Calcular rugosidad stats = roughness.statistics(flat) -print(f"Sa = {stats.sa:.4g} {stats.unit}") -print(f"Sq = {stats.sq:.4g} {stats.unit}") +print(f"Sa = {stats.Sa:.4g} {stats.unit}") +print(f"Sq = {stats.Sq:.4g} {stats.unit}") -# Análisis KPFM (si el archivo contiene canal CPD) -cpd = kpfm.statistics(data["CPD"], tip_work_function=5.0) -print(f"CPD medio = {cpd.mean_cpd:.4g} V") +cpd_channel = data.select("CPD", direction="forward") +cpd = kpfm.statistics(cpd_channel, tip_work_function=4.7) +print(f"CPD medio = {cpd.mean:.4g} {cpd.unit}") +print(f"Función de trabajo = {cpd.work_function} {cpd.work_function_unit}") ``` +El core conserva los canales raw: la nivelación devuelve un `SPMChannel` nuevo y no modifica +`raw`. Los datos están en la unidad física indicada; los rangos espaciales están en metros. + +La convención implementada es `phi_sample = phi_tip - mean(CPD)` al expresar la función de +trabajo en eV y el CPD en V. Si omites `tip_work_function`, `work_function` queda en `None`: +spmkit no inventa una función de trabajo de la punta. + --- ## Siguiente paso diff --git a/docs/index.md b/docs/index.md index 6b927c3..66388de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,9 +11,13 @@ --- -spmkit lee formatos **NanoSurf** (`.nid`, `.nhf`) y **Gwyddion** (`.gwy`) y entrega análisis listo para publicar: rugosidad ISO 25178, perfiles interactivos, KPFM y nanomecánica, con una CLI y una GUI científica completa. +spmkit lee imágenes **NanoSurf** (`.nid`), archivos **Gwyddion** (`.gwy`) y, de forma +experimental, contenedores NanoSurf HDF5 (`.nhf`). Entrega estadísticas areales, perfiles, +KPFM y nanomecánica mediante una API, una CLI y la GUI Fathom. -Su lectura del `.nid` está **validada a precisión de máquina** contra Gwyddion. +La lectura `.nid` está validada externamente a precisión de máquina contra exportaciones de +Gwyddion. El soporte `.gwy` tiene pruebas de escritura y relectura (*round-trip*); `.nhf` +mantiene un contrato probado con archivos sintéticos, pero continúa marcado como experimental. ![spmkit GUI](images/screenshot_viewer.png) @@ -25,8 +29,8 @@ Su lectura del `.nid` está **validada a precisión de máquina** contra Gwyddio | Capacidad | Descripción | |-----------|-------------| -| **Formatos** | Lee `.nid`, `.nhf`, `.gwy`; escribe `.gwy` (round-trip con Gwyddion) | -| **Rugosidad** | ISO 25178 (Sa, Sq, Sz, Ssk, Sku) + nivelación (plano / polinomio / filas) | +| **Formatos** | `.nid` validado externamente; `.gwy` con round-trip; `.nhf` experimental y contract-tested | +| **Rugosidad** | Estadísticas areales (Sa, Sq, Sz, Sp, Sv, Ssk, Sku) + nivelación (plano / polinomio / filas) | | **Perfiles** | Perfiles de línea interactivos con interpolación bilineal | | **KPFM** | Potencial de contacto (CPD) y función de trabajo | | **Nanomecánica** | Hertz / Sneddon → módulo de Young, adhesión, mapas de módulo | diff --git a/docs/user-guide.md b/docs/user-guide.md index 8590dee..1196f38 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -7,25 +7,26 @@ muestra sólo los paneles que necesita. Una **paleta de comandos** (⌘K) da acc ## Abrir la GUI ```bash -spmkit gui # Fathom (por defecto) -spmkit gui --legacy # la app clásica de 7 pestañas (conservada como fallback) +spmkit gui # abre Fathom +spmkit gui scan.gwy # abre Fathom e intenta cargar el archivo ``` O desde Python: ```python from spmkit.gui.app import run -run() +run("scan.gwy") ``` !!! note "Requisito" - Instala el extra `gui`: `pip install "spmkit[gui]"`. Para detección de granos añade - `grains` (scipy): `pip install "spmkit[gui,grains]"`. + Para abrir el ejemplo `.gwy`, instala `pip install "spmkit[gui,gwy]"`. El lector `.nhf` + experimental requiere además `hdf5`; para detección de granos añade `grains` (scipy). ## Abrir datos - **Arrastra y suelta** un archivo sobre la ventana, o `Ctrl+O`. -- Fathom **inspecciona** el archivo y lo rutea solo: imágenes (`.nid`, `.nhf`, `.gwy`) van a +- Fathom **inspecciona** el archivo y lo rutea solo: imágenes (`.nid`, `.gwy` y `.nhf` + experimental) van a las perspectivas de imagen; curvas/force-volume (`.jpk-force`, `.nid` de espectroscopía, y con el extra `afm`: QI/force-map de JPK, `.ibw`, HDF5…) van a las de fuerza. Si un archivo trae imagen **y** curvas, pregunta cómo abrirlo. @@ -35,10 +36,18 @@ run() ## Perspectivas ### Imagen -Visor de canales: elige canal, **nivela** (plano / polinomio / por filas), **colormap**, y -traza un **perfil de línea** arrastrando el ROI sobre la imagen. El panel *Análisis* grafica -el perfil y muestra rugosidad (Sa/Sq/Sz/Ssk/Sku) y **KPFM/CPD** para canales de potencial; -exporta el perfil a CSV. +El selector muestra una etiqueta distinta para cada canal duplicado, usando su grupo o +dirección, para que puedas distinguir los barridos forward y backward. La identidad del +canal raw se conserva por posición; los controles **Plano**, **Polinomio**, **Filas** y +**Sin nivelar** producen la vista de trabajo sin modificar los datos raw cargados. + +Elige el colormap y traza un **perfil de línea** arrastrando los extremos del ROI sobre la +imagen. El panel *Análisis* grafica el perfil, muestra `Sa`, `Sq`, `Sz`, `Ssk` y `Sku`, y el +botón **Exportar perfil (CSV)…** guarda `distance[m]` y `height[unidad del canal]`. + +Al seleccionar un canal de potencial en voltios, el panel muestra la media y el contraste +CPD. La función de trabajo de la muestra solo aparece después de introducir explícitamente +**Φ punta (eV)**; con el valor en cero, Fathom no calcula ni inventa esa magnitud. ### Granos Detección de partículas sobre la topografía nivelada: **overlay** coloreado + estadística diff --git a/pyproject.toml b/pyproject.toml index e589aea..93de63f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "hatchling.build" [project] name = "spmkit" -version = "0.1.2" +version = "0.1.4" description = "Analizador open-source de datos AFM/KPFM para microscopía de sonda de barrido (SPM)" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.11,<3.13" license = { text = "MIT" } authors = [ { name = "José Labarca", email = "jlabarca@usm.cl" }, @@ -34,7 +34,6 @@ dependencies = [ [project.optional-dependencies] hdf5 = ["h5py>=3.8"] gwy = ["gwyfile>=0.3"] # interop con Gwyddion (.gwy) -nanosurf = ["NSFopen>=2.2"] # lector .nhf validado (NanoSurf) afm = ["afmformats>=0.18"] # lectores de la cola larga (JPK QI, .ibw, HDF5, NT-MDT…) jpk = ["tifffile>=2023.7"] # curvas/mapas de fuerza JPK en formato TIFF grains = ["scipy>=1.10"] # detección de granos/partículas @@ -52,12 +51,13 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.10", + "types-PyYAML>=6.0", "black>=24.0", "pre-commit>=3.7", ] test-gui = ["pytest-qt>=4.4"] # tests de GUI (requiere también el extra 'gui') docs = ["mkdocs-material>=9.5"] -all = ["spmkit[hdf5,gwy,nanosurf,afm,jpk,grains,viz,report,gui]"] +all = ["spmkit[hdf5,gwy,afm,jpk,grains,viz,report,gui]"] [project.urls] Homepage = "https://github.com/kegouro/spmkit" @@ -69,12 +69,15 @@ spmkit = "spmkit.cli.app:app" [tool.hatch.build.targets.wheel] packages = ["src/spmkit"] +exclude = ["/src/spmkit/gui/legacy"] [tool.hatch.build.targets.sdist] # El sdist no necesita las imágenes de docs (banners/capturas ~4 MB); mantiene el texto. exclude = [ "docs/images/", "/.github", + "/src/spmkit/gui/legacy", + "/.superpowers", "/reference", "/ui_preview", "/site", @@ -110,6 +113,7 @@ target-version = ["py311"] # sigue siendo compatible con 3.11+ (lo garantiza ruff target-version py311). python_version = "3.12" packages = ["spmkit"] +exclude = ["^src/spmkit/gui/legacy/"] ignore_missing_imports = true disallow_untyped_defs = true warn_unused_ignores = true diff --git a/scripts/run_gui_tests.sh b/scripts/run_gui_tests.sh index cfad3a5..8aa6ee5 100644 --- a/scripts/run_gui_tests.sh +++ b/scripts/run_gui_tests.sh @@ -78,7 +78,7 @@ _run_one() { } failed="" -for f in tests/gui/test_*.py; do +for f in tests/gui/test_*.py tests/e2e/gui/test_*.py; do echo "▶ $f" _run_one "$f" case $? in diff --git a/src/spmkit/__init__.py b/src/spmkit/__init__.py index 19a83b6..a9f05c6 100644 --- a/src/spmkit/__init__.py +++ b/src/spmkit/__init__.py @@ -9,8 +9,13 @@ Para el análisis usa los submódulos de :mod:`spmkit.core.analysis`. """ +from importlib.metadata import PackageNotFoundError, version + from spmkit.core import SPMChannel, SPMData, load -__version__ = "0.1.2" +try: + __version__ = version("spmkit") +except PackageNotFoundError: + __version__ = "0+unknown" __all__ = ["load", "SPMData", "SPMChannel", "__version__"] diff --git a/src/spmkit/cli/app.py b/src/spmkit/cli/app.py index 6b4efff..a9d4621 100644 --- a/src/spmkit/cli/app.py +++ b/src/spmkit/cli/app.py @@ -6,6 +6,7 @@ from __future__ import annotations +from enum import StrEnum from pathlib import Path import typer @@ -13,8 +14,9 @@ from rich.table import Table from spmkit import __version__, load -from spmkit.core.analysis import kpfm, leveling, roughness, spectral +from spmkit.core.analysis import kpfm, leveling, profiles, roughness, spectral from spmkit.core.export import to_csv, to_json +from spmkit.core.models import SPMChannel, SPMData from spmkit.core.verify import trace_nid app = typer.Typer( @@ -26,6 +28,31 @@ console = Console() +class _Level(StrEnum): + PLANE = "plane" + POLY = "poly" + ROWS = "rows" + NONE = "none" + + +def _select_channel( + data: SPMData, + name: str, + *, + direction: str | None = None, + group: str | None = None, + option_prefix: str = "", +) -> SPMChannel: + try: + return data.select(name, direction=direction, group=group) + except (KeyError, ValueError) as exc: + detail = str(exc.args[0]) if exc.args else str(exc) + raise typer.BadParameter( + f"{detail} Revise --{option_prefix}channel y use " + f"--{option_prefix}direction/--{option_prefix}group para precisar la selección." + ) from exc + + def _version_callback(value: bool) -> None: if value: console.print(f"spmkit {__version__}") @@ -47,12 +74,13 @@ def main( @app.command() -def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf")) -> None: +def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy")) -> None: """Muestra metadatos y canales del archivo.""" data = load(file) table = Table(title=f"{file.name} · formato {data.metadata.get('format', '?')}") table.add_column("Canal", style="cyan") table.add_column("Dirección") + table.add_column("Grupo") table.add_column("Forma") table.add_column("Unidad") table.add_column("Tamaño X·Y", justify="right") @@ -60,6 +88,7 @@ def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf table.add_row( ch.name, ch.direction, + ch.group, f"{ch.shape[0]}×{ch.shape[1]}", ch.unit, f"{ch.x_range * 1e6:.2f}×{ch.y_range * 1e6:.2f} µm", @@ -69,13 +98,15 @@ def info(file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf @app.command(name="roughness") def roughness_cmd( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), - level: str = typer.Option("plane", "--level", "-l", help="Nivelación: plane|poly|none"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l", help="Nivelación"), ) -> None: """Calcula parámetros de rugosidad (ISO 25178) de un canal.""" data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) ch = _apply_level(ch, level) result = roughness.statistics(ch) table = Table(title=f"Rugosidad · {channel} ({result.unit})") @@ -89,14 +120,43 @@ def roughness_cmd( console.print(table) +@app.command(help="Extrae un perfil de línea entre coordenadas de píxel.") +def profile( + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), + channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), + x0: float = typer.Option(0.0, "--x0", help="Coordenada X inicial en píxeles"), + y0: float = typer.Option(0.0, "--y0", help="Coordenada Y inicial en píxeles"), + x1: float = typer.Option(..., "--x1", help="Coordenada X final en píxeles"), + y1: float = typer.Option(..., "--y1", help="Coordenada Y final en píxeles"), + n: int | None = typer.Option(None, "--n", help="Número de muestras"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l", help="Nivelación"), + output: Path = typer.Option(Path("profile.csv"), "--output", "-o", help="CSV de salida"), +) -> None: + data = load(file) + ch = _apply_level( + _select_channel(data, channel, direction=direction, group=group), + level, + ) + try: + result = profiles.line(ch, (x0, y0), (x1, y1), n=n) + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + to_csv(result, output) + console.print(f"[green]✓[/] Perfil → {output}") + + @app.command(name="psd") def psd_cmd( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c", help="Canal a analizar"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), ) -> None: """Análisis espectral: dimensión fractal, Hurst y longitud de correlación.""" data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) ch = leveling.plane_fit(ch) frac = spectral.fractal_dimension(ch) corr = spectral.correlation_length(ch) @@ -113,11 +173,15 @@ def psd_cmd( @app.command() def analyze( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid o .nhf"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), output: Path = typer.Option(Path("./results"), "--output", "-o", help="Carpeta de salida"), channel: str = typer.Option("Z-Axis", "--channel", "-c"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), cpd_channel: str = typer.Option("CPD", "--cpd-channel"), - level: str = typer.Option("plane", "--level", "-l"), + cpd_direction: str | None = typer.Option(None, "--cpd-direction", help="Dirección de CPD"), + cpd_group: str | None = typer.Option(None, "--cpd-group", help="Grupo de CPD"), + level: _Level = typer.Option(_Level.PLANE, "--level", "-l"), tip_work_function: float | None = typer.Option( None, "--tip-wf", help="Función de trabajo de la punta (eV) para KPFM" ), @@ -127,14 +191,24 @@ def analyze( output.mkdir(parents=True, exist_ok=True) stem = file.stem - ch = _apply_level(data[channel], level) + ch = _apply_level( + _select_channel(data, channel, direction=direction, group=group), + level, + ) rough = roughness.statistics(ch) to_csv(rough, output / f"{stem}_roughness.csv") to_json(rough, output / f"{stem}_roughness.json") console.print(f"[green]✓[/] Rugosidad → {output / (stem + '_roughness.csv')}") if cpd_channel in data.names: - cpd = kpfm.statistics(data[cpd_channel], tip_work_function=tip_work_function) + cpd_ch = _select_channel( + data, + cpd_channel, + direction=cpd_direction, + group=cpd_group, + option_prefix="cpd-", + ) + cpd = kpfm.statistics(cpd_ch, tip_work_function=tip_work_function) to_csv(cpd, output / f"{stem}_kpfm.csv") to_json(cpd, output / f"{stem}_kpfm.json") console.print(f"[green]✓[/] KPFM → {output / (stem + '_kpfm.csv')}") @@ -328,17 +402,19 @@ def evaporation( @app.command() def figure( - file: Path = typer.Argument(..., exists=True, help="Archivo .nid/.nhf/.gwy"), + file: Path = typer.Argument(..., exists=True, help="Archivo .nid, .nhf o .gwy"), channel: str = typer.Option("Z-Axis", "--channel", "-c"), + direction: str | None = typer.Option(None, "--direction", help="Dirección del canal"), + group: str | None = typer.Option(None, "--group", help="Grupo del canal"), output: Path = typer.Option(Path("figure.png"), "--output", "-o", help="png|svg|pdf"), - colormap: str = typer.Option("batlow", "--colormap"), + colormap: str = typer.Option("gold", "--colormap"), title: str = typer.Option("", "--title"), ) -> None: """Exporta una figura de publicación (con scale bar y colormap científico).""" from spmkit.core.viz import FigureSpec, save_figure data = load(file) - ch = data[channel] + ch = _select_channel(data, channel, direction=direction, group=group) spec = FigureSpec( title=title or ch.name, colormap=colormap, colorbar_label=f"{ch.name} ({ch.unit})" ) @@ -425,15 +501,9 @@ def verify( @app.command() def gui( file: Path | None = typer.Argument(None, help="Archivo a abrir al arrancar (solo Fathom)"), - legacy: bool = typer.Option(False, "--legacy", help="Lanza la app clásica de 7 pestañas"), ) -> None: - """Lanza la GUI: **Fathom** por defecto, o la clásica con ``--legacy`` (requiere 'gui').""" + """Lanza la GUI Fathom (requiere 'gui').""" try: - if legacy: - from spmkit.gui.legacy import run as run_legacy - - run_legacy() - return from spmkit.gui.app import run except ImportError: console.print("[red]La GUI requiere PyQt6. Instala con:[/] pip install 'spmkit[gui]'") @@ -454,14 +524,16 @@ def workspace( raise typer.Exit(code=run(str(file) if file else None)) -def _apply_level(ch, level: str): # type: ignore[no-untyped-def] - if level == "plane": +def _apply_level(ch: SPMChannel, level: _Level) -> SPMChannel: + if level is _Level.PLANE: return leveling.plane_fit(ch) - if level == "poly": + if level is _Level.POLY: return leveling.polynomial(ch, order=2) - if level == "none": + if level is _Level.ROWS: + return leveling.align_rows(ch, method="median") + if level is _Level.NONE: return ch - raise typer.BadParameter("level debe ser plane|poly|none") + raise AssertionError(f"Nivelado no soportado: {level}") def _force_recipe(model: str, tip_radius: float, recipe_path: Path | None = None): # type: ignore[no-untyped-def] diff --git a/src/spmkit/core/analysis/kpfm.py b/src/spmkit/core/analysis/kpfm.py index fba0884..f404f24 100644 --- a/src/spmkit/core/analysis/kpfm.py +++ b/src/spmkit/core/analysis/kpfm.py @@ -47,8 +47,16 @@ def statistics(channel: SPMChannel, tip_work_function: float | None = None) -> C tip_work_function: Función de trabajo de la punta en eV. Si se entrega, se calcula ``phi_sample = phi_tip - V_CPD_medio``. """ + if channel.unit.casefold() != "v": + raise ValueError("El canal KPFM debe estar expresado en voltios SI (V)") + if tip_work_function is not None and ( + not np.isfinite(tip_work_function) or tip_work_function <= 0 + ): + raise ValueError("tip_work_function debe ser finita y estrictamente positiva") v = np.asarray(channel.data, dtype=np.float64).ravel() v = v[np.isfinite(v)] + if v.size == 0: + raise ValueError("Canal KPFM sin datos finitos") mean = float(v.mean()) work_function = None if tip_work_function is not None: diff --git a/src/spmkit/core/analysis/leveling.py b/src/spmkit/core/analysis/leveling.py index 695ce93..7e1acfd 100644 --- a/src/spmkit/core/analysis/leveling.py +++ b/src/spmkit/core/analysis/leveling.py @@ -12,16 +12,34 @@ from spmkit.core.models import SPMChannel +def _image_data(channel: SPMChannel) -> np.ndarray: + z = np.asarray(channel.data, dtype=np.float64) + if z.ndim != 2 or z.shape[0] < 2 or z.shape[1] < 2: + raise ValueError("Se requiere una imagen 2D de al menos 2x2") + return z + + +def _fit_coefficients(a_mat: np.ndarray, z: np.ndarray) -> np.ndarray: + finite = np.isfinite(z).ravel() + fit_matrix = a_mat[finite] + if fit_matrix.shape[0] < a_mat.shape[1]: + raise ValueError("Puntos finitos insuficientes para el ajuste") + if np.linalg.matrix_rank(fit_matrix) < a_mat.shape[1]: + raise ValueError("Rango insuficiente de puntos finitos para el ajuste") + coeffs, *_ = np.linalg.lstsq(fit_matrix, z.ravel()[finite], rcond=None) + return coeffs + + def plane_fit(channel: SPMChannel) -> SPMChannel: """Resta un plano de mínimos cuadrados ``z = a*x + b*y + c``. Es la corrección de inclinación más común para topografía AFM. """ - z = channel.data + z = _image_data(channel) rows, cols = z.shape yy, xx = np.mgrid[0:rows, 0:cols] a_mat = np.column_stack([xx.ravel(), yy.ravel(), np.ones(z.size)]) - coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None) + coeffs = _fit_coefficients(a_mat, z) plane = (a_mat @ coeffs).reshape(z.shape) return channel.with_data(z - plane) @@ -33,14 +51,16 @@ def polynomial(channel: SPMChannel, order: int = 2) -> SPMChannel: """ if order < 1: raise ValueError("order debe ser >= 1") - z = channel.data + z = _image_data(channel) rows, cols = z.shape - yy, xx = np.mgrid[0:rows, 0:cols] - x = xx.ravel().astype(np.float64) - y = yy.ravel().astype(np.float64) + y_axis = np.linspace(-1.0, 1.0, rows) + x_axis = np.linspace(-1.0, 1.0, cols) + yy, xx = np.meshgrid(y_axis, x_axis, indexing="ij") + x = xx.ravel() + y = yy.ravel() terms = [(x**i) * (y**j) for i in range(order + 1) for j in range(order + 1 - i)] a_mat = np.column_stack(terms) - coeffs, *_ = np.linalg.lstsq(a_mat, z.ravel(), rcond=None) + coeffs = _fit_coefficients(a_mat, z) surface = (a_mat @ coeffs).reshape(z.shape) return channel.with_data(z - surface) @@ -51,11 +71,13 @@ def align_rows(channel: SPMChannel, method: str = "median") -> SPMChannel: Args: method: ``"median"`` (robusto) o ``"mean"``. """ - z = channel.data - if method == "median": - baseline = np.median(z, axis=1, keepdims=True) - elif method == "mean": - baseline = np.mean(z, axis=1, keepdims=True) - else: + if method not in {"median", "mean"}: raise ValueError("method debe ser 'median' o 'mean'") - return channel.with_data(z - baseline) + leveled = np.asarray(channel.data, dtype=np.float64).copy() + for row in leveled: + finite = np.isfinite(row) + if not finite.any(): + continue + baseline = np.median(row[finite]) if method == "median" else np.mean(row[finite]) + row[finite] -= baseline + return channel.with_data(leveled) diff --git a/src/spmkit/core/analysis/profiles.py b/src/spmkit/core/analysis/profiles.py index 2ed7628..c46358f 100644 --- a/src/spmkit/core/analysis/profiles.py +++ b/src/spmkit/core/analysis/profiles.py @@ -58,10 +58,23 @@ def line( Un :class:`Profile` con distancia física acumulada y altura. """ z = np.asarray(channel.data, dtype=np.float64) + if z.ndim != 2 or not channel.is_spatial: + raise ValueError("El perfil requiere un canal de imagen espacial 2D") (x0, y0), (x1, y1) = p0, p1 + rows_count, cols_count = z.shape + endpoints = (x0, y0, x1, y1) + if not all(np.isfinite(value) for value in endpoints) or not ( + 0 <= x0 <= cols_count - 1 + and 0 <= x1 <= cols_count - 1 + and 0 <= y0 <= rows_count - 1 + and 0 <= y1 <= rows_count - 1 + ): + raise ValueError("Punto fuera de los límites de la imagen") seg_px = float(np.hypot(x1 - x0, y1 - y0)) if n is None: n = max(2, int(round(seg_px)) + 1) + elif n < 1: + raise ValueError("n debe ser >= 1") cols = np.linspace(x0, x1, n) rows = np.linspace(y0, y1, n) diff --git a/src/spmkit/core/analysis/roughness.py b/src/spmkit/core/analysis/roughness.py index 68a4ee4..632d8a6 100644 --- a/src/spmkit/core/analysis/roughness.py +++ b/src/spmkit/core/analysis/roughness.py @@ -47,6 +47,8 @@ def statistics(channel: SPMChannel) -> RoughnessResult: z = np.asarray(channel.data, dtype=np.float64) flat = z.ravel() flat = flat[np.isfinite(flat)] + if flat.size == 0: + raise ValueError("Canal sin datos finitos para calcular rugosidad") mean = flat.mean() dev = flat - mean diff --git a/src/spmkit/core/export/writers.py b/src/spmkit/core/export/writers.py index 11c05b0..5a15109 100644 --- a/src/spmkit/core/export/writers.py +++ b/src/spmkit/core/export/writers.py @@ -42,7 +42,7 @@ def _to_serializable(obj: Any) -> Any: return obj.item() if isinstance(obj, dict): return {k: _to_serializable(v) for k, v in obj.items()} - if isinstance(obj, (list, tuple)): + if isinstance(obj, list | tuple): return [_to_serializable(v) for v in obj] return obj diff --git a/src/spmkit/core/io/jpk_tiff.py b/src/spmkit/core/io/jpk_tiff.py index e98c688..614ed63 100644 --- a/src/spmkit/core/io/jpk_tiff.py +++ b/src/spmkit/core/io/jpk_tiff.py @@ -38,7 +38,13 @@ def looks_like_jpk_tiff(path: str | Path) -> bool: import tifffile with tifffile.TiffFile(path) as tf: - return _JPK_TAG in {t.code for t in tf.pages[0].tags} + page_or_frame = tf.pages[0] + page = ( + page_or_frame.aspage() + if isinstance(page_or_frame, tifffile.TiffFrame) + else page_or_frame + ) + return _JPK_TAG in {t.code for t in page.tags} except Exception: # noqa: BLE001 - cualquier fallo = no es un JPK-TIFF legible return False @@ -68,7 +74,12 @@ def _read_pages(path: str | Path) -> list[dict[str, Any]]: pages: list[dict[str, Any]] = [] with tifffile.TiffFile(path) as tf: - for page in tf.pages: + for page_or_frame in tf.pages: + page = ( + page_or_frame.aspage() + if isinstance(page_or_frame, tifffile.TiffFrame) + else page_or_frame + ) tags = {t.code: t.value for t in page.tags} name = str(tags.get(_CHANNEL_NAME_TAG, "")) slots = _slots(tags) diff --git a/src/spmkit/core/io/nhf.py b/src/spmkit/core/io/nhf.py index 8635620..5e31245 100644 --- a/src/spmkit/core/io/nhf.py +++ b/src/spmkit/core/io/nhf.py @@ -5,9 +5,9 @@ tomando unidades/escala de los atributos cuando están disponibles. .. note:: - Implementación inicial best-effort. Requiere validación contra archivos - ``.nhf`` reales del lab. Necesita el extra ``hdf5`` (``pip install - spmkit[hdf5]``). + Implementación experimental con contrato sintético reproducible, aún no + validada contra un instrumento u oráculo externo. Necesita el extra + ``hdf5`` (``pip install spmkit[hdf5]``). """ from __future__ import annotations @@ -43,29 +43,34 @@ def load_nhf(path: str | Path) -> SPMData: path = Path(path) channels: list[SPMChannel] = [] - with h5py.File(path, "r") as f: - - def visit(name: str, obj: Any) -> None: - if not isinstance(obj, h5py.Dataset): - return - if obj.ndim != 2: - return - data = np.asarray(obj[()], dtype=np.float64) - channels.append( - SPMChannel( - name=_attr(obj, "name", "Name", default=name.split("/")[-1]), - data=data, - unit=_attr(obj, "unit", "Unit", "base_unit", default=""), - x_range=float(_attr(obj, "x_range", "image_size_x", default=0.0)), - y_range=float(_attr(obj, "y_range", "image_size_y", default=0.0)), - direction=_attr(obj, "direction", default="forward"), - group=name.rsplit("/", 1)[0], - metadata={k: _attr(obj, k) for k in obj.attrs}, + try: + with h5py.File(path, "r") as f: + + def visit(name: str, obj: Any) -> None: + if not isinstance(obj, h5py.Dataset): + return + if obj.ndim != 2: + return + data = np.asarray(obj[()], dtype=np.float64) + channels.append( + SPMChannel( + name=_attr(obj, "name", "Name", default=name.split("/")[-1]), + data=data, + unit=_attr(obj, "unit", "Unit", "base_unit", default=""), + x_range=float(_attr(obj, "x_range", "image_size_x", default=0.0)), + y_range=float(_attr(obj, "y_range", "image_size_y", default=0.0)), + direction=_attr(obj, "direction", default="forward"), + group=name.rsplit("/", 1)[0], + metadata={k: _attr(obj, k) for k in obj.attrs}, + ) ) - ) - f.visititems(visit) - root_meta = {k: _attr(f, k) for k in f.attrs} + f.visititems(visit) + root_meta = {k: _attr(f, k) for k in f.attrs} + except OSError as exc: + raise ValueError( + f"No se pudo abrir o leer el .nhf; puede ser inválido, corrupto o inaccesible: {path}" + ) from exc if not channels: raise ValueError(f"No se encontraron canales 2D en el .nhf: {path}") diff --git a/src/spmkit/core/models/spmdata.py b/src/spmkit/core/models/spmdata.py index 5dec333..6b13382 100644 --- a/src/spmkit/core/models/spmdata.py +++ b/src/spmkit/core/models/spmdata.py @@ -112,6 +112,32 @@ def get(self, name: str, direction: str = "forward") -> SPMChannel: return ch raise KeyError(f"Canal no encontrado: {name!r}. Disponibles: {self.names}") + def select( + self, + name: str, + *, + direction: str | None = None, + group: str | None = None, + ) -> SPMChannel: + """Selecciona un único canal por los campos suministrados.""" + matches = [ + channel + for channel in self.channels + if channel.name == name + and (direction is None or channel.direction == direction) + and (group is None or channel.group == group) + ] + selection = f"name={name!r}, direction={direction!r}, group={group!r}" + identities = ", ".join( + f"(name={channel.name!r}, direction={channel.direction!r}, group={channel.group!r})" + for channel in (matches or self.channels) + ) + if not matches: + raise KeyError(f"Canal no encontrado para {selection}. Disponibles: {identities}") + if len(matches) > 1: + raise ValueError(f"Selección ambigua para {selection}. Disponibles: {identities}") + return matches[0] + def __getitem__(self, name: str) -> SPMChannel: return self.get(name) diff --git a/src/spmkit/core/project.py b/src/spmkit/core/project.py index 941ce06..4e171e9 100644 --- a/src/spmkit/core/project.py +++ b/src/spmkit/core/project.py @@ -10,6 +10,7 @@ from __future__ import annotations +import hashlib import json from dataclasses import dataclass, field from pathlib import Path @@ -25,6 +26,16 @@ class OpenFile: path: str kind: str # "image" | "force" + sha256: str | None = None + + @classmethod + def from_path(cls, path: str | Path, kind: str) -> OpenFile: + ruta = Path(path) + resumen = hashlib.sha256() + with ruta.open("rb") as flujo: + for bloque in iter(lambda: flujo.read(1024 * 1024), b""): + resumen.update(bloque) + return cls(path=str(ruta), kind=kind, sha256=resumen.hexdigest()) @dataclass @@ -40,7 +51,7 @@ def to_dict(self) -> dict[str, Any]: return { "version": self.version, "perspective": self.perspective, - "files": [{"path": f.path, "kind": f.kind} for f in self.files], + "files": [{"path": f.path, "kind": f.kind, "sha256": f.sha256} for f in self.files], "params": self.params, } @@ -56,7 +67,11 @@ def load_project(path: str | Path) -> ProjectState: """Lee un ``.spmproj``, tolerante a campos faltantes/desconocidos.""" raw = json.loads(Path(path).read_text(encoding="utf-8")) files = [ - OpenFile(path=str(f["path"]), kind=str(f.get("kind", "force"))) + OpenFile( + path=str(f["path"]), + kind=str(f.get("kind", "force")), + sha256=str(f["sha256"]) if f.get("sha256") is not None else None, + ) for f in raw.get("files", []) if isinstance(f, dict) and f.get("path") ] diff --git a/src/spmkit/core/viz/forcecurve.py b/src/spmkit/core/viz/forcecurve.py index 09ffa09..d3d8b8d 100644 --- a/src/spmkit/core/viz/forcecurve.py +++ b/src/spmkit/core/viz/forcecurve.py @@ -21,7 +21,7 @@ def _modulus_label(ctx: dict[str, Any]) -> str: e = ctx.get("young_modulus") - if not isinstance(e, (int, float)) or not np.isfinite(e): + if not isinstance(e, int | float) or not np.isfinite(e): return "" es = ctx.get("young_modulus_std", 0.0) or 0.0 scale, unit = 1.0, "Pa" @@ -30,7 +30,7 @@ def _modulus_label(ctx: dict[str, Any]) -> str: scale, unit = s, u break r2 = ctx.get("r_squared") - r2_txt = f"\nR² = {r2:.4f}" if isinstance(r2, (int, float)) and np.isfinite(r2) else "" + r2_txt = f"\nR² = {r2:.4f}" if isinstance(r2, int | float) and np.isfinite(r2) else "" return f"E = {e / scale:.3g} ± {es / scale:.2g} {unit}{r2_txt}" @@ -55,7 +55,7 @@ def render_force_curve( ctx = ctx or {} contact = ctx.get("contact_point") - offset = float(contact) if (indentation and isinstance(contact, (int, float))) else 0.0 + offset = float(contact) if (indentation and isinstance(contact, int | float)) else 0.0 def axis_of(seg: Any) -> np.ndarray: return (display_axis(seg.separation, seg.raw_height) - offset) * _NM @@ -82,7 +82,7 @@ def axis_of(seg: Any) -> np.ndarray: lw=2.2, label="ajuste", ) - if isinstance(contact, (int, float)) and ctx.get("contact_detected", True): + if isinstance(contact, int | float) and ctx.get("contact_detected", True): # Punto de contacto en oro (coherente con la app y el logo Fathom). ax.axvline((float(contact) - offset) * _NM, color="#B26A1E", ls="--", lw=0.9) diff --git a/src/spmkit/gui/app_workspace.py b/src/spmkit/gui/app_workspace.py index 65bfcec..0ad5acf 100644 --- a/src/spmkit/gui/app_workspace.py +++ b/src/spmkit/gui/app_workspace.py @@ -175,11 +175,24 @@ def _save_project(ws: Workspace, vm: ForceViewModel, session: dict[str, Any]) -> ) if not path: return - files = [OpenFile(session["path"], session["kind"])] if session.get("path") else [] + without_hash = False + files = [] + if session.get("path"): + try: + files = [OpenFile.from_path(session["path"], session["kind"])] + except OSError: + files = [OpenFile(path=str(session["path"]), kind=session["kind"], sha256=None)] + without_hash = True state = ProjectState(files=files, params=vm.params, perspective=ws.active_perspective) save_project(state, path) _remember_dir(path) - ws.show_status(f"proyecto guardado: {Path(path).name}") + if without_hash: + ws.show_status( + f"proyecto guardado sin hash: {Path(path).name}; " + "restaura el archivo de origen y vuelve a guardar" + ) + else: + ws.show_status(f"proyecto guardado: {Path(path).name}") def _open_project( @@ -256,7 +269,7 @@ def _suggested(name: str) -> str: def _scalar_results(ctx: dict) -> dict: """Filtra el contexto a valores serializables (descarta el objeto de ajuste).""" - return {k: v for k, v in ctx.items() if isinstance(v, (int, float, str, bool)) or v is None} + return {k: v for k, v in ctx.items() if isinstance(v, int | float | str | bool) or v is None} def _results_tsv(ctx: dict) -> str: diff --git a/src/spmkit/gui/panels/force_canvas.py b/src/spmkit/gui/panels/force_canvas.py index c59cee4..34b804a 100644 --- a/src/spmkit/gui/panels/force_canvas.py +++ b/src/spmkit/gui/panels/force_canvas.py @@ -216,7 +216,7 @@ def _on_results(self, ctx: dict) -> None: """Re-render con la curva calibrada + overlay de ajuste + residuos.""" self._last_ctx = ctx cp = ctx.get("contact_point") - self._contact = float(cp) if isinstance(cp, (int, float)) else None + self._contact = float(cp) if isinstance(cp, int | float) else None self._refresh_offset() curve = self._vm.result_curve() if curve is not None: diff --git a/src/spmkit/gui/panels/inspector.py b/src/spmkit/gui/panels/inspector.py index f1f441e..4df6bec 100644 --- a/src/spmkit/gui/panels/inspector.py +++ b/src/spmkit/gui/panels/inspector.py @@ -47,7 +47,7 @@ def _fmt_modulus(e: float, es: float) -> str: def _fmt_scaled(value: object, scale: float, unit: str, prec: str = ".3g") -> str: """Formatea un número escalado (p. ej. N→nN) o ``—`` si no es finito.""" - if not isinstance(value, (int, float)) or not math.isfinite(float(value)): + if not isinstance(value, int | float) or not math.isfinite(float(value)): return _EMPTY return f"{float(value) * scale:{prec}} {unit}" @@ -99,7 +99,7 @@ def _on_results(self, ctx: dict) -> None: ) r2 = ctx.get("r_squared") self._values["r_squared"].setText( - f"{float(r2):.4f}" if isinstance(r2, (int, float)) and math.isfinite(r2) else _EMPTY + f"{float(r2):.4f}" if isinstance(r2, int | float) and math.isfinite(r2) else _EMPTY ) self._values["contact"].setText(_fmt_scaled(ctx.get("contact_point"), 1e9, "nm")) self._values["adhesion"].setText(_fmt_scaled(ctx.get("adhesion"), 1e9, "nN")) diff --git a/tests/core/test_cli_gui.py b/tests/core/test_cli_gui.py new file mode 100644 index 0000000..0568956 --- /dev/null +++ b/tests/core/test_cli_gui.py @@ -0,0 +1,12 @@ +from typer.testing import CliRunner + +from spmkit.cli.app import app + +runner = CliRunner() + + +def test_gui_help_no_anuncia_legacy() -> None: + result = runner.invoke(app, ["gui", "--help"]) + + assert result.exit_code == 0, result.output + assert "--legacy" not in result.output diff --git a/tests/core/test_cli_image.py b/tests/core/test_cli_image.py new file mode 100644 index 0000000..d9c60ff --- /dev/null +++ b/tests/core/test_cli_image.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import numpy as np +import pytest +from click import unstyle +from typer.testing import CliRunner + +from spmkit.core.models import SPMChannel, SPMData + +cli_app = importlib.import_module("spmkit.cli.app") +app = cli_app.app +runner = CliRunner() + + +def _compact_output(output: str) -> str: + return "".join(unstyle(output).replace("│", " ").split()) + + +def _channel( + direction: str = "forward", + group: str = "Scan 1", + *, + name: str = "Z-Axis", + values: np.ndarray | None = None, +) -> SPMChannel: + return SPMChannel( + name=name, + data=np.asarray(values if values is not None else np.arange(9).reshape(3, 3)), + unit="m" if name == "Z-Axis" else "V", + x_range=1e-6, + y_range=1e-6, + direction=direction, + group=group, + ) + + +def _invoke_roughness( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + data: SPMData, + *args: str, +) -> tuple[Any, list[SPMChannel]]: + source = tmp_path / "synthetic.gwy" + source.touch() + selected: list[SPMChannel] = [] + monkeypatch.setattr(cli_app, "load", lambda _path: data) + + def fake_statistics(channel: SPMChannel) -> SimpleNamespace: + selected.append(channel) + return SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}) + + monkeypatch.setattr(cli_app.roughness, "statistics", fake_statistics) + result = runner.invoke(app, ["roughness", str(source), *args], terminal_width=200) + return result, selected + + +@pytest.mark.parametrize("command", ["roughness", "analyze", "psd", "figure"]) +def test_help_canales_incluye_selectores_y_gwy(command: str) -> None: + result = runner.invoke(app, [command, "--help"]) + + assert result.exit_code == 0, result.output + output = _compact_output(result.output) + assert "--direction" in output + assert "--group" in output + assert ".gwy" in output + + +def test_help_level_muestra_choices_y_analyze_muestra_selectores_cpd() -> None: + roughness_help = runner.invoke(app, ["roughness", "--help"]) + analyze_help = runner.invoke(app, ["analyze", "--help"]) + + assert roughness_help.exit_code == 0, roughness_help.output + roughness_output = _compact_output(roughness_help.output) + analyze_output = _compact_output(analyze_help.output) + assert all(choice in roughness_output for choice in ("plane", "poly", "rows", "none")) + assert "--cpd-direction" in analyze_output + assert "--cpd-group" in analyze_output + + +def test_info_muestra_grupo(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + monkeypatch.setattr(cli_app, "load", lambda _path: SPMData(channels=(_channel(),))) + + result = runner.invoke(app, ["info", str(source)]) + + assert result.exit_code == 0, result.output + assert "Grupo" in result.output + assert "Scan1" in _compact_output(result.output) + + +def test_level_invalido_se_rechaza_durante_parsing_con_choices(tmp_path: Path) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + + result = runner.invoke(app, ["roughness", str(source), "--level", "invalid"]) + + assert result.exit_code == 2 + output = _compact_output(result.output) + assert "invalid" in output + assert all(choice in output for choice in ("plane", "poly", "rows", "none")) + + +def test_roughness_rows_usa_mediana(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + raw = np.array([[1.0, 2.0, 100.0], [10.0, 20.0, 30.0], [5.0, 5.0, 9.0]]) + data = SPMData(channels=(_channel(values=raw),)) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data, "--level", "rows") + + assert result.exit_code == 0, result.output + np.testing.assert_allclose(np.median(selected[0].data, axis=1), 0.0) + + +def test_roughness_selecciona_canal_exacto(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + channels = ( + _channel("forward", "Scan 1"), + _channel("forward", "Scan 2"), + _channel("backward", "Scan 1"), + ) + + result, selected = _invoke_roughness( + monkeypatch, + tmp_path, + SPMData(channels=channels), + "--direction", + "forward", + "--group", + "Scan 2", + "--level", + "none", + ) + + assert result.exit_code == 0, result.output + assert selected == [channels[1]] + + +def test_roughness_ambigua_es_bad_parameter_accionable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + data = SPMData(channels=(_channel(group="Scan 1"), _channel(group="Scan 2"))) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data) + + assert result.exit_code == 2 + normalized_output = _compact_output(result.output) + assert "ambigua" in normalized_output + assert "Scan1" in normalized_output + assert "Scan2" in normalized_output + assert "--direction" in normalized_output + assert "--group" in normalized_output + assert selected == [] + + +def test_roughness_ausente_incluye_opciones_disponibles( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + data = SPMData(channels=(_channel(name="Height", group="Topography"),)) + + result, selected = _invoke_roughness(monkeypatch, tmp_path, data) + + assert result.exit_code == 2 + assert "Disponibles" in result.output + assert "Height" in result.output + assert "Topography" in result.output + assert selected == [] + + +def test_analyze_omite_cpd_solo_si_el_nombre_no_existe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + data = SPMData(channels=(_channel(),)) + monkeypatch.setattr(cli_app, "load", lambda _path: data) + monkeypatch.setattr( + cli_app.roughness, + "statistics", + lambda channel: SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}), + ) + monkeypatch.setattr(cli_app, "to_csv", lambda *_args: None) + monkeypatch.setattr(cli_app, "to_json", lambda *_args: None) + + result = runner.invoke( + app, ["analyze", str(source), "--output", str(tmp_path / "output"), "--level", "none"] + ) + + assert result.exit_code == 0, result.output + assert "Sin canal CPD" in result.output + + +def test_analyze_rechaza_cpd_ambiguo_si_el_nombre_existe( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "synthetic.gwy" + source.touch() + data = SPMData( + channels=( + _channel(), + _channel(name="CPD", group="Scan 1"), + _channel(name="CPD", group="Scan 2"), + ) + ) + monkeypatch.setattr(cli_app, "load", lambda _path: data) + monkeypatch.setattr( + cli_app.roughness, + "statistics", + lambda channel: SimpleNamespace(unit=channel.unit, to_dict=lambda: {"Sa": 0.0}), + ) + monkeypatch.setattr(cli_app, "to_csv", lambda *_args: None) + monkeypatch.setattr(cli_app, "to_json", lambda *_args: None) + + result = runner.invoke( + app, ["analyze", str(source), "--output", str(tmp_path / "output"), "--level", "none"] + ) + + assert result.exit_code == 2 + output = _compact_output(result.output) + assert "ambigua" in output + assert "Scan1" in output + assert "Scan2" in output + assert "--cpd-direction" in output + assert "--cpd-group" in output diff --git a/tests/core/test_export.py b/tests/core/test_export.py index 7f07e90..d13d7bb 100644 --- a/tests/core/test_export.py +++ b/tests/core/test_export.py @@ -2,6 +2,7 @@ from __future__ import annotations +import csv import json from pathlib import Path @@ -28,6 +29,33 @@ def test_roughness_to_csv(flat_noisy: SPMChannel, tmp_path: Path) -> None: assert "Sq" in text +def test_roughness_round_trip_csv_json(tmp_path: Path) -> None: + channel = SPMChannel( + name="Z", + data=np.array([[1.0, 2.0], [3.0, 4.0]]), + unit="m", + x_range=1.0, + y_range=1.0, + ) + result = roughness.statistics(channel) + + csv_path = to_csv(result, tmp_path / "roughness.csv") + json_path = to_json(result, tmp_path / "roughness.json") + + with csv_path.open(newline="") as file: + csv_result = {row["key"]: row["value"] for row in csv.DictReader(file)} + json_result = json.loads(json_path.read_text()) + + assert float(csv_result["Sa"]) == result.Sa + assert float(csv_result["Sq"]) == result.Sq + assert csv_result["unit"] == result.unit + assert int(csv_result["n_points"]) == result.n_points + assert json_result["Sa"] == result.Sa + assert json_result["Sq"] == result.Sq + assert json_result["unit"] == result.unit + assert json_result["n_points"] == result.n_points + + def test_profile_to_csv(tmp_path: Path) -> None: ch = SPMChannel(name="Z", data=np.zeros((5, 5)), unit="m", x_range=1e-6, y_range=1e-6) prof = profiles.line(ch, (0, 0), (4, 0), n=5) diff --git a/tests/core/test_jpk_tiff.py b/tests/core/test_jpk_tiff.py index 2ad59e6..8bd1af4 100644 --- a/tests/core/test_jpk_tiff.py +++ b/tests/core/test_jpk_tiff.py @@ -66,6 +66,29 @@ def test_jpk_tiff_conversion_exacta(tmp_path) -> None: np.testing.assert_allclose(ext.separation, raw_h[0] * m_h - raw_vd[0] * m_dm) +def test_jpk_tiff_conserva_segmentos_con_tiff_frames(tmp_path, monkeypatch) -> None: + path = tmp_path / "curva_con_frames" + raw = np.array([[10, 20, 30, 40]], dtype=np.int32) + _write_synthetic_jpk_tiff(path, raw, raw, 1e-9, 1e-9, 1e-8) + tiff_file = tifffile.TiffFile + + def abrir_con_frames(*args, **kwargs): + kwargs["_useframes"] = True + archivo = tiff_file(*args, **kwargs) + assert any(isinstance(page, tifffile.TiffFrame) for page in archivo.pages) + return archivo + + monkeypatch.setattr(tifffile, "TiffFile", abrir_con_frames) + + assert looks_like_jpk_tiff(path) + volume = load_jpk_tiff(path) + assert volume.n_curves == 1 + assert [segment.segment_type for segment in volume.curve(0).segments] == [ + "extend", + "retract", + ] + + def test_jpk_tiff_via_load_any(tmp_path) -> None: """``load_any`` reconoce el JPK-TIFF por contenido y devuelve un ForceVolume.""" from spmkit.core.io.loadany import inspect_any, load_any diff --git a/tests/core/test_kpfm.py b/tests/core/test_kpfm.py index 0b96863..c12a3ba 100644 --- a/tests/core/test_kpfm.py +++ b/tests/core/test_kpfm.py @@ -2,6 +2,11 @@ from __future__ import annotations +import warnings + +import numpy as np +import pytest + from spmkit.core.analysis import kpfm from spmkit.core.models import SPMChannel @@ -23,3 +28,43 @@ def test_work_function(cpd_channel: SPMChannel) -> None: def test_cpd_to_dict(cpd_channel: SPMChannel) -> None: d = kpfm.statistics(cpd_channel).to_dict() assert {"mean", "std", "contrast", "work_function"} <= set(d) + + +def test_cpd_accepts_lowercase_volts_and_ignores_nan() -> None: + ch = SPMChannel( + name="CPD", + data=np.array([[0.2, np.nan], [0.4, 0.6]]), + unit="v", + x_range=1e-6, + y_range=1e-6, + ) + + result = kpfm.statistics(ch) + + assert result.mean == pytest.approx(0.4) + assert result.unit == "v" + assert result.work_function is None + + +def test_cpd_rejects_non_voltage_channel() -> None: + ch = SPMChannel(name="Height", data=np.zeros((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="voltios"): + kpfm.statistics(ch) + + +def test_cpd_rejects_all_nan_without_numpy_warnings() -> None: + ch = SPMChannel(name="CPD", data=np.full((2, 2), np.nan), unit="V", x_range=1e-6, y_range=1e-6) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + with pytest.raises(ValueError, match="sin datos finitos"): + kpfm.statistics(ch) + + +@pytest.mark.parametrize("tip_work_function", [0.0, -1.0, np.inf, -np.inf, np.nan]) +def test_tip_work_function_must_be_finite_and_positive(tip_work_function: float) -> None: + ch = SPMChannel(name="CPD", data=np.zeros((2, 2)), unit="V", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="finita y estrictamente positiva"): + kpfm.statistics(ch, tip_work_function=tip_work_function) diff --git a/tests/core/test_leveling.py b/tests/core/test_leveling.py index ff6474a..4b5de80 100644 --- a/tests/core/test_leveling.py +++ b/tests/core/test_leveling.py @@ -2,7 +2,11 @@ from __future__ import annotations +import warnings +from collections.abc import Callable + import numpy as np +import pytest from spmkit.core.analysis import leveling from spmkit.core.models import SPMChannel @@ -22,6 +26,31 @@ def test_plane_fit_preserves_metadata(tilted_surface: SPMChannel) -> None: assert leveled.shape == tilted_surface.shape +def test_plane_fit_does_not_mutate_or_share_input_data(tilted_surface: SPMChannel) -> None: + original_data = tilted_surface.data.copy() + + leveled = leveling.plane_fit(tilted_surface) + + assert np.array_equal(tilted_surface.data, original_data) + assert isinstance(leveled, SPMChannel) + assert leveled is not tilted_surface + assert not np.shares_memory(leveled.data, tilted_surface.data) + + +def test_plane_fit_ignores_nan_and_preserves_mask() -> None: + yy, xx = np.mgrid[0:4, 0:4] + data = (2.0 * xx + 3.0 * yy + 1.0).astype(float) + data[1, 2] = np.nan + original = data.copy() + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + + leveled = leveling.plane_fit(ch) + + assert np.isnan(leveled.data[1, 2]) + assert np.allclose(leveled.data[np.isfinite(original)], 0.0, atol=1e-12) + assert np.array_equal(ch.data, original, equal_nan=True) + + def test_polynomial_flattens_curvature() -> None: rows = cols = 32 yy, xx = np.mgrid[0:rows, 0:cols] @@ -31,9 +60,91 @@ def test_polynomial_flattens_curvature() -> None: assert np.allclose(leveled.data, 0.0, atol=1e-6) +def test_polynomial_ignores_nan_and_preserves_mask() -> None: + yy, xx = np.mgrid[0:4, 0:4] + data = (xx**2 + yy**2 + xx * yy).astype(float) + data[2, 1] = np.nan + original = data.copy() + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + + leveled = leveling.polynomial(ch, order=2) + + assert np.isnan(leveled.data[2, 1]) + assert np.allclose(leveled.data[np.isfinite(original)], 0.0, atol=1e-10) + assert np.array_equal(ch.data, original, equal_nan=True) + + +def test_polynomial_avoids_false_rank_loss_on_large_coordinates() -> None: + ch = SPMChannel(name="Z", data=np.zeros((32, 32)), unit="m", x_range=1e-6, y_range=1e-6) + + leveled = leveling.polynomial(ch, order=8) + + assert np.array_equal(leveled.data, np.zeros((32, 32))) + + +@pytest.mark.parametrize("operation", [leveling.plane_fit, leveling.polynomial]) +def test_surface_fit_requires_image_of_at_least_two_by_two( + operation: Callable[[SPMChannel], SPMChannel], +) -> None: + ch = SPMChannel(name="Z", data=np.ones((1, 4)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="2x2"): + operation(ch) + + +def test_plane_fit_requires_enough_independent_finite_points() -> None: + ch = SPMChannel( + name="Z", + data=np.array([[1.0, np.nan], [np.nan, 2.0]]), + unit="m", + x_range=1e-6, + y_range=1e-6, + ) + + with pytest.raises(ValueError, match="(?i)puntos finitos"): + leveling.plane_fit(ch) + + +def test_plane_fit_rejects_collinear_finite_points_by_rank() -> None: + data = np.full((3, 3), np.nan) + np.fill_diagonal(data, [1.0, 2.0, 3.0]) + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="(?i)rango insuficiente"): + leveling.plane_fit(ch) + + +def test_polynomial_requires_enough_independent_finite_points() -> None: + ch = SPMChannel(name="Z", data=np.ones((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="(?i)puntos finitos"): + leveling.polynomial(ch, order=2) + + +def test_polynomial_rejects_dependent_basis_by_rank() -> None: + ch = SPMChannel(name="Z", data=np.ones((2, 3)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="(?i)rango insuficiente"): + leveling.polynomial(ch, order=2) + + def test_align_rows() -> None: data = np.zeros((10, 10)) data += np.arange(10).reshape(-1, 1) # offset por fila ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) leveled = leveling.align_rows(ch, method="median") assert np.allclose(leveled.data, 0.0) + + +@pytest.mark.parametrize("method", ["median", "mean"]) +def test_align_rows_ignores_nan_without_warning(method: str) -> None: + data = np.array([[1.0, np.nan, 3.0], [np.nan, np.nan, np.nan]]) + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always") + leveled = leveling.align_rows(ch, method=method) + + assert not recorded + assert np.array_equal(leveled.data[0], np.array([-1.0, np.nan, 1.0]), equal_nan=True) + assert np.isnan(leveled.data[1]).all() diff --git a/tests/core/test_nhf.py b/tests/core/test_nhf.py new file mode 100644 index 0000000..0ead7c3 --- /dev/null +++ b/tests/core/test_nhf.py @@ -0,0 +1,70 @@ +from pathlib import Path + +import numpy as np +import pytest + +from spmkit import load + +h5py = pytest.importorskip("h5py") + + +def test_load_nhf_conserva_canal_y_atributos_bytes(tmp_path: Path) -> None: + path = tmp_path / "synthetic.nhf" + esperado = np.array([[1.25, 2.5, 3.75], [4.0, 5.5, 6.75]], dtype=np.float64) + with h5py.File(path, "w") as archivo: + grupo = archivo.create_group("Scan forward") + dataset = grupo.create_dataset("raw_height", data=esperado) + dataset.attrs["name"] = np.bytes_("Z-Axis") + dataset.attrs["unit"] = np.bytes_("m") + dataset.attrs["x_range"] = 3.0e-6 + dataset.attrs["y_range"] = 2.0e-6 + dataset.attrs["direction"] = np.bytes_("backward") + dataset.attrs["note"] = np.bytes_("sintético".encode()) + + resultado = load(path) + + assert len(resultado.channels) == 1 + canal = resultado.channels[0] + np.testing.assert_array_equal(canal.data, esperado) + assert canal.name == "Z-Axis" + assert canal.unit == "m" + assert canal.x_range == 3.0e-6 + assert canal.y_range == 2.0e-6 + assert canal.direction == "backward" + assert canal.group == "Scan forward" + assert canal.metadata["note"] == "sintético" + assert resultado.source_path == str(path) + + +def test_load_nhf_ignora_datasets_que_no_son_2d(tmp_path: Path) -> None: + path = tmp_path / "mixed.nhf" + with h5py.File(path, "w") as archivo: + archivo.create_dataset("scalar", data=1.0) + archivo.create_dataset("profile", data=np.arange(4)) + archivo.create_dataset("volume", data=np.zeros((2, 3, 4))) + imagen = archivo.create_dataset("image", data=np.ones((2, 3))) + imagen.attrs["name"] = "Height" + + resultado = load(path) + + assert resultado.names == ["Height"] + + +def test_load_nhf_sin_datasets_2d_falla_con_mensaje_accionable(tmp_path: Path) -> None: + path = tmp_path / "empty.nhf" + with h5py.File(path, "w") as archivo: + archivo.create_dataset("profile", data=np.arange(4)) + + with pytest.raises(ValueError, match=r"(?i)no se encontraron canales 2D.*\.nhf"): + load(path) + + +def test_load_nhf_invalido_envuelve_error_de_h5py(tmp_path: Path) -> None: + path = tmp_path / "corrupt.nhf" + path.write_bytes(b"esto no es HDF5") + + patron = r"(?i)no se pudo abrir o leer.*\.nhf.*(inválido|corrupto)" + with pytest.raises(ValueError, match=patron) as error: + load(path) + + assert isinstance(error.value.__cause__, OSError) diff --git a/tests/core/test_profiles.py b/tests/core/test_profiles.py index 027bde5..c19c2dd 100644 --- a/tests/core/test_profiles.py +++ b/tests/core/test_profiles.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pytest from spmkit.core.analysis import profiles from spmkit.core.models import SPMChannel @@ -29,3 +30,45 @@ def test_bilinear_midpoint() -> None: ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) prof = profiles.line(ch, (0.5, 0), (0.5, 0), n=1) assert prof.height[0] == 1.0 + + +def test_diagonal_profile_uses_anisotropic_physical_ranges() -> None: + ch = SPMChannel(name="Z", data=np.zeros((4, 4)), unit="m", x_range=2e-6, y_range=6e-6) + + prof = profiles.line(ch, (0, 0), (3, 3), n=4) + + expected = np.hypot(3 * ch.pixel_size_x, 3 * ch.pixel_size_y) + assert prof.distance[-1] == pytest.approx(expected) + + +def test_profile_rejects_non_spatial_channel() -> None: + ch = SPMChannel(name="Spectrum", data=np.zeros((1, 4)), unit="V", x_range=1.0, y_range=1.0) + + with pytest.raises(ValueError, match="espacial"): + profiles.line(ch, (0, 0), (3, 0)) + + +@pytest.mark.parametrize("n", [0, -1]) +def test_profile_rejects_sample_count_below_one(n: int) -> None: + ch = SPMChannel(name="Z", data=np.zeros((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="n debe ser >= 1"): + profiles.line(ch, (0, 0), (1, 1), n=n) + + +@pytest.mark.parametrize( + ("p0", "p1"), + [ + ((-0.1, 0), (1, 1)), + ((0, -0.1), (1, 1)), + ((0, 0), (2, 1)), + ((0, 0), (1, 2)), + ], +) +def test_profile_rejects_endpoints_outside_image( + p0: tuple[float, float], p1: tuple[float, float] +) -> None: + ch = SPMChannel(name="Z", data=np.zeros((2, 2)), unit="m", x_range=1e-6, y_range=1e-6) + + with pytest.raises(ValueError, match="fuera"): + profiles.line(ch, p0, p1, n=2) diff --git a/tests/core/test_project.py b/tests/core/test_project.py index f1a9f23..1fe8e32 100644 --- a/tests/core/test_project.py +++ b/tests/core/test_project.py @@ -2,11 +2,31 @@ from __future__ import annotations +import hashlib import json from spmkit.core.project import OpenFile, ProjectState, load_project, save_project +def test_roundtrip_con_hash_sha256(tmp_path) -> None: # type: ignore[no-untyped-def] + contenido = b"spmkit-project-hash\x00\xff" + archivo = tmp_path / "datos.bin" + archivo.write_bytes(contenido) + esperado = hashlib.sha256(contenido).hexdigest() + state = ProjectState( + files=[OpenFile.from_path(archivo, "force")], + perspective="map", + ) + + path = save_project(state, tmp_path / "sesion.spmproj") + + raw = json.loads(path.read_text(encoding="utf-8")) + assert raw["files"] == [{"path": str(archivo), "kind": "force", "sha256": esperado}] + loaded = load_project(path) + assert loaded.files == [OpenFile(str(archivo), "force", esperado)] + assert loaded.perspective == "map" + + def test_roundtrip(tmp_path) -> None: # type: ignore[no-untyped-def] state = ProjectState( files=[OpenFile("a.nid", "force"), OpenFile("b.gwy", "image")], diff --git a/tests/core/test_roughness.py b/tests/core/test_roughness.py index 34b1eb9..20ae7ab 100644 --- a/tests/core/test_roughness.py +++ b/tests/core/test_roughness.py @@ -2,6 +2,8 @@ from __future__ import annotations +import warnings + import numpy as np import pytest @@ -25,6 +27,16 @@ def test_flat_surface_zero_roughness() -> None: assert r.Ssk == 0.0 # guardia contra división por cero +def test_sa_sq_exact_values() -> None: + data = np.array([[1.0, 2.0], [3.0, 4.0]]) + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + r = roughness.statistics(ch) + assert r.Sa == pytest.approx(1.0) + assert r.Sq == pytest.approx(np.sqrt(1.25)) + assert r.unit == "m" + assert r.n_points == 4 + + def test_sz_is_peak_to_valley() -> None: data = np.zeros((8, 8)) data[0, 0] = 10.0 @@ -47,3 +59,22 @@ def test_gaussian_moments() -> None: r = roughness.statistics(ch) assert r.Ssk == pytest.approx(0.0, abs=0.05) assert r.Sku == pytest.approx(3.0, abs=0.1) + + +def test_statistics_ignores_isolated_nan() -> None: + data = np.array([[1.0, 2.0], [3.0, np.nan]]) + ch = SPMChannel(name="Z", data=data, unit="m", x_range=1e-6, y_range=1e-6) + + result = roughness.statistics(ch) + + assert result.Sq == pytest.approx(np.std([1.0, 2.0, 3.0])) + assert result.n_points == 3 + + +def test_statistics_rejects_all_nan_without_numpy_warnings() -> None: + ch = SPMChannel(name="Z", data=np.full((2, 2), np.nan), unit="m", x_range=1e-6, y_range=1e-6) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + with pytest.raises(ValueError, match="sin datos finitos"): + roughness.statistics(ch) diff --git a/tests/core/test_spmdata_selection.py b/tests/core/test_spmdata_selection.py new file mode 100644 index 0000000..f203713 --- /dev/null +++ b/tests/core/test_spmdata_selection.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from spmkit.core.models import SPMChannel, SPMData + + +def _channel(name: str, direction: str, group: str) -> SPMChannel: + return SPMChannel( + name=name, + data=np.zeros((2, 2)), + unit="m", + x_range=1e-6, + y_range=1e-6, + direction=direction, + group=group, + ) + + +@pytest.fixture +def duplicate_channels() -> SPMData: + return SPMData( + channels=( + _channel("Z-Axis", "forward", "Scan 1"), + _channel("Z-Axis", "backward", "Scan 1"), + _channel("Z-Axis", "forward", "Scan 2"), + _channel("CPD", "forward", "Scan 1"), + ) + ) + + +def test_select_devuelve_la_identidad_exacta(duplicate_channels: SPMData) -> None: + selected = duplicate_channels.select("Z-Axis", direction="forward", group="Scan 2") + + assert selected is duplicate_channels.channels[2] + + +def test_select_filtra_solo_los_campos_suministrados(duplicate_channels: SPMData) -> None: + selected = duplicate_channels.select("Z-Axis", direction="backward") + + assert selected is duplicate_channels.channels[1] + + +def test_select_rechaza_nombre_ambiguo_con_identidades(duplicate_channels: SPMData) -> None: + with pytest.raises(ValueError, match="ambigua") as exc_info: + duplicate_channels.select("Z-Axis") + + message = str(exc_info.value) + assert "forward" in message + assert "backward" in message + assert "Scan 1" in message + assert "Scan 2" in message + + +def test_select_rechaza_direccion_ambigua_sin_grupo(duplicate_channels: SPMData) -> None: + with pytest.raises(ValueError, match="ambigua") as exc_info: + duplicate_channels.select("Z-Axis", direction="forward") + + message = str(exc_info.value) + assert "Scan 1" in message + assert "Scan 2" in message + + +def test_select_ausente_describe_seleccion_y_opciones(duplicate_channels: SPMData) -> None: + with pytest.raises(KeyError) as exc_info: + duplicate_channels.select("Z-Axis", direction="backward", group="Scan 2") + + message = str(exc_info.value) + assert "Z-Axis" in message + assert "backward" in message + assert "Scan 2" in message + assert "Disponibles" in message + assert "forward" in message + assert "Scan 1" in message + + +def test_get_y_getitem_conservan_el_fallback_compatible(duplicate_channels: SPMData) -> None: + assert duplicate_channels.get("Z-Axis") is duplicate_channels.channels[0] + assert duplicate_channels.get("Z-Axis", direction="missing") is duplicate_channels.channels[0] + assert duplicate_channels["Z-Axis"] is duplicate_channels.channels[0] diff --git a/tests/e2e/cli/__init__.py b/tests/e2e/cli/__init__.py new file mode 100644 index 0000000..1c5882c --- /dev/null +++ b/tests/e2e/cli/__init__.py @@ -0,0 +1 @@ +"""Journeys end-to-end de la interfaz CLI.""" diff --git a/tests/e2e/cli/test_image_journey.py b/tests/e2e/cli/test_image_journey.py new file mode 100644 index 0000000..fc43c47 --- /dev/null +++ b/tests/e2e/cli/test_image_journey.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import csv +import json +import math +from pathlib import Path + +import pytest +from click import unstyle +from typer.testing import CliRunner + +from spmkit import load +from spmkit.cli.app import app +from spmkit.core.analysis import kpfm, leveling, roughness + +runner = CliRunner() + + +def _compact_output(output: str) -> str: + return "".join(unstyle(output).replace("│", " ").split()) + + +def _csv_scalars(path: Path) -> dict[str, str]: + with path.open(newline="", encoding="utf-8") as stream: + return {row["key"]: row["value"] for row in csv.DictReader(stream)} + + +def test_real_gwy_cli_info_selection_and_analysis(real_gwy_path: Path, tmp_path: Path) -> None: + info_result = runner.invoke(app, ["info", str(real_gwy_path)], terminal_width=200) + assert info_result.exit_code == 0, info_result.output + info_output = _compact_output(info_result.output) + assert "formatogwy" in info_output + assert "Grupo" in info_output + assert "Z-Axisforward" in info_output + assert "Z-Axisbackward" in info_output + assert "CPDforward" in info_output + + roughness_result = runner.invoke( + app, + ["roughness", str(real_gwy_path), "--direction", "forward", "--level", "plane"], + ) + assert roughness_result.exit_code == 0, roughness_result.output + + ambiguous_result = runner.invoke(app, ["roughness", str(real_gwy_path)]) + assert ambiguous_result.exit_code == 2 + normalized_error = _compact_output(ambiguous_result.output) + assert "ambigua" in normalized_error.casefold() + assert "--direction/--group" in normalized_error + + output_dir = tmp_path / "analysis" + analyze_result = runner.invoke( + app, + [ + "analyze", + str(real_gwy_path), + "--output", + str(output_dir), + "--direction", + "forward", + "--cpd-direction", + "forward", + "--tip-wf", + "4.7", + ], + ) + assert analyze_result.exit_code == 0, analyze_result.output + + data = load(real_gwy_path) + expected_roughness = roughness.statistics( + leveling.plane_fit(data.select("Z-Axis", direction="forward")) + ) + expected_kpfm = kpfm.statistics(data.select("CPD", direction="forward"), tip_work_function=4.7) + stem = real_gwy_path.stem + roughness_csv = _csv_scalars(output_dir / f"{stem}_roughness.csv") + roughness_json = json.loads((output_dir / f"{stem}_roughness.json").read_text(encoding="utf-8")) + kpfm_csv = _csv_scalars(output_dir / f"{stem}_kpfm.csv") + kpfm_json = json.loads((output_dir / f"{stem}_kpfm.json").read_text(encoding="utf-8")) + assert roughness_csv["unit"] == roughness_json["unit"] == expected_roughness.unit + assert float(roughness_csv["Sq"]) == pytest.approx(expected_roughness.Sq) + assert roughness_json["Sq"] == pytest.approx(expected_roughness.Sq) + assert kpfm_csv["unit"] == kpfm_json["unit"] == expected_kpfm.unit + assert float(kpfm_csv["mean"]) == pytest.approx(expected_kpfm.mean) + assert kpfm_json["work_function"] == pytest.approx(expected_kpfm.work_function) + + +def test_real_gwy_cli_profile_and_default_figure(real_gwy_path: Path, tmp_path: Path) -> None: + profile_help = runner.invoke(app, ["profile", "--help"]) + assert profile_help.exit_code == 0, profile_help.output + profile_output = _compact_output(profile_help.output) + assert "coordenadasdepíxel" in profile_output + assert "--x1" in profile_output and "required" in profile_output + assert "--y1" in profile_output and "required" in profile_output + + profile_path = tmp_path / "profile.csv" + profile_result = runner.invoke( + app, + [ + "profile", + str(real_gwy_path), + "--direction", + "forward", + "--x0", + "0.5", + "--y0", + "0.5", + "--x1", + "5.5", + "--y1", + "3.5", + "--n", + "3", + "--level", + "none", + "--output", + str(profile_path), + ], + ) + assert profile_result.exit_code == 0, profile_result.output + with profile_path.open(newline="", encoding="utf-8") as stream: + rows = list(csv.reader(stream)) + assert rows[0] == ["distance[m]", "height[m]"] + assert len(rows) == 4 + for row in rows[1:]: + assert len(row) == 2 + assert all(math.isfinite(float(value)) for value in row) + + invalid_profile = runner.invoke( + app, + [ + "profile", + str(real_gwy_path), + "--direction", + "forward", + "--x1", + "7", + "--y1", + "4", + ], + ) + assert invalid_profile.exit_code == 2 + assert "fueradeloslímites" in _compact_output(invalid_profile.output) + + figure_help = runner.invoke(app, ["figure", "--help"]) + assert figure_help.exit_code == 0, figure_help.output + assert "gold" in figure_help.output + figure_path = tmp_path / "figure.png" + figure_result = runner.invoke( + app, + [ + "figure", + str(real_gwy_path), + "--direction", + "forward", + "--output", + str(figure_path), + ], + ) + assert figure_result.exit_code == 0, figure_result.output + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..74aa03c --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from spmkit.core.io import save_gwy +from spmkit.core.models import SPMChannel, SPMData + + +@pytest.fixture +def real_gwy_path(tmp_path: Path) -> Path: + pytest.importorskip("gwyfile") + + rows, cols = np.indices((5, 7), dtype=np.float64) + texture = ((cols + 2.0 * rows) % 3.0 - 1.0) * 0.25e-9 + topography_forward = 10e-9 + 2e-9 * cols + 3e-9 * rows + texture + topography_backward = 20e-9 - 1e-9 * cols + 1.5e-9 * rows - texture + cpd = 0.1 + 0.01 * rows + 0.005 * cols + x_range = 7e-6 + y_range = 10e-6 + + data = SPMData( + channels=( + SPMChannel( + name="Z-Axis", + data=topography_forward, + unit="m", + x_range=x_range, + y_range=y_range, + direction="forward", + group="Topography forward", + ), + SPMChannel( + name="Z-Axis", + data=topography_backward, + unit="m", + x_range=x_range, + y_range=y_range, + direction="backward", + group="Topography backward", + ), + SPMChannel( + name="CPD", + data=cpd, + unit="V", + x_range=x_range, + y_range=y_range, + direction="forward", + group="Potential forward", + ), + ), + metadata={"format": "synthetic"}, + ) + return save_gwy(data, tmp_path / "image_journey.gwy") diff --git a/tests/e2e/gui/test_image_journey.py b/tests/e2e/gui/test_image_journey.py new file mode 100644 index 0000000..f8860c6 --- /dev/null +++ b/tests/e2e/gui/test_image_journey.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import csv +from pathlib import Path + +import numpy as np +import pytest + +pytest.importorskip("PyQt6") +pytest.importorskip("pytestqt") + +from PyQt6.QtCore import QCoreApplication, QEvent, Qt # noqa: E402 +from PyQt6.QtWidgets import QFileDialog, QPushButton # noqa: E402 + +from spmkit.gui.app_workspace import build_workspace # noqa: E402 + + +def test_corrupt_gwy_via_open_action_keeps_gui_alive( + qtbot, monkeypatch, tmp_path: Path +) -> None: # type: ignore[no-untyped-def] + corrupt_path = tmp_path / "corrupt_image.gwy" + corrupt_path.write_bytes(b"not a Gwyddion file") + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(corrupt_path), "")), + ) + ws = build_workspace() + ws.show() + qtbot.wait(0) + + try: + open_action = next(action for action in ws._persp_bar.actions() if "Abrir" in action.text()) + open_action.trigger() + qtbot.wait(0) + + canvas = ws.panel("image_canvas") + assert canvas is not None + assert not ws.isHidden() + assert ws.active_perspective != "image" + assert canvas._vm.data is None + assert ws._status._message.text() == ( + "No se pudo abrir corrupt_image.gwy: " + "archivo .gwy inválido o corrupto: corrupt_image.gwy" + ) + finally: + ws.close() + ws.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QCoreApplication.processEvents() + qtbot.wait(0) + + +def test_real_gwy_gui_image_journey( + qtbot, monkeypatch, real_gwy_path: Path, tmp_path: Path +) -> None: # type: ignore[no-untyped-def] + monkeypatch.setattr( + QFileDialog, + "getOpenFileName", + staticmethod(lambda *args, **kwargs: (str(real_gwy_path), "")), + ) + ws = build_workspace() + ws.show() + qtbot.wait(0) + + try: + open_action = next(action for action in ws._persp_bar.actions() if "Abrir" in action.text()) + open_action.trigger() + qtbot.wait(0) + + assert ws.active_perspective == "image" + status = ws._status._message.text() + assert real_gwy_path.name in status + assert "imagen" in status + assert "3 canales" in status + + canvas = ws.panel("image_canvas") + analysis = ws.panel("image_analysis") + assert canvas is not None and analysis is not None + image_vm = canvas._vm + assert image_vm.data is not None + assert len(image_vm.data.channels) == 3 + assert image_vm.names == ["Z-Axis", "Z-Axis", "CPD"] + + selector = canvas._channel + assert selector.count() == 3 + forward_label = selector.itemText(0) + backward_label = selector.itemText(1) + assert forward_label != backward_label + assert "Z-Axis" in forward_label and "Z-Axis" in backward_label + + forward = image_vm.raw_channel_at(0) + backward = image_vm.raw_channel_at(1) + assert forward is not None and backward is not None + assert forward.direction == "forward" + assert backward.direction == "backward" + forward_raw = np.asarray(forward.data).copy() + backward_raw = np.asarray(backward.data).copy() + assert not np.array_equal(forward_raw, backward_raw) + + selector.setCurrentIndex(0) + assert image_vm.current_index == 0 + selector.setCurrentIndex(1) + assert image_vm.current_index == 1 + assert image_vm.raw_channel_at(1) is backward + np.testing.assert_array_equal(image_vm.raw_channel_at(0).data, forward_raw) + np.testing.assert_array_equal(image_vm.raw_channel_at(1).data, backward_raw) + + selector.setCurrentIndex(0) + profile = image_vm.profile((0.5, 0.5), (5.5, 3.5)) + assert profile is not None + assert image_vm.last_profile is profile + assert analysis._plot.listDataItems() + + profile_path = tmp_path / "profile.csv" + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(profile_path), "")), + ) + profile_button = next( + button + for button in analysis.findChildren(QPushButton) + if "Exportar perfil" in button.text() + ) + qtbot.mouseClick(profile_button, Qt.MouseButton.LeftButton) + assert profile_path.is_file() + with profile_path.open(newline="", encoding="utf-8") as stream: + reader = csv.DictReader(stream) + rows = list(reader) + assert reader.fieldnames == ["distance[m]", "height[m]"] + assert len(rows) == len(profile) + np.testing.assert_allclose([float(row["distance[m]"]) for row in rows], profile.distance) + np.testing.assert_allclose([float(row["height[m]"]) for row in rows], profile.height) + + selector.setCurrentIndex(2) + assert image_vm.current_index == 2 + assert image_vm.channel == "CPD" + assert analysis._wf.isVisible() + analysis._wf.setValue(4.5) + assert image_vm.tip_work_function == 4.5 + readout = analysis._readout.text() + assert "KPFM (CPD)" in readout + assert "Φ muestra" in readout + + figure_action = next( + action for action in ws._persp_bar.actions() if action.text() == "Figura" + ) + figure_action.trigger() + qtbot.wait(0) + assert ws.active_perspective == "figure" + + figure = ws.panel("figure_editor") + assert figure is not None + assert figure._vm.channel == "Z-Axis" + assert "Z-Axis" in figure._channel.currentText() + assert figure._cmap.currentText() == "gold" + assert figure._vm.spec.colormap == "gold" + + figure_path = tmp_path / "figure.png" + monkeypatch.setattr( + QFileDialog, + "getSaveFileName", + staticmethod(lambda *args, **kwargs: (str(figure_path), "")), + ) + figure_button = next( + button + for button in figure.findChildren(QPushButton) + if button.text() == "Exportar figura…" + ) + qtbot.mouseClick(figure_button, Qt.MouseButton.LeftButton) + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 + finally: + ws.close() + ws.deleteLater() + QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete) + QCoreApplication.processEvents() + qtbot.wait(0) diff --git a/tests/e2e/library/__init__.py b/tests/e2e/library/__init__.py new file mode 100644 index 0000000..fbb27f3 --- /dev/null +++ b/tests/e2e/library/__init__.py @@ -0,0 +1 @@ +"""Journeys end-to-end de la API de librería.""" diff --git a/tests/e2e/library/test_image_journey.py b/tests/e2e/library/test_image_journey.py new file mode 100644 index 0000000..62f10cd --- /dev/null +++ b/tests/e2e/library/test_image_journey.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path + +import numpy as np +import pytest + +from spmkit import load +from spmkit.core.analysis import kpfm, leveling, profiles, roughness +from spmkit.core.export import to_csv, to_json +from spmkit.core.viz import FigureSpec, save_figure + + +def test_real_gwy_library_image_journey(real_gwy_path: Path, tmp_path: Path) -> None: + data = load(real_gwy_path) + + assert data.metadata["format"] == "gwy" + assert data.source_path == str(real_gwy_path) + assert len(data.channels) == 3 + assert data.names == ["Z-Axis", "Z-Axis", "CPD"] + forward = data.select("Z-Axis", direction="forward", group="Z-Axis forward") + backward = data.select("Z-Axis", direction="backward", group="Z-Axis backward") + assert forward.shape == backward.shape == (5, 7) + assert forward.unit == backward.unit == "m" + assert forward.x_range == backward.x_range == pytest.approx(7e-6) + assert forward.y_range == backward.y_range == pytest.approx(10e-6) + assert not np.array_equal(forward.data, backward.data) + + raw = forward.data.copy() + leveled = leveling.plane_fit(forward) + np.testing.assert_array_equal(forward.data, raw) + assert not np.shares_memory(leveled.data, forward.data) + + roughness_result = roughness.statistics(leveled) + assert roughness_result.unit == "m" + assert all( + np.isfinite(value) + for value in ( + roughness_result.Sa, + roughness_result.Sq, + roughness_result.Sz, + roughness_result.Sp, + roughness_result.Sv, + roughness_result.Ssk, + roughness_result.Sku, + ) + ) + + profile = profiles.line(forward, (0.5, 0.5), (5.5, 3.5), n=3) + assert profile.unit == "m" + assert profile.distance_unit == "m" + assert len(profile) == 3 + assert profile.height[0] == pytest.approx(float(np.mean(raw[:2, :2]))) + assert profile.height[1] == pytest.approx(float(raw[2, 3])) + assert profile.height[-1] == pytest.approx(float(np.mean(raw[3:5, 5:7]))) + expected_distance = np.hypot(5.0 * forward.pixel_size_x, 3.0 * forward.pixel_size_y) + assert profile.distance[-1] == pytest.approx(expected_distance) + + cpd_channel = data.select("CPD", direction="forward", group="CPD forward") + assert cpd_channel.shape == (5, 7) + assert cpd_channel.unit == "V" + cpd_result = kpfm.statistics(cpd_channel, tip_work_function=4.7) + assert cpd_result.mean == pytest.approx(0.135) + assert cpd_result.minimum == pytest.approx(0.1) + assert cpd_result.maximum == pytest.approx(0.17) + assert cpd_result.contrast == pytest.approx(0.07) + assert cpd_result.work_function == pytest.approx(4.7 - cpd_result.mean) + assert cpd_result.work_function_unit == "eV" + + roughness_csv = to_csv(roughness_result, tmp_path / "roughness.csv") + with roughness_csv.open(newline="", encoding="utf-8") as stream: + roughness_rows = {row["key"]: row["value"] for row in csv.DictReader(stream)} + assert roughness_rows["unit"] == roughness_result.unit + assert float(roughness_rows["Sq"]) == pytest.approx(roughness_result.Sq) + assert int(roughness_rows["n_points"]) == roughness_result.n_points + + profile_csv = to_csv(profile, tmp_path / "profile.csv") + with profile_csv.open(newline="", encoding="utf-8") as stream: + profile_rows = list(csv.DictReader(stream)) + assert tuple(profile_rows[0]) == ("distance[m]", "height[m]") + np.testing.assert_allclose( + [float(row["distance[m]"]) for row in profile_rows], profile.distance + ) + np.testing.assert_allclose([float(row["height[m]"]) for row in profile_rows], profile.height) + + kpfm_json = to_json(cpd_result, tmp_path / "kpfm.json") + reopened_kpfm = json.loads(kpfm_json.read_text(encoding="utf-8")) + assert reopened_kpfm["unit"] == cpd_result.unit + assert reopened_kpfm["work_function_unit"] == cpd_result.work_function_unit + assert reopened_kpfm["mean"] == pytest.approx(cpd_result.mean) + assert reopened_kpfm["work_function"] == pytest.approx(cpd_result.work_function) + + figure_path = save_figure(forward, FigureSpec(), tmp_path / "topography.png") + figure_bytes = figure_path.read_bytes() + assert figure_bytes.startswith(b"\x89PNG\r\n\x1a\n") + assert len(figure_bytes) > 1_000 diff --git a/tests/gui/test_e2e_flows.py b/tests/gui/test_e2e_flows.py index aa3f0cf..5211313 100644 --- a/tests/gui/test_e2e_flows.py +++ b/tests/gui/test_e2e_flows.py @@ -68,6 +68,9 @@ def test_e2e_force_flow(qtbot, synthetic_volume) -> None: # type: ignore[no-unt assert mvm.result is not None # el mapa de módulo se calculó (grilla definida) for key in ("force_canvas", "map_canvas", "smfs_canvas", "batch_table"): assert not ws.panel(key).errored + ws.close() + ws.deleteLater() + qtbot.wait(0) def test_e2e_image_flow(qtbot) -> None: # type: ignore[no-untyped-def] @@ -81,6 +84,9 @@ def test_e2e_image_flow(qtbot) -> None: # type: ignore[no-untyped-def] assert ivm.roughness() is not None # la rugosidad se computa sobre la topografía for key in ("image_canvas", "grains_canvas", "spectral_canvas"): assert not ws.panel(key).errored + ws.close() + ws.deleteLater() + qtbot.wait(0) def test_e2e_resonance_flow(qtbot) -> None: # type: ignore[no-untyped-def] @@ -93,3 +99,6 @@ def test_e2e_resonance_flow(qtbot) -> None: # type: ignore[no-untyped-def] assert rvm.result is not None assert abs(rvm.result.peak.f0 - 72_800.0) < 500.0 # recupera f0 del pico sintético assert not ws.panel("resonance_canvas").errored + ws.close() + ws.deleteLater() + qtbot.wait(0) diff --git a/tests/gui/test_spmproj.py b/tests/gui/test_spmproj.py index e684b4d..7867b4a 100644 --- a/tests/gui/test_spmproj.py +++ b/tests/gui/test_spmproj.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib from pathlib import Path import pytest @@ -19,6 +20,49 @@ def _force_sample() -> Path | None: return next(iter(_SAMPLES.glob("*.jpk-force")), None) +def test_spmproj_save_incluye_hash_sin_cargar_datos(qtbot, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + contenido = b"sesion-gui-spmkit" + archivo = tmp_path / "sesion.bin" + archivo.write_bytes(contenido) + proj = tmp_path / "sesion.spmproj" + ws = build_workspace() + qtbot.addWidget(ws) + vm = ws.panel("force_canvas")._vm + session = {"path": str(archivo), "kind": "force"} + monkeypatch.setattr( + QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(proj), "")) + ) + + _save_project(ws, vm, session) + + state = load_project(proj) + assert state.files[0].sha256 == hashlib.sha256(contenido).hexdigest() + + +def test_spmproj_save_sin_origen_conserva_archivo_sin_hash( + qtbot, tmp_path, monkeypatch +) -> None: # type: ignore[no-untyped-def] + origen = tmp_path / "origen_ausente.jpk-force" + proj = tmp_path / "sesion_sin_origen.spmproj" + ws = build_workspace() + qtbot.addWidget(ws) + vm = ws.panel("force_canvas")._vm + session = {"path": str(origen), "kind": "force"} + statuses: list[str] = [] + monkeypatch.setattr(ws, "show_status", statuses.append) + monkeypatch.setattr( + QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(proj), "")) + ) + + _save_project(ws, vm, session) + + state = load_project(proj) + assert state.files[0].path == str(origen) + assert state.files[0].kind == "force" + assert state.files[0].sha256 is None + assert "sin hash" in statuses[-1].lower() + + def test_spmproj_save_and_open(qtbot, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] sample = _force_sample() if sample is None: diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..5cd72c6 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,25 @@ +import tomllib +from importlib.metadata import version +from pathlib import Path + +import yaml + +import spmkit + +RAIZ = Path(__file__).parents[1] + + +def test_version_coincide_en_todas_las_fuentes() -> None: + proyecto = tomllib.loads((RAIZ / "pyproject.toml").read_text(encoding="utf-8")) + cita = yaml.safe_load((RAIZ / "CITATION.cff").read_text(encoding="utf-8")) + version_proyecto = proyecto["project"]["version"] + + assert version_proyecto == cita["version"] + assert version("spmkit") == version_proyecto + assert spmkit.__version__ == version_proyecto + + +def test_rango_de_python_soportado_es_exacto() -> None: + proyecto = tomllib.loads((RAIZ / "pyproject.toml").read_text(encoding="utf-8")) + + assert proyecto["project"]["requires-python"] == ">=3.11,<3.13"