From 7a0bf29260f52880959058df1615680a0f66758c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:33:05 +0000 Subject: [PATCH 1/2] irspec edit: peak add, remove, clear and auto on manifest.json Second slice of roadmap 1a. `add` snaps each wavenumber to the nearest local band and `auto` picks again, both through session's own peak code (ir_organize._finish), so the label heights are session's and `peak auto` on a fresh session changes nothing. `remove` takes out the stored peak nearest X within 15 cm-1, `clear` leaves none. Atomic, idempotent, old -> new, --json, exit 2 (cannot be carried out, including no spectrum to read) and 1. "peaks": [] now means no peaks in resolve_peaks (it was picked again), so clear survives a session re-run. Tests in tests/test_edit_peaks.py, mutation-checked; README, CHANGELOG (Unreleased) and roadmap updated. No version change. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01GqBTMN8XHvB3aG8T6PmfYS --- CHANGELOG.md | 12 +- README.md | 28 ++- docs/roadmap.md | 22 ++- src/irspec/cli.py | 2 +- src/irspec/edit.py | 174 +++++++++++++++++- src/irspec/ir_organize.py | 5 +- tests/test_edit_peaks.py | 358 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 575 insertions(+), 26 deletions(-) create mode 100644 tests/test_edit_peaks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c786b2..03a3419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,18 @@ irspec follows the rules in the README's "Versioning" section. Newest first. editor (keys: `title`, `axis`, `name`, `ymin`, `ymax`, `xmin`, `xmax`, `threshold`, `prominence`, `distance`). Atomic write, order and layout kept, idempotent, prints the old and new value (`--json`: one object), errors on stderr with exit 2 (1 for a missing manifest). - First slice of roadmap item 1a; peak editing and re-running the session are not in it, and + First slice of roadmap item 1a; re-running the session is not in it, and `xmin`, `xmax` and `ymax` are stored but not yet read by the figures. +- `irspec edit DIR FILE peak add X ...`, `peak remove X ...`, `peak clear` and `peak auto`: + second slice of roadmap item 1a. `add` snaps each wavenumber to the nearest local band + (`find_peaks_manual`, through the code `session` itself uses, so the label heights are + session's), `remove` takes out the stored peak nearest X within 15 cm-1, `clear` leaves none, + `auto` picks again with the entry's `threshold`/`prominence`/`distance` and replaces hand + edits, saying what it dropped. Same conventions as `set`/`unset` (atomic, idempotent, old -> + new, `--json`, exit 2/1); the spectrum is read as `session` reads it, exit 2 when there is none. +- `"peaks": []` in the manifest now means "no peaks" (it used to be picked again by `session`, + as if the key were missing), so a cleared or emptied list survives a re-run. Delete the key to + pick again, as before. ## 0.1.0 (2026-10-16), the first release diff --git a/README.md b/README.md index b4d516c..151317b 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ re-run: your edits stay, new files are appended, vanished files are dropped. |---|---| | `file` | path of the spectrum, relative to the folder (`SPA/…`, `jdx/…`, `img/…`) | | `title` | the caption under the figure, free text (`"Alcohol (stock)"` is fine). A new entry starts with the file's stem (an instrument spectrum), or with its compound name (`"METHYL_CINNAMATE_GAS"` gives "Methyl cinnamate gas"), or with the title inside the `.jdx`; change it here | -| `peaks` | `[wavenumber, label_y]` pairs, as picked; edit, add or delete here. A bare number is snapped to the nearest local band. Delete the key to pick again | +| `peaks` | `[wavenumber, label_y]` pairs, as picked; edit, add or delete here (or with `irspec edit … peak`). A bare number is snapped to the nearest local band. `[]` means no peaks. Delete the key to pick again | | `axis` | y-axis label (default `Transmittance %`) | | `ymin` | bottom of the y axis; the tool's suggestion is kept as it is, and may be negative for a strong band | | `threshold`, `prominence`, `distance` | peak-picking sensitivity overrides, for a band at the plot edge or noise taken for bands | @@ -226,6 +226,10 @@ folder afterwards to redraw (a re-run keeps what you set). ``` irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 set title="Phenacetin (crude)" ymin=-5 irspec edit my_lab_folder/ SPA/PHENACETIN_CRUDE_LR1-2.SPA unset ymin +irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak add 1720 3000 # snapped to the nearest band +irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak remove 3253 +irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak clear # no peaks at all +irspec edit my_lab_folder/ PHENACETIN_CRUDE_LR1-2 peak auto # pick again, drop hand edits irspec edit my_lab_folder/ show # every entry, as text irspec edit my_lab_folder/ show PHENACETIN_CRUDE_LR1-2 --json ``` @@ -236,8 +240,20 @@ irspec edit my_lab_folder/ show PHENACETIN_CRUDE_LR1-2 --json - **Which keys.** `set` and `unset` take `title`, `axis`, `name` (text) and `ymin`, `ymax`, `xmin`, `xmax`, `threshold`, `prominence`, `distance` (numbers, stored as JSON numbers; a value that is not a number is refused). Any other key is an error naming the allowed ones. - `xmin`, `xmax` and `ymax` are stored but the drawing code does not read them yet; `peaks` - still has to be edited by hand. + `xmin`, `xmax` and `ymax` are stored but the drawing code does not read them yet. +- **Peaks.** `peak add X ...` snaps each wavenumber to the nearest local band of that + spectrum (within 15 cm⁻¹) and adds it with the label height `session` gives a band named by + its wavenumber alone. `peak remove X ...` takes out the stored peak nearest X, if one is + within 15 cm⁻¹. `peak clear` leaves the figure with no peaks (`"peaks": []`). `peak auto` + picks again with the entry's `threshold`, `prominence` and `distance`, **replaces everything + stored, hand edits included**, and lists what it dropped. Each writes an explicit `peaks` + list, which `irspec session` keeps, so the redraw shows exactly these peaks. They start from + what the figure shows now (an entry with no `peaks` yet starts from the automatic list), a + peak already there or none near an X to remove changes nothing (a note goes to stderr, exit + 0), and the changes print as `peak: old -> new` (`--json`: `{"key": "peak", "old": null, + "new": [1732.7, 93.6]}` for an added peak, the other way round for a removed one). The + spectrum is read as `session` reads it (an own `.SPA` from the file, a reference spectrum + from `plain/NAME.csv`); with no spectrum to read, exit 2. - **What it writes.** The file is replaced atomically (a temporary file in the same folder, then a rename). Entries and keys keep their order, a new key goes last, and the file keeps its layout (the one `session` writes, else its own indentation, line endings and final @@ -395,10 +411,10 @@ Anything that depends on irspec before 1.0 should pin the minor version ## Roadmap -The first release is out (PyPI, public repository). Planned, in this order; of this, only `irspec edit set`, `unset` and `show` exist so far: +The first release is out (PyPI, public repository). Planned, in this order; of this, only `irspec edit set`, `unset`, `show` and `peak` exist so far: -- **Editing from the command line** (`irspec edit set/unset/show` are in; still to come: add and - remove peaks, rename/remove/move, re-running the session for the figure), +- **Editing from the command line** (`irspec edit set/unset/show/peak` are in; still to come: rename/remove/move, + re-running the session for the figure), `--json` output and a written description of the manifest, so scripts and AI agents can drive everything a person can; - **A preview in the terminal** (`irspec preview`, `session --preview`): the spectrum drawn in diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dafdbc..9f9e5d9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,14 +42,20 @@ moves only if the manifest or `figures.json` format breaks; adding optional keys ### 1a. Editing: `irspec edit` -**Done (Unreleased, `src/irspec/edit.py`, `tests/test_edit.py`):** `set KEY=VALUE ...`, -`unset KEY ...` and `show [FILE] [--json]` on `manifest.json` only, for the keys `title`, -`axis`, `name`, `ymin`, `ymax`, `xmin`, `xmax`, `threshold`, `prominence`, `distance`; atomic -write, order and layout kept, idempotent, `--json` for the changes. **Not done:** `peak -add/remove/auto/clear`, `rename`/`remove`/`move`, re-running the session for the figure -(`--no-build`), and the per-figure `xmin`/`xmax`/`ymax` in the drawing code (the keys can be -stored today, nothing reads them). The command listing below is the original design; the three -done commands are exactly as written there. +**Done (Unreleased, `src/irspec/edit.py`, `tests/test_edit.py`, `tests/test_edit_peaks.py`):** +`set KEY=VALUE ...`, `unset KEY ...` and `show [FILE] [--json]` on `manifest.json` only, for the +keys `title`, `axis`, `name`, `ymin`, `ymax`, `xmin`, `xmax`, `threshold`, `prominence`, +`distance`; and `peak add X ...`, `peak remove X ...`, `peak clear`, `peak auto`. Atomic write, +order and layout kept, idempotent, `--json` for the changes. The peak commands run `session`'s own +peak code (`ir_organize._finish`/`resolve_peaks`, hence `find_peaks_manual`, `find_peaks_auto`, +`assign_label_y`), so `peak auto` on a fresh session changes nothing and a following `session` +keeps the edit; `remove` matches within 15 cm-1 (the snapping window). To make `clear` survive a +re-run, `"peaks": []` now means "no peaks" in `resolve_peaks` (it used to be picked again). +**Not done:** `rename`/`remove`/`move`, re-running the session for the figure (`--no-build`), a +`--prominence` etc. override on `peak auto` (it uses the entry's keys; `set` them first), and the +per-figure `xmin`/`xmax`/`ymax` in the drawing code (the keys can be stored today, nothing reads +them). The command listing below is the original design; the done commands are exactly as written +there. **Decided.** Today the manifest is edited by hand in a text editor, which is the only "edit" there is. It becomes commands (names provisional; `irspec peaks` is already the peak picker for a diff --git a/src/irspec/cli.py b/src/irspec/cli.py index 7fb16be..a6b4230 100644 --- a/src/irspec/cli.py +++ b/src/irspec/cli.py @@ -3,7 +3,7 @@ irspec session DIR a folder of spectra -> CSVs, peaks, figure PDF irspec export PDF ... PNG/TIFF/SVG pictures from a figure PDF irspec plot DIR ... the figures drawn by matplotlib or R, no TeX needed - irspec edit DIR ... change or show manifest.json (set/unset a key, show) + irspec edit DIR ... change or show manifest.json (set/unset a key, peak add/remove, show) irspec spa ... OMNIC .SPA -> CSVs, peaks, legacy plots (spa_tool) irspec jdx ... JCAMP-DX -> CSVs (jdx_tool) irspec peaks ... peak-pick a .SPA, .jdx or CSV (spa_peaks) diff --git a/src/irspec/edit.py b/src/irspec/edit.py index 5cc966d..36a5d6a 100644 --- a/src/irspec/edit.py +++ b/src/irspec/edit.py @@ -2,6 +2,10 @@ irspec edit DIR FILE set KEY=VALUE [KEY=VALUE ...] irspec edit DIR FILE unset KEY [KEY ...] + irspec edit DIR FILE peak add X [X ...] + irspec edit DIR FILE peak remove X [X ...] + irspec edit DIR FILE peak clear + irspec edit DIR FILE peak auto irspec edit DIR show [FILE] [--json] DIR is a session folder (the one holding manifest.json). FILE names one @@ -17,6 +21,19 @@ xmin, xmax and ymax are stored for the figure code that will read them (roadmap 1a); today only ymin, title, axis, name and the three peak-picking numbers change a figure. +Peaks. `peak add X` snaps each X (a wavenumber) to the nearest local band of the file's own +spectrum with `find_peaks_manual`, and puts it in the entry's "peaks" with the label height +`irspec session` gives a band that is only named by its wavenumber. `peak remove X` takes out +the stored peak nearest X, if one is within PEAK_TOLERANCE cm-1. `peak clear` leaves the entry +with no peaks. `peak auto` picks again with the entry's threshold, prominence and distance +(`find_peaks_auto`, `assign_label_y`) and replaces whatever is stored, hand edits included. +Each of them writes an explicit "peaks" list, which `session` always keeps (an empty list +means no peaks). They are worked out from what `session` would draw now: an entry with no +"peaks" yet starts from the automatic list. The spectrum is read as `session` reads it: an own +.SPA entry from the .SPA (else from the CSV copies in plain/ or csv/), a reference spectrum +from its plain/ CSV; with none of them, exit 2. A peak already +there, or none near an X to remove, changes nothing and is not an error. + It changes manifest.json and nothing else; it does not re-run the session, so run `irspec session DIR` afterwards to redraw (a re-run keeps what is edited). The write is atomic (a temporary file in the same folder, then a rename), the @@ -26,12 +43,13 @@ that changes nothing does not touch the file, so running it twice leaves identical bytes. Nothing is ever asked: a question would be a flag. -Every `set`/`unset` prints what it changed; with --json it prints exactly one +Every `set`/`unset`/`peak` prints what it changed (a peak is {"key": "peak", "old": null, +"new": [wavenumber, label_y]} when added, the other way round when removed); with --json it prints exactly one JSON object on stdout, {"file": ..., "changes": [{"key", "old", "new"}, ...]} ("old" is null for a key that was not there, "new" is null after `unset`; a key whose value does not change is not listed). Errors go to stderr with a non-zero exit: 2 for a command that cannot be carried out (unknown key, a text -where a number belongs, no such entry), 1 when manifest.json is missing or +where a number belongs, no such entry, no spectrum to snap to), 1 when manifest.json is missing or unreadable. `show` prints one entry (or all of them) as text; with --json, the entry as a @@ -42,6 +60,7 @@ import math import os import re +import struct import sys import tempfile @@ -50,6 +69,9 @@ ALLOWED_KEYS = ("title", "axis", "ymin", "ymax", "xmin", "xmax", "threshold", "prominence", "distance", "name") +PEAK_ACTIONS = ("add", "remove", "clear", "auto") +PEAK_TOLERANCE = 15.0 # cm-1: the window find_peaks_manual snaps within, and `remove` matches within + _NUMBER = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$") _INT = re.compile(r"^[+-]?\d+$") @@ -260,6 +282,129 @@ def apply_unset(entry, keys): return changes +def parse_peak_args(items): + """['add', '1720', '3253'] -> ('add', [1720, 3253]), checked.""" + if not items: + raise EditError(f"peak needs an action: {', '.join(PEAK_ACTIONS)}") + action, rest = items[0], items[1:] + if action not in PEAK_ACTIONS: + raise EditError(f"unknown peak action {action!r}; use {', '.join(PEAK_ACTIONS)}") + if action in ("add", "remove"): + if not rest: + raise EditError(f"peak {action} needs at least one wavenumber") + xs = [] + for text in rest: + s = text.strip() + if not _NUMBER.match(s): + raise EditError(f"peak {action} needs wavenumbers (numbers), got {text!r}") + value = float(s) + if not math.isfinite(value): + raise EditError(f"peak {action} needs finite wavenumbers, got {text!r}") + xs.append(value) + return action, xs + if rest: + raise EditError(f"peak {action} takes no wavenumbers") + return action, [] + + +def load_spectrum(folder, entry): + """(x, y, axis, absorbance) of the entry as `irspec session` sees it: for an own .SPA entry + the .SPA itself (session reads that), else the plain/ CSV session wrote, else csv/; for a + reference spectrum the plain/ CSV. EditError (2) if there is none.""" + from . import ir_organize as _io + from . import session, spa_peaks, spa_tool + f = entry_file(entry) + if not f: + raise EditError("this manifest entry has no \"file\", so there is no spectrum to snap to") + if f.lower().endswith(_io.SPA_EXT): + stem = spa_tool.clean_stem(f) + plain = os.path.join(folder, "plain", f"{stem}_plain.csv") + archival = os.path.join(folder, "csv", f"{stem}.csv") + x = None + if os.path.exists(os.path.join(folder, f)): # what session reads, so a pick is the same pick + try: + x, y = spa_tool.read_spa(os.path.join(folder, f)) + except (OSError, ValueError, struct.error): + x = None + if x is not None: + pass + elif os.path.exists(plain): + x, y = session.read_plain(plain) + elif os.path.exists(archival): + x, y, _ = spa_peaks.read_house_csv(archival) + keep = x >= spa_tool.CLIP_CM # as the plain CSV is clipped + x, y = x[keep], y[keep] + else: + raise EditError(f"no spectrum for {f}: neither {os.path.relpath(plain, folder)} nor " + f"{os.path.relpath(archival, folder)} exists (run `irspec session` " + f"on the folder first)") + axis = entry.get("axis") or spa_tool.axis_of(y, x >= spa_tool.CLIP_CM) + return x, y, axis, axis.startswith("Absorbance") + plain = os.path.join(folder, "plain", f"{session.reference_name(folder, entry)}.csv") + if not os.path.exists(plain): + raise EditError(f"no spectrum for {f}: {os.path.relpath(plain, folder)} does not exist " + f"(run `irspec session` on the folder first)") + x, y = session.read_plain(plain) + return x, y, session.TRANSMITTANCE, False + + +def resolve_peaks(entry, spectrum, spec="keep"): + """The peaks `irspec session` would draw for `entry`, as sorted [x, label_y] pairs. + spec "keep": its own "peaks" (the automatic list if it has none); "auto": the automatic + list whatever is stored; else the "peaks" value to try instead. Runs session's own code + (ir_organize._finish), so snapping, label heights and rounding are session's.""" + from . import ir_organize as _io + x, y, axis, absorbance = spectrum + probe = dict(entry) + if spec == "auto": + probe.pop("peaks", None) + elif spec != "keep": + probe["peaks"] = spec + _io._finish(probe, "", x, y, axis, absorbance, "", "") + return [[px, ly] for px, ly in probe["peaks"]] + + +def _peak_changes(old, new): + """What differs between two peak lists, as `set`-style changes (pairs are compared by x).""" + near = lambda a, b: abs(a[0] - b[0]) < 0.05 and abs(a[1] - b[1]) < 0.05 # noqa: E731 + changes = [{"key": "peak", "old": p, "new": None} for p in old if not any(near(p, q) for q in new)] + changes += [{"key": "peak", "old": None, "new": q} for q in new if not any(near(p, q) for p in old)] + return sorted(changes, key=lambda c: (c["old"] or c["new"])[0]) + + +def apply_peaks(entry, folder, action, xs): + """Run a peak action on `entry`; returns the changes (the entry's "peaks" is set if any).""" + spectrum = load_spectrum(folder, entry) + current = resolve_peaks(entry, spectrum) + if action == "clear": + new = [] + elif action == "auto": + new = resolve_peaks(entry, spectrum, "auto") + elif action == "add": + new = list(current) + for wanted in xs: + hit = resolve_peaks(entry, spectrum, [wanted]) + if not hit: + raise EditError(f"no data within {PEAK_TOLERANCE:g} cm-1 of {wanted:g}, " + f"so there is no band to snap to") + if not any(abs(hit[0][0] - p[0]) < 0.05 for p in new): + new.append(hit[0]) + else: # remove + new = list(current) + for wanted in xs: + near = min(new, key=lambda p: abs(p[0] - wanted), default=None) + if near is not None and abs(near[0] - wanted) <= PEAK_TOLERANCE: + new.remove(near) + else: + print(f"irspec edit: no stored peak within {PEAK_TOLERANCE:g} cm-1 of {wanted:g}", + file=sys.stderr) + new.sort() + changes = _peak_changes(current, new) + if changes: + entry["peaks"] = new + return changes + + # ----------------------------------------------------------------------------- # output # ----------------------------------------------------------------------------- @@ -304,17 +449,22 @@ def format_changes(file, changes): # commands # ----------------------------------------------------------------------------- def edit(folder, name, verb, items): - """Run `set` or `unset` on the entry `name`. Returns (file, changes); writes only if needed.""" + """Run `set`, `unset` or `peak` on the entry `name`. Returns (file, changes); writes only if needed.""" if verb == "set": plan = parse_assignments(items) # checked before the manifest is even read elif verb == "unset": plan = parse_unset_keys(items) + elif verb == "peak": + plan = parse_peak_args(items) else: - raise EditError(f"unknown action {verb!r}; use set or unset (or: irspec edit DIR show)") + raise EditError(f"unknown action {verb!r}; use set, unset or peak (or: irspec edit DIR show)") entries, text = read_manifest(folder) i = find_entry(entries, name) entry = entries[i] - changes = apply_set(entry, plan) if verb == "set" else apply_unset(entry, plan) + if verb == "peak": + changes = apply_peaks(entry, folder, *plan) + else: + changes = apply_set(entry, plan) if verb == "set" else apply_unset(entry, plan) if changes: write_atomic(manifest_path(folder), serialize(entries, text)) return entry_file(entry), changes @@ -331,15 +481,19 @@ def build_parser(): prog="irspec edit", usage="irspec edit DIR FILE set KEY=VALUE [KEY=VALUE ...]\n" " irspec edit DIR FILE unset KEY [KEY ...]\n" + " irspec edit DIR FILE peak add|remove X [X ...]\n" + " irspec edit DIR FILE peak clear|auto\n" " irspec edit DIR show [FILE] [--json]", description="Change or show a session's manifest.json. Keys: " + ", ".join(ALLOWED_KEYS) + ". FILE is an entry's \"file\" value, or its file name or stem if that is " - "unambiguous. Changes manifest.json only; run `irspec session DIR` to redraw.", + "unambiguous. `peak` snaps, removes, clears or re-picks the entry's peaks (`peak auto` " + "replaces hand edits). Changes manifest.json only; run `irspec session DIR` to redraw.", epilog="Exit status: 0 done; 2 the command cannot be carried out; 1 manifest.json missing " "or unreadable.") p.add_argument("dir", metavar="DIR", help="the session folder (holds manifest.json)") p.add_argument("rest", nargs="*", metavar="FILE ACTION ...", - help="FILE set KEY=VALUE ... | FILE unset KEY ... | show [FILE]") + help="FILE set KEY=VALUE ... | FILE unset KEY ... | " + "FILE peak add|remove X ... | FILE peak clear|auto | show [FILE]") p.add_argument("--json", action="store_true", help="print one JSON object (set/unset) or the entries (show) on stdout") return p @@ -371,12 +525,16 @@ def main(argv=None): print("(no entries)") return 0 if len(rest) < 2: - raise EditError("expected: irspec edit DIR FILE set|unset ... or irspec edit DIR show [FILE]") + raise EditError("expected: irspec edit DIR FILE set|unset|peak ... or irspec edit DIR show [FILE]") file, changes = edit(args.dir, rest[0], rest[1], rest[2:]) if args.json: print(json.dumps({"file": file, "changes": changes}, ensure_ascii=False)) else: print(format_changes(file, changes)) + if rest[1] == "peak" and rest[2:3] == ["auto"]: + dropped = [str(c["old"][0]) for c in changes if c["new"] is None] + print("peak auto replaces the stored peaks, hand edits included" + + (f"; dropped: {', '.join(dropped)}" if dropped else "")) return 0 except EditError as e: print(f"irspec edit: {e}", file=sys.stderr) diff --git a/src/irspec/ir_organize.py b/src/irspec/ir_organize.py index ec6ae19..e1a3e1b 100644 --- a/src/irspec/ir_organize.py +++ b/src/irspec/ir_organize.py @@ -177,11 +177,12 @@ def rel(p): def resolve_peaks(x, y, e, axis, absorbance, auto_kwargs, gap): """Peaks for one spectrum as [(x, y, label_y)]. Manifest "peaks" wins: [x, label_y] pairs are used verbatim, bare numbers are snapped to the - nearest local band; otherwise auto-pick. Also (re)writes e["peaks"].""" + nearest local band; an empty list means none; no "peaks" key means auto-pick. + Also (re)writes e["peaks"].""" spec = e.get("peaks") if isinstance(spec, str): # legacy comma string spec = [float(v) for v in spec.split(",") if v.strip()] - if spec: + if spec or isinstance(spec, list): # [] is "no peaks" (irspec edit peak clear), not "pick" sign = 1.0 if absorbance else -1.0 bare = [float(v) for v in spec if not isinstance(v, (list, tuple))] snapped = {} diff --git a/tests/test_edit_peaks.py b/tests/test_edit_peaks.py new file mode 100644 index 0000000..1257bf9 --- /dev/null +++ b/tests/test_edit_peaks.py @@ -0,0 +1,358 @@ +"""irspec edit ... peak add/remove/clear/auto: the peaks of a manifest entry.""" +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "src")) +from irspec import edit, ir_organize, spa_tool # noqa: E402 + +DATA = ROOT / "tests" / "data" +SAMPLE = "PHENACETIN_CRUDE_LR1-2.SPA" +SPA = "SPA/" + SAMPLE +STEM = "PHENACETIN_CRUDE_LR1-2" +ENV = dict(os.environ, PYTHONPATH=str(ROOT / "src")) + + +def irspec(*args): + r = subprocess.run([sys.executable, "-m", "irspec", *map(str, args)], + capture_output=True, text=True, env=ENV, encoding="utf-8") + return r.returncode, r.stdout, r.stderr + + +def run(*args): + return irspec("edit", *args) + + +class Base(unittest.TestCase): + """A session folder made once by `irspec session --build none` on the sample .SPA, + copied afresh for each test.""" + + @classmethod + def setUpClass(cls): + cls._tmp = tempfile.TemporaryDirectory() + cls.template = Path(cls._tmp.name) / "template" + (cls.template / "SPA").mkdir(parents=True) + shutil.copy(DATA / SAMPLE, cls.template / "SPA" / SAMPLE) + code, out, err = irspec("session", cls.template, "--build", "none") + assert code == 0, err + cls.fresh = (cls.template / "manifest.json").read_bytes() + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + + def setUp(self): + self._work = tempfile.TemporaryDirectory() + self.addCleanup(self._work.cleanup) + self.dir = Path(self._work.name) / "session" + shutil.copytree(self.template, self.dir) + + @property + def mpath(self): + return self.dir / "manifest.json" + + def entry(self): + return json.loads(self.mpath.read_text(encoding="utf-8"))[0] + + def peaks(self): + return self.entry()["peaks"] + + def xs(self): + return [p[0] for p in self.peaks()] + + def write_entry(self, **changes): + entries = json.loads(self.mpath.read_text(encoding="utf-8")) + for k, v in changes.items(): + if v is None: + entries[0].pop(k, None) + else: + entries[0][k] = v + ir_organize.save_manifest(str(self.dir), entries) + + def peak(self, *args): + return run(self.dir, STEM, "peak", *args) + + +class Add(Base): + def test_snaps_to_the_nearest_local_band_and_keeps_the_list_sorted(self): + x, y = spa_tool.read_spa(DATA / SAMPLE) + ((bx, by),) = spa_tool.find_peaks_manual(x, y, [1720], "Transmittance %") + before = self.peaks() + code, out, err = self.peak("add", "1720") + self.assertEqual((code, err), (0, "")) + self.assertIn(f"{round(bx, 1)}, {round(by - 5.0, 1)}", out) + after = self.peaks() + self.assertEqual(after, sorted(before + [[round(bx, 1), round(by - 5.0, 1)]])) + self.assertEqual(len(after), len(before) + 1) + + def test_label_height_is_the_one_session_gives_a_bare_wavenumber(self): + self.peak("add", "1720", "3000") + edited = self.peaks() + # the same bare numbers in the manifest, resolved by session itself + self.write_entry(peaks=[1720, 3000]) + self.assertEqual(irspec("session", self.dir, "--build", "none")[0], 0) + by_session = self.peaks() + self.assertEqual(len(by_session), 2) + self.assertTrue(all(p in edited for p in by_session)) + + def test_several_at_once_and_a_second_run_changes_nothing(self): + code, out, _ = self.peak("add", "1720", "3000", "2850") + self.assertEqual(code, 0) + self.assertEqual(out.count("(not set) ->"), 2) # 2850 is already a band (2848.3) + once = self.mpath.read_bytes() + code, out, _ = self.peak("add", "1720", "3000", "2850") + self.assertEqual((code, out.strip()), (0, f"{SPA}: no change")) + self.assertEqual(self.mpath.read_bytes(), once) + + def test_a_band_already_there_changes_nothing(self): + code, out, _ = self.peak("add", "740") + self.assertEqual((code, out.strip()), (0, f"{SPA}: no change")) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_a_band_with_a_staggered_label_is_not_added_twice(self): + stored = self.peaks() + self.assertTrue(any(abs(ly - (self.trough(x) - 5.0)) > 0.06 for x, ly in stored)) + code, out, _ = self.peak("add", *[str(x) for x, _ in stored], "1720") + self.assertEqual(code, 0) + after = self.peaks() + self.assertEqual(len(after), len(stored) + 1) + self.assertEqual(len(set(self.xs())), len(after)) + self.assertTrue(all(p in after for p in stored)) # their labels are untouched + + def trough(self, wavenumber): + x, y = spa_tool.read_spa(DATA / SAMPLE) + return float(y[abs(x - wavenumber).argmin()]) + + def test_no_peaks_key_starts_from_what_session_would_pick(self): + self.write_entry(peaks=None) + n = len(json.loads(self.fresh)[0]["peaks"]) + self.peak("add", "1720") + self.assertEqual(len(self.peaks()), n + 1) + + def test_out_of_the_data_is_exit_2_and_nothing_is_written(self): + code, out, err = self.peak("add", "1720", "99999") + self.assertEqual((code, out), (2, "")) + self.assertIn("99999", err) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_json_is_one_object(self): + code, out, _ = self.peak("add", "1720", "--json") + self.assertEqual(code, 0) + obj = json.loads(out) + self.assertEqual(obj["file"], SPA) + (c,) = obj["changes"] + self.assertEqual((c["key"], c["old"]), ("peak", None)) + self.assertEqual(len(c["new"]), 2) + self.assertEqual(out.count("\n"), 1) + + def test_other_keys_order_and_layout_are_kept(self): + self.write_entry(ymin=-7, note="keep me") + before = self.entry() + self.peak("add", "1720") + after = self.entry() + self.assertEqual(list(after), list(before)) + self.assertEqual({k: v for k, v in after.items() if k != "peaks"}, + {k: v for k, v in before.items() if k != "peaks"}) + self.assertIn(' [740.5, 52.3],\n', self.mpath.read_text(encoding="utf-8")) + + +class Remove(Base): + def test_removes_the_nearest_stored_peak(self): + code, out, _ = self.peak("remove", "830") # 825.4 is 4.6 away, 837.0 is 7.0 + self.assertEqual(code, 0) + self.assertNotIn(825.4, self.xs()) + self.assertIn(837.0, self.xs()) + self.assertIn("[825.4, 39.4] -> (removed)", out) + self.assertEqual(len(self.peaks()), len(json.loads(self.fresh)[0]["peaks"]) - 1) + + def test_several_and_a_second_run_changes_nothing(self): + self.peak("remove", "740", "1242", "3250") + self.assertTrue({740.5, 1241.9, 3253.3}.isdisjoint(self.xs())) + once = self.mpath.read_bytes() + code, out, err = self.peak("remove", "740", "1242", "3250") + self.assertEqual((code, out.strip()), (0, f"{SPA}: no change")) + self.assertEqual(self.mpath.read_bytes(), once) + + def test_nothing_within_the_tolerance_is_a_note_not_an_error(self): + code, out, err = self.peak("remove", "2400") # nearest is 2018.1 or 2848.3 + self.assertEqual((code, out.strip()), (0, f"{SPA}: no change")) + self.assertIn("2400", err) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_tolerance_is_the_snapping_window(self): + self.assertEqual(edit.PEAK_TOLERANCE, 15.0) + self.peak("remove", "3268") # 14.7 from 3253.3 + self.assertNotIn(3253.3, self.xs()) + self.peak("remove", "3330") # nothing left near + self.assertEqual(len(self.peaks()), len(json.loads(self.fresh)[0]["peaks"]) - 1) + + def test_json_lists_the_removed_pair(self): + code, out, _ = self.peak("remove", "3253", "--json") + self.assertEqual(json.loads(out)["changes"], + [{"key": "peak", "old": [3253.3, 75.3], "new": None}]) + + def test_add_then_remove_gives_the_manifest_back(self): + self.peak("add", "1720") + self.peak("remove", "1720") + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + +class Clear(Base): + def test_clear_writes_an_empty_list_and_is_idempotent(self): + code, out, _ = self.peak("clear") + self.assertEqual(code, 0) + self.assertEqual(self.peaks(), []) + self.assertIn('"peaks": []', self.mpath.read_text(encoding="utf-8")) + once = self.mpath.read_bytes() + code, out, _ = self.peak("clear") + self.assertEqual((code, out.strip()), (0, f"{SPA}: no change")) + self.assertEqual(self.mpath.read_bytes(), once) + + def test_add_after_clear_gives_just_that_peak(self): + self.peak("clear") + self.peak("add", "1720") + self.assertEqual(len(self.peaks()), 1) + + def test_removing_the_last_peak_leaves_none(self): + self.write_entry(peaks=[[1715.0, 90.0]]) + self.peak("remove", "1715") + self.assertEqual(self.peaks(), []) + + +class Auto(Base): + def test_on_a_fresh_session_it_is_what_session_picked(self): + code, out, _ = self.peak("auto") + self.assertEqual((code, out.splitlines()[0]), (0, f"{SPA}: no change")) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_replaces_hand_edits_and_says_so(self): + self.peak("remove", "740", "3253") + self.peak("add", "1720") + code, out, _ = self.peak("auto") + self.assertEqual(code, 0) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + self.assertIn("replaces the stored peaks, hand edits included", out) + self.assertIn("dropped: 1732.7", out) + self.assertIn("(not set) -> [740.5, 52.3]", out) + + def test_follows_the_entrys_own_sensitivity(self): + n = len(self.peaks()) + run(self.dir, STEM, "set", "prominence=10", "threshold=20") + self.peak("auto") + self.assertLess(len(self.peaks()), n) + self.assertEqual(run(self.dir, STEM, "set", "prominence=10")[2], "") + self.assertEqual(self.peak("auto")[1].strip(), + f"{SPA}: no change\npeak auto replaces the stored peaks, hand edits included") + + def test_a_second_run_changes_nothing(self): + self.peak("clear") + self.peak("auto") + once = self.mpath.read_bytes() + self.peak("auto") + self.assertEqual(self.mpath.read_bytes(), once) + + def test_absorbance_peaks_point_up_and_labels_go_above(self): + run(self.dir, STEM, "set", "axis=Absorbance") + self.write_entry(peaks=None) # session picks again, as absorbance this time + self.assertEqual(irspec("session", self.dir, "--build", "none")[0], 0) + by_session = self.mpath.read_bytes() + self.assertNotEqual(by_session, self.fresh) + self.peak("clear") + self.peak("auto") + self.assertEqual(self.mpath.read_bytes(), by_session) + code, out, _ = self.peak("add", "1720", "--json") + ((x_, ly),) = [c["new"] for c in json.loads(out)["changes"]] + x, y = spa_tool.read_spa(DATA / SAMPLE) + self.assertGreater(ly, float(y[abs(x - x_).argmin()])) + + +class Arguments(Base): + def test_bad_commands_are_exit_2_and_leave_the_file_alone(self): + for args in (("add",), ("add", "abc"), ("remove",), ("remove", "1e999"), ("clear", "5"), + ("auto", "5"), ("rename", "1"), ()): + with self.subTest(args=args): + code, out, err = self.peak(*args) + self.assertEqual((code, out), (2, "")) + self.assertTrue(err.startswith("irspec edit:"), err) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_unknown_file_is_exit_2(self): + code, _, err = run(self.dir, "NOPE", "peak", "clear") + self.assertEqual(code, 2) + self.assertIn("NOPE", err) + + def test_missing_manifest_is_exit_1(self): + self.mpath.unlink() + self.assertEqual(self.peak("clear")[0], 1) + + def test_no_spectrum_is_exit_2_and_names_what_is_missing(self): + shutil.rmtree(self.dir / "SPA") + shutil.rmtree(self.dir / "plain") + shutil.rmtree(self.dir / "csv") + code, out, err = self.peak("add", "1720") + self.assertEqual((code, out), (2, "")) + self.assertIn("plain", err) + self.assertEqual(self.mpath.read_bytes(), self.fresh) + + def test_without_the_spa_the_copies_are_read(self): + shutil.rmtree(self.dir / "SPA") + self.assertEqual(self.peak("add", "1720")[0], 0) + shutil.rmtree(self.dir / "plain") # csv/ is left + self.assertEqual(self.peak("add", "3000")[0], 0) + self.assertEqual(self.peak("add", "600")[0], 2) # csv/ holds the whole range, plain/ stops at 650 + self.assertEqual(len(self.peaks()), len(json.loads(self.fresh)[0]["peaks"]) + 2) + + def test_a_reference_spectrum_is_read_from_its_plain_csv(self): + ref = Path(self._work.name) / "ref" + (ref / "jdx").mkdir(parents=True) + shutil.copy(DATA / "phenacetin_T.jdx", ref / "jdx" / "62-44-2-IR.jdx") + self.assertEqual(irspec("session", ref, "--build", "none")[0], 0) + name = json.loads((ref / "manifest.json").read_text(encoding="utf-8"))[0]["name"] + fresh = (ref / "manifest.json").read_bytes() + code, out, err = run(ref, "62-44-2-IR", "peak", "auto") + self.assertEqual((code, err), (0, "")) + self.assertEqual((ref / "manifest.json").read_bytes(), fresh) + self.assertEqual(run(ref, "62-44-2-IR", "peak", "add", "1720")[0], 0) + os.unlink(ref / "plain" / f"{name}.csv") + code, out, err = run(ref, "62-44-2-IR", "peak", "clear") + self.assertEqual((code, out), (2, "")) + self.assertIn(f"{name}.csv", err) + + +class SessionKeepsTheEdit(Base): + def test_session_after_edits_keeps_them_and_figures_json_has_them(self): + self.peak("remove", "740", "3253") + self.peak("add", "1720", "3000") + edited = self.peaks() + manifest = self.mpath.read_bytes() + code, out, err = irspec("session", self.dir, "--build", "none") + self.assertEqual(code, 0, err) + self.assertEqual(self.mpath.read_bytes(), manifest) + figures = json.loads((self.dir / "figures.json").read_text(encoding="utf-8")) + figure = figures["figures"][0] if isinstance(figures, dict) else figures[0] + self.assertEqual([list(p) for p in figure["peaks"]], edited) + self.assertNotIn(740.5, [p[0] for p in figure["peaks"]]) + md = next(self.dir.glob("*.md")).read_text(encoding="utf-8") + label = next(p[1] for p in edited if p[0] == 1732.7) + self.assertIn(f"1732.7/{label}", md.replace(" ", "")) + self.assertNotIn("740.5/", md) + + def test_session_after_clear_draws_no_peaks(self): + self.peak("clear") + code, out, err = irspec("session", self.dir, "--build", "none") + self.assertEqual(code, 0, err) + self.assertEqual(self.peaks(), []) + figures = json.loads((self.dir / "figures.json").read_text(encoding="utf-8")) + figure = figures["figures"][0] if isinstance(figures, dict) else figures[0] + self.assertEqual(figure["peaks"], []) + self.assertIn("peaks=0", out) + + +if __name__ == "__main__": + unittest.main() From 78c424adc878ef1ae95feb5281d081913b7de643 Mon Sep 17 00:00:00 2001 From: Ali Perdekhan <327986462+aliperdehan@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:41:46 +0500 Subject: [PATCH 2/2] tests: the session-after-edit test reads ir_session.md, not the first .md found The session folder is copied from a template, so a stale ir_template.md sat beside the new ir_session.md and glob picked the stale one. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_edit_peaks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_edit_peaks.py b/tests/test_edit_peaks.py index 1257bf9..9d99ba5 100644 --- a/tests/test_edit_peaks.py +++ b/tests/test_edit_peaks.py @@ -338,7 +338,7 @@ def test_session_after_edits_keeps_them_and_figures_json_has_them(self): figure = figures["figures"][0] if isinstance(figures, dict) else figures[0] self.assertEqual([list(p) for p in figure["peaks"]], edited) self.assertNotIn(740.5, [p[0] for p in figure["peaks"]]) - md = next(self.dir.glob("*.md")).read_text(encoding="utf-8") + md = (self.dir / "ir_session.md").read_text(encoding="utf-8") label = next(p[1] for p in edited if p[0] == 1732.7) self.assertIn(f"1732.7/{label}", md.replace(" ", "")) self.assertNotIn("740.5/", md)