Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a3f4663
test: verificar valores exactos de Sa y Sq
kegouro Jul 13, 2026
aa0639e
test: verify roughness CSV and JSON round trip
kegouro Jul 13, 2026
84f9ae0
test(gui): aislar recursos Qt entre flujos E2E
kegouro Jul 13, 2026
fd64cbc
test: verifica plane_fit no destructivo
kegouro Jul 13, 2026
09f2efc
feat: añade SHA-256 a proyectos spmproj
kegouro Jul 13, 2026
d710ae6
Añade selección inequívoca al CLI de imagen
kegouro Jul 13, 2026
3864084
feat(cli): añadir exportación de perfiles
kegouro Jul 13, 2026
7940d2f
fix: modernize isinstance type checks
kegouro Jul 13, 2026
adb805b
test(e2e): aislar identidades de journeys
kegouro Jul 13, 2026
dca86b2
test(e2e): caracteriza journey GUI de imagen
kegouro Jul 13, 2026
90856d3
test: estabiliza salida Rich en CI
kegouro Jul 13, 2026
67d8cb6
build: excluye GUI legacy de artefactos
kegouro Jul 13, 2026
28763b2
chore: unblock full mypy check
kegouro Jul 13, 2026
e982130
Revert "build: excluye GUI legacy de artefactos"
kegouro Aug 4, 2026
9199a36
Revert "chore: unblock full mypy check"
kegouro Aug 4, 2026
9e993a4
chore(audit): remove codex internal .superpowers report pulled in by …
kegouro Aug 4, 2026
8cd46a2
fix(analysis): validate profile line boundaries (extracted from codex…
kegouro Aug 4, 2026
607f425
style: apply Black formatting to recovered branch files
kegouro Aug 5, 2026
06bbd82
test: guard e2e GUI journey from collection without PyQt6
kegouro Aug 5, 2026
2e6b91b
docs: sync CLI command count and index for the new profile command
kegouro Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/manual/artifacts-manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"artifacts": [
{
"bytes": 56482,
"bytes": 56542,
"path": "docs/user-guide.md",
"sha256": "2af676b1a962202ec7c5424a95d7477a6b99621c88ad433720cff46b2e117a43"
"sha256": "ecc0da824cd3886aa59ec6322f3b27ad15168bd5203fe4b191ce108e8fa2fb3e"
},
{
"bytes": 39958,
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,7 @@ spmkit gui --legacy
|---------|----------|-------|
| `info` | Inspection | `.nid`, `.nhf` |
| `roughness` | Image analysis | `.nid`, `.nhf`, `.gwy` |
| `profile` | Profile extraction | `.nid`, `.nhf`, `.gwy` |
| `psd` | Spectral | `.nid`, `.nhf`, `.gwy` |
| `analyze` | Pipeline | `.nid`, `.nhf`, `.gwy` |
| `nanomech` | Force | `.nid` (spectroscopy) |
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_docs_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def main() -> int:
)

commands = cli_commands()
checks.require(len(commands) == 19, "source declares 19 CLI commands")
checks.require(len(commands) == 20, "source declares 20 CLI commands")
markdown_manual = text(DOCS / "user-guide.md")
checks.require(
all(f"`{command}`" in markdown_manual for command in commands),
Expand Down
2 changes: 1 addition & 1 deletion scripts/run_gui_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 97 additions & 19 deletions src/spmkit/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

from __future__ import annotations

from enum import StrEnum
from pathlib import Path

import typer
from rich.console import Console
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(
Expand All @@ -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__}")
Expand All @@ -47,19 +74,21 @@ 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")
for ch in data.channels:
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",
Expand All @@ -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})")
Expand All @@ -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)
Expand All @@ -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"
),
Expand All @@ -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')}")
Expand Down Expand Up @@ -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})"
)
Expand Down Expand Up @@ -454,14 +530,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]
Expand Down
13 changes: 13 additions & 0 deletions src/spmkit/core/analysis/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/spmkit/core/export/writers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions src/spmkit/core/models/spmdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
19 changes: 17 additions & 2 deletions src/spmkit/core/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -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
Expand All @@ -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,
}

Expand All @@ -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")
]
Expand Down
Loading
Loading