From 5e971e350d34002eaf17cb9cea392958cf361f5d Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Mon, 9 Mar 2026 00:16:14 +0000 Subject: [PATCH] docs: Restructure usage section into workflow-based pages and TUI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace interface-centric usage pages (cli.md, shell.md, tui.md, gui.md) with workflow-step pages covering the full processing pipeline: project setup, adding data, event selection, initial inspection, ICCS stack, ICCS alignment (including interactive picking), snapshots, and MCCC finalisation. TUI changes: - Gate Align/Tools/Parameters/New Snapshot bindings on event selection rather than active tab - Add NoProjectModal on startup when no project exists (create or quit) - Distinguish "no data" from "no event selected" in the event bar - Fix mypy: NoProjectModal callback accepts bool | None - Update test_event_bar_shows_no_event_message → test_event_bar_shows_no_data_message --- .gitignore | 2 + CHANGELOG.md | 1 + docs/usage/alignment.md | 220 ++++++++++++++ docs/usage/api.md | 8 +- docs/usage/cli.md | 116 -------- docs/usage/data.md | 219 ++++++++++++++ docs/usage/event-selection.md | 76 +++++ docs/usage/gui.md | 1 - docs/usage/iccs-stack.md | 145 +++++++++ docs/usage/index.md | 273 +++++++++++++++-- docs/usage/inspection.md | 118 ++++++++ docs/usage/mccc.md | 117 ++++++++ docs/usage/project.md | 106 +++++++ docs/usage/shell.md | 79 ----- docs/usage/snapshots.md | 189 ++++++++++++ docs/usage/tui.md | 180 ----------- src/aimbat/_tui/aimbat.tcss | 78 +++++ src/aimbat/_tui/app.py | 544 +++++++++++++++++++++------------- src/aimbat/_tui/modals.py | 286 +++++++++++++++++- tests/functional/test_tui.py | 42 +-- uv.lock | 12 +- zensical.toml | 12 +- 22 files changed, 2167 insertions(+), 657 deletions(-) create mode 100644 docs/usage/alignment.md delete mode 100644 docs/usage/cli.md create mode 100644 docs/usage/data.md create mode 100644 docs/usage/event-selection.md delete mode 100644 docs/usage/gui.md create mode 100644 docs/usage/iccs-stack.md create mode 100644 docs/usage/inspection.md create mode 100644 docs/usage/mccc.md create mode 100644 docs/usage/project.md delete mode 100644 docs/usage/shell.md create mode 100644 docs/usage/snapshots.md delete mode 100644 docs/usage/tui.md diff --git a/.gitignore b/.gitignore index 5326b7a..02bf07c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ GEMINI.md CLAUDE.md .claude/settings.local.json scratch/ +aimbat.db-shm +aimbat.db-wal diff --git a/CHANGELOG.md b/CHANGELOG.md index e12f4ca..2c62545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ All notable changes to the **AIMBAT** project will be documented in this file. - **(core)** Re-arange core, move set_default_event and friends out of core. - Active event -> default event - Use default event concept only for cli commands +- Update plot seismograms. ### 🚀 New Features diff --git a/docs/usage/alignment.md b/docs/usage/alignment.md new file mode 100644 index 0000000..3057694 --- /dev/null +++ b/docs/usage/alignment.md @@ -0,0 +1,220 @@ +# Aligning with ICCS + +## The process + +ICCS alignment is inherently exploratory. There is no fixed sequence of steps +that works for every dataset — it is a feedback loop between adjusting +parameters, running the algorithm, and examining the results. The goal is a +stack that is coherent across the array and CC norms that are high enough to +give MCCC a clean dataset to work with. + +Parameters interact: a filter that sharpens the waveform may allow a narrower +time window, which in turn changes which seismograms align well. It is +generally best to change one thing at a time and observe the effect before +making further adjustments. + +--- + +## Running ICCS + +=== "CLI / Shell" + + ```bash + aimbat align iccs # basic run + aimbat align iccs --autoflip # flip inverted polarity automatically + aimbat align iccs --autoselect # deselect poor-quality seismograms automatically + aimbat align iccs --autoflip --autoselect # both + ``` + +=== "TUI" + + Press `a` to open the alignment menu and choose **ICCS**. Before running, + toggle **Autoflip** (`f`) and **Autoselect** (`s`) as needed. + +=== "GUI" + + Use the **Run ICCS** button in the **Processing** tab. Autoflip and + autoselect can be toggled before running. + +After each run, inspect the stack and matrix image to assess alignment quality +before deciding what to change next. + +--- + +## Parameters + +All parameters are per-event and can be adjusted at any time. Changes take +effect on the next ICCS run. + +### Time window + +`window_pre` and `window_post` define how much of the seismogram — before and +after the pick — is used in the cross-correlation. Since the pick aims to sit +at the **onset** of the target phase (the first coherent ground motion), the +window effectively starts a little before the onset and extends through the +arrival. Keeping `window_pre` short and placing the onset near the beginning +of the window tends to work well, as it limits how much noise before the +arrival is included. The window should be narrow enough that it is dominated +by the target phase rather than noise or later arrivals. + +A good starting point is a window that visually frames the onset in the stack +plot. Narrowing it once initial alignment is reasonable often improves +precision. + +### Bandpass filter + +`bandpass_apply`, `bandpass_fmin`, and `bandpass_fmax` control an optional +bandpass filter applied before cross-correlation. Filtering can dramatically +improve alignment on noisy data by suppressing frequencies where the signal +is weak, but the right frequency range depends on the event and the array. + +Filtering is off by default. When enabled, the same filter is applied to +both the seismograms and the stack, so the cross-correlation is always +comparing like with like. + +### The phase pick (t1) + +`t1` is the per-seismogram pick that ICCS refines during each run — every +seismogram gets its own value, reflecting how much it needs to be shifted +relative to the stack. When adjusted interactively from the stack plot, +however, the same shift is applied to all seismograms simultaneously. This +makes interactive picking a coarse, global adjustment — useful for moving +the entire array onto the onset — while ICCS handles the fine, per-seismogram +refinement. + +### Minimum CC norm + +`min_ccnorm` is the threshold used by autoselect to deselect seismograms +automatically. It does not affect the cross-correlation itself — only which +seismograms are excluded from contributing to the stack in subsequent +iterations. + +Setting this too high early on may exclude seismograms that would align well +once the stack improves. It is usually more effective to start with a +permissive threshold and tighten it as alignment converges. + +--- + +## Interactive adjustment + +In addition to setting parameters directly, three tools let you adjust values +by interacting with the plot — clicking or scrolling in a waveform display +rather than typing numbers. + +=== "CLI / Shell" + + ```bash + aimbat pick phase # adjust t1 by clicking on the stack + aimbat pick window # set window_pre / window_post by clicking + aimbat pick ccnorm # set min_ccnorm by scrolling the matrix image + ``` + + Each command opens a matplotlib window. Click (or scroll, for ccnorm) to + set the value, then close the window to save it. + + All three accept `--no-context` and `--all` (include deselected + seismograms). + +=== "TUI" + + Press `t` to open the **Tools** menu, then choose from: + + - **Phase arrival (t1)** — click in the stack to shift all picks globally + - **Time window** — click to place the window boundaries + - **Min CC norm** — scroll the matrix image to set the threshold + + Before launching, toggle **Context** (`c`) and **All seismograms** (`a`) + as needed. The TUI suspends while the matplotlib window is open and + resumes when you close it. + +The pick and window tools open the **stack view**; the CC norm tool opens the +**matrix image**. The behaviour of each is described in [The ICCS +Stack](iccs-stack.md#use-in-interactive-adjustment). + +--- + +## Running modes + +### Basic + +Running without autoflip or autoselect leaves all decisions about which +seismograms to include and whether to flip them up to the user. The stack and +matrix views show the full result, and you can manually toggle `select` and +`flip` on individual seismograms from the seismogram list. + +### Autoflip + +Depending on the focal mechanism and a station's azimuth and take-off angle, +some stations may record the target phase with opposite polarity to the rest +of the array. These seismograms contribute destructively to the stack, +degrading alignment for everything else. The `flip` flag multiplies a +seismogram's data by −1 before it enters the stack and cross-correlation, +correcting for this. With autoflip enabled, ICCS detects seismograms whose +maximum absolute cross-correlation with the stack is negative and +automatically toggles their `flip` parameter. + +Autoflip can be run once early on to correct polarity issues, or left enabled +throughout. It is safe to run repeatedly. + +### Autoselect + +With autoselect enabled, seismograms whose CC norm falls below `min_ccnorm` +are automatically set to `select = False` and excluded from the stack in +subsequent iterations. Importantly, they are still cross-correlated against +the stack — so if parameters improve and they start to align better, they can +be re-selected automatically in a later run. + +This means autoselect is not permanent. A seismogram deselected at an early +stage may recover as parameters improve — and narrowing the time window tends +to increase CC norms across the board, which can bring previously deselected +seismograms back above the threshold even without any change in alignment +quality. This is worth keeping in mind when interpreting CC norms after a +window adjustment. + +--- + +## Convergence + +Within a single run, ICCS iterates — rebuilding the stack and re-correlating +after each pass — until the stack stops changing meaningfully between iterations +or a maximum number of iterations is reached. Convergence is assessed by +comparing the current stack to the previous one: either by their correlation +coefficient, or by the normalised change in stack shape. This happens +automatically; there is no need to monitor it. Running ICCS again from +AIMBAT's interface always starts a fresh run from the current picks. + +What matters is the convergence of the *overall process*: across multiple +runs with adjusted parameters, do the stack and CC norms keep improving, or +have they plateaued? When further adjustments produce no visible improvement +in the stack, alignment is as good as it is going to get with ICCS, and it is +time to move to MCCC. + +--- + +## Knowing when to stop + +There is no objective criterion for when ICCS alignment is "done". Practical +signals that the dataset is ready for MCCC: + +- The stack is visually coherent — individual traces closely follow its shape +- CC norms are high across most of the array +- The time window highlights a clean, well-defined arrival +- Running ICCS again with or without autoflip/autoselect produces no + meaningful change + +It is worth taking a snapshot at this point before running MCCC. + +--- + +## Tips + +- **Change one parameter at a time.** It is easy to lose track of what caused + an improvement or regression if multiple things change at once. +- **Take snapshots liberally.** They are lightweight and make it easy to + backtrack to a promising state. +- **Don't over-optimise.** MCCC is more precise than ICCS and will further + refine picks. The job of ICCS is to get the data to a state where MCCC can + succeed — not to produce perfect picks itself. +- **Outlier seismograms.** If a seismogram consistently has a poor CC norm + across many runs and parameter combinations, it may be worth deleting it + from the project rather than letting it drag down the stack. diff --git a/docs/usage/api.md b/docs/usage/api.md index 9401860..00e1465 100644 --- a/docs/usage/api.md +++ b/docs/usage/api.md @@ -1,9 +1,9 @@ # Python API -If none of the above interfaces suit your needs, or you want to write custom -scripts, you can use the AIMBAT Python API. This is the most powerful way to -interact with your projects. View the full [API reference](../api/aimbat.md) -here. +The CLI, shell, TUI, and GUI all use the same underlying Python library. You +can use it directly for custom scripts, automation, or workflows that go beyond +what the other interfaces expose. See the full [API reference](../api/aimbat.md) +for a complete listing. !!! note "Writing seismogram data" [`AimbatSeismogram.data`][aimbat.models.AimbatSeismogram.data] is backed by diff --git a/docs/usage/cli.md b/docs/usage/cli.md deleted file mode 100644 index 9cc2955..0000000 --- a/docs/usage/cli.md +++ /dev/null @@ -1,116 +0,0 @@ -# Command Line Interface (CLI) - -The CLI is the primary tool for project administration, data import, and batch -processing. Every command has a `--help` flag that prints its full option list. - -!!! note "Parameter validation" - Event parameters (e.g. time window, bandpass settings) are validated against - the seismogram data before being written to the database — invalid values are - rejected with an error message. Other fields (e.g. raw seismogram or station - attributes) are written directly without data-aware checks. - -## Project location - -By default, AIMBAT looks for a project file called `aimbat.db` in the current -directory. All commands must be run from the same directory, or the project path -must be set explicitly: - -```bash -export AIMBAT_PROJECT=/path/to/my/project.db -``` - -## Getting started - -```bash -aimbat project create # create a new project in the current directory -aimbat data add *.sac # import SAC files -aimbat event list # list events and find the ID to work with -aimbat event default # set the default event -``` - -Re-adding a file that is already in the project is safe — existing records are -reused rather than duplicated. - -## Targeting a specific event - -Most processing commands operate on the [default event](index.md#default-event) -unless overridden with `--event`: - -```bash -aimbat align iccs --event 6a4a -aimbat event parameter set window_pre --event 6a4a 10.0 -``` - -IDs can be supplied as the full UUID or any unique prefix — as short as the -display in `aimbat event list` shows. - -## Alignment - -```bash -aimbat align iccs # iterative cross-correlation and stack -aimbat align iccs --autoflip --autoselect # with automatic QC -aimbat align mccc # final relative arrival times -aimbat align mccc --all # include deselected seismograms -``` - -ICCS updates picks in `t1`, using `t0` as the starting point if `t1` is not yet -set. MCCC reads the ICCS-refined `t1` picks. - -## Parameters - -```bash -aimbat event parameter list # show all parameters for default event -aimbat event parameter get window_pre # get a single parameter -aimbat event parameter set window_pre 10.0 # set a single parameter - -aimbat seismogram parameter list # seismogram-level parameters -``` - -## Snapshots - -```bash -aimbat snapshot create --comment "before filter change" -aimbat snapshot list -aimbat snapshot rollback -aimbat snapshot details -``` - -## Interactive picking - -These commands open a matplotlib window. Click to set the value, then close the -window to save it. - -```bash -aimbat pick phase # adjust phase arrival (t1) per seismogram -aimbat pick window # set the cross-correlation time window -aimbat pick ccnorm # set the minimum CC norm threshold -``` - -## Inspection and plotting - -```bash -aimbat event list -aimbat seismogram list -aimbat station list - -aimbat plot data # raw seismograms sorted by epicentral distance -aimbat plot stack # ICCS cross-correlation stack -aimbat plot image # 2-D wiggle plot -``` - -Most plot commands accept `--context` / `--no-context` and `--all` (include -deselected seismograms). - -## Scripting - -All commands exit with a non-zero status on error, making them safe to use in -shell scripts: - -```bash -aimbat project create -aimbat data add *.sac -aimbat event default $(aimbat event dump | jq -r '.[0].id') -aimbat snapshot create --comment "initial import" -aimbat align iccs --autoflip --autoselect -aimbat align mccc -``` diff --git a/docs/usage/data.md b/docs/usage/data.md new file mode 100644 index 0000000..48902d9 --- /dev/null +++ b/docs/usage/data.md @@ -0,0 +1,219 @@ +# Adding Data + +## How AIMBAT stores data + +AIMBAT never modifies input files. When you add a data source, AIMBAT reads +the metadata it needs (event location, station coordinates, initial pick time) +and stores copies in the project database. After import, the original files +are only accessed to read waveform samples. + +Before any record is written to the database, AIMBAT validates the data +extracted from each file — checking that required fields are present, that +values are within expected ranges, and that types are correct. If a file fails +validation, the entire import for that file is aborted and the database is left +unchanged. Other files in the same batch are still processed. + +!!! warning "Files must remain accessible" + AIMBAT stores the path to each data file at import time. If you move, + rename, or delete a file after importing it, AIMBAT will no longer be + able to read waveform data for the associated seismogram. Keep data files + in a stable location, or update the path in the database before moving + them. + +--- + +## Data types + +AIMBAT supports three data types, selected with `--type`: + +| Type | Flag | What it provides | +|------|------|-----------------| +| SAC | `--type sac` *(default)* | Station + event + seismogram waveform | +| JSON event | `--type json_event` | Event metadata only — no seismogram created | +| JSON station | `--type json_station` | Station metadata only — no seismogram created | + +SAC files are recognised by the extensions `.sac`, `.bhz`, `.bhn`, and `.bhe`. +JSON files use `.json` regardless of whether they carry event or station data, +so the `--type` flag is required to distinguish them. + +--- + +## Adding SAC files + +The most common case — a directory of SAC files for one or more events: + +```bash +aimbat data add *.sac +``` + +Shell glob expansion is the primary way to select files. Because AIMBAT +deduplicates on import, you can safely re-run the same command — files already +in the project are skipped: + +```bash +aimbat data add *.sac # first run: imports everything +aimbat data add *.sac # second run: no-op, all files already known +``` + +### Selecting subsets + +Standard shell patterns apply: + +```bash +aimbat data add event1/*.sac # one subdirectory +aimbat data add data/**/*.sac # recursive (bash 4+ with globstar) +aimbat data add data/*/BHZ.sac # vertical component only +aimbat data add data/II.*.BHZ.sac # network filter +``` + +### Previewing before import + +Use `--dry-run` to see what would be added without touching the database: + +```bash +aimbat data add --dry-run *.sac +``` + +### Initial picks + +SAC files carry named time markers (`t0`–`t9`). AIMBAT reads one of these as +the initial phase pick (`t0` in AIMBAT's terminology). The default header is +`t0`; change it with `AIMBAT_SAC_PICK_HEADER`: + +```bash +AIMBAT_SAC_PICK_HEADER=t1 aimbat data add *.sac +``` + +Or set it permanently for a project in `.env`: + +```bash title=".env" +AIMBAT_SAC_PICK_HEADER=t1 +``` + +--- + +## Mixing SAC and JSON + +JSON files let you pre-populate event or station records independently of SAC +files — useful when SAC headers are incomplete or when you want to add +metadata first and link waveform files later. + +### Supplying a missing event + +If your SAC files do not carry event metadata (e.g. origin time or +coordinates), add it from a JSON file first, then link the SAC files to the +resulting event: + +```bash +aimbat data add --type json_event event.json +aimbat data add --use-event --type sac *.sac +``` + +`--use-event` accepts a full UUID or any unique prefix from `aimbat event list`. + +### Supplying a missing station + +Similarly, if a SAC file lacks station coordinates: + +```bash +aimbat data add --type json_station station.json +aimbat data add --use-station --type sac waveform.sac +``` + +### JSON format + +JSON event and station files must match the structure of +[`AimbatEvent`][aimbat.models.AimbatEvent] and +[`AimbatStation`][aimbat.models.AimbatStation] respectively. Use +`aimbat event dump` or `aimbat station dump` to export existing records as +templates. + +Every record in AIMBAT is identified by a UUID rather than a simple integer. +This matters when importing JSON from another AIMBAT project: because UUIDs are +generated randomly from a very large space, there is no realistic chance of two +projects producing the same ID. JSON files exported from one project can be +imported into another without any risk of ID collisions. + +--- + +## In the TUI and GUI + +Both the TUI and GUI provide a basic file picker for adding SAC files. For +anything beyond a straightforward single-directory import — recursive globs, +mixed types, `--dry-run` checks, or JSON metadata — use the CLI or shell. + +=== "TUI" + + Press `d` to open a data-type menu (SAC, JSON Event, JSON Station), then + a file picker filtered to the relevant extensions. + +=== "GUI" + + Use the **Add Data** button in the Project tab. + +--- + +## Inspecting what was imported + +```bash +aimbat data list # data sources for the default event +aimbat data list --all-events # all data sources in the project +aimbat event list # events extracted from imported files +aimbat station list # stations extracted from imported files +aimbat seismogram list # seismograms for the default event +``` + +After import, compare the station count and seismogram count for the event: + +```bash +aimbat station list +aimbat seismogram list +``` + +In the typical case — one waveform per station — the two numbers should be +equal. A mismatch usually means something in the source data is inconsistent: +duplicate station entries with slightly different coordinates, missing headers, +or files that failed to parse cleanly. It is worth investigating before moving +on to processing, since unexpected duplicates or gaps in the dataset can affect +alignment quality. + +--- + +## Removing data + +The unit of deletion in AIMBAT is the **seismogram**. This reflects the data +model: a seismogram is the record that ties together a waveform file, a +station, and an event. Removing a seismogram severs that link and drops the +associated data source entry. Events and stations are metadata containers +shared across seismograms — they are left in place even if no seismograms +reference them any more, since they may still be needed (or may have been +added intentionally via JSON). + +To remove a single seismogram: + +```bash +aimbat seismogram delete +``` + +To remove everything belonging to an event — all its seismograms and the event +record itself: + +```bash +aimbat event delete +``` + +To remove all seismograms associated with a station (across all events) and +then the station record: + +```bash +aimbat station delete +``` + +In the TUI, select any row in the **Seismograms** tab and press `Enter` to +open the action menu, which includes a delete option. Events and stations can +be deleted from the **Project** tab in the same way. + +!!! note + Deleting a seismogram from AIMBAT never touches the underlying file on + disk — only the database record and its link to the waveform source are + removed. diff --git a/docs/usage/event-selection.md b/docs/usage/event-selection.md new file mode 100644 index 0000000..9d9a489 --- /dev/null +++ b/docs/usage/event-selection.md @@ -0,0 +1,76 @@ +# Selecting an Event + +After importing data, the first step before inspecting or processing is to +identify which event you want to work with. All processing commands operate on +one event at a time. + +## Listing events + +=== "CLI / Shell" + + ```bash + aimbat event list + ``` + + The table shows each event's ID, time, location, and whether it is + currently the default. IDs are displayed in their shortest unambiguous + form — use any unique prefix when passing an ID to other commands. + +=== "TUI" + + Events are listed in the **Project** tab under **Events**. + +=== "GUI" + + Events are listed in the **Project** tab. + +--- + +## Setting the default event (CLI / Shell) + +The CLI and shell operate on a **default event** — a single event stored in +the database that all commands target unless overridden with `--event`. Set it +after import: + +```bash +aimbat event default +``` + +From that point on, commands like `aimbat plot seismograms` or +`aimbat align iccs` automatically target this event without needing an +explicit ID. + +To target a different event for a single command without changing the default: + +```bash +aimbat align iccs --event +``` + +The default event is marked in `aimbat event list` and is also shown in the +shell prompt. + +--- + +## Selecting an event for processing (TUI / GUI) + +The TUI and GUI maintain their own event selection independently of the +database default — changing it here does not affect what the CLI uses, and +vice versa. + +=== "TUI" + + Two ways to select an event: + + - Press `e` to open the event switcher, navigate with `j` / `k`, and + press `Enter` to select. + - In the **Project** tab, navigate to the **Events** table, press `Enter` + on a row, and choose **Select event**. + + The selected event is shown in the event bar at the top of the screen + and marked with `▶` in both the switcher and the events table. + +=== "GUI" + + Select an event in the **Project** tab. The selection is reflected across + the **Event**, **Snapshots**, and **Processing** tabs. + diff --git a/docs/usage/gui.md b/docs/usage/gui.md deleted file mode 100644 index 9bb1e1c..0000000 --- a/docs/usage/gui.md +++ /dev/null @@ -1 +0,0 @@ -# Graphical User Interface (GUI) diff --git a/docs/usage/iccs-stack.md b/docs/usage/iccs-stack.md new file mode 100644 index 0000000..e9c1dbc --- /dev/null +++ b/docs/usage/iccs-stack.md @@ -0,0 +1,145 @@ +# The ICCS Stack + +## How the stack is assembled + +At the start of each ICCS run, each seismogram is windowed around the current +phase pick (`t1`, or `t0` if `t1` has not yet been set) and tapered at both +ends to suppress edge effects. These windowed, tapered copies — the **CC +seismograms** — are summed to form the initial **stack**. Only seismograms +with `select = True` contribute to the stack; deselected seismograms are +excluded. + +Each seismogram is then cross-correlated with this stack to determine the time +shift that aligns it most closely. The picks (`t1`) are updated with these +refined shifts and the stack is rebuilt from the newly aligned seismograms. +This process repeats iteratively — each new stack is better aligned than the +last — until the picks converge. The CC norm produced at each iteration +quantifies how closely each seismogram matches the current stack. + +Because every seismogram is correlated against the stack rather than against +every other seismogram, ICCS is substantially faster than MCCC — and it is +designed to be run first, to prepare well-aligned data for a final MCCC pass. + +--- + +## Two seismogram types + +Every ICCS instance maintains two representations of each seismogram, both +derived from the original data but never modifying it: + +**CC seismograms** — the windowed, tapered copies used in the actual +cross-correlation. The window is defined by `window_pre` and `window_post` +relative to the pick. A cosine taper (width controlled by `ramp_width`) is +applied just outside the window to bring the signal smoothly to zero. This is +what the algorithm operates on, and what is shown when `context` is off. + +**Context seismograms** — a broader view around the same pick, extended by +`context_width` on each side, without any tapering. These exist purely for +display and interactive picking: seeing the waveform beyond the taper edges +makes it much easier to judge where the window boundaries should be placed. +This is the default view. + +The time window region is highlighted in the plots so the boundary between +the two representations is always visible. + +--- + +## Viewing the stack + +The **stack view** overlays all individual seismograms as thin lines on top of +the bold stack waveform. Lines are coloured by their CC norm on a light-blue-to-pink scale using a +power-law normalisation (γ = 2), which compresses the low end and spreads +out the high end. Differences among well-aligned seismograms are therefore +more visually distinct than differences among poorly-matching ones, making +it easy to identify which traces are contributing most to the stack. + +=== "CLI / Shell" + + ```bash + aimbat plot stack # context mode (default) + aimbat plot stack --no-context # CC seismograms only + aimbat plot stack --all # include deselected seismograms + ``` + +=== "TUI" + + Press `t` to open the Tools menu and choose **Plot stack**. Before + launching, the options **context** and **all seismograms** can be toggled + in the menu. + +=== "GUI" + + The stack is shown in the **Processing** tab. Use the **Context / CC** + toggle to switch between the two seismogram types. + +--- + +## Viewing the matrix image + +The **matrix image** plots each seismogram as a horizontal row in a 2-D +colour image, with time on the x-axis and one row per seismogram. Rows are +sorted by CC norm, so the best-aligned seismograms appear at the top and the +worst at the bottom. This layout makes it easy to spot systematic misalignment +or outlier traces that stand out from the rest of the array. + +The same time window highlight and `context` / `--no-context` toggle apply as +in the stack view. + +=== "CLI / Shell" + + ```bash + aimbat plot matrix + aimbat plot matrix --no-context + aimbat plot matrix --all + ``` + +=== "TUI" + + Press `t` and choose **Plot matrix image**. + +=== "GUI" + + Switch to the **Image** tab within the Processing panel. + +--- + +## Choosing a view + +The two views complement each other: + +- **Stack view** is best for assessing overall alignment and picking a new + phase arrival — the waveform shape of the stack and its coherence with + individual traces is immediately apparent. +- **Matrix image** is better for spotting patterns: a cluster of rows at the + bottom with poor CC norms, a seismogram whose polarity is inverted (shows as + an opposite-coloured band), or a group of traces that are consistently + shifted in one direction. + +Using both views together, especially after adjusting parameters, gives the +most complete picture of alignment quality. + +--- + +## Use in interactive adjustment + +These two views are not just for passive inspection — they are the same plots +used when interactively adjusting the phase pick, time window, and minimum CC +norm threshold. Which view is presented depends on the tool and can usually +be chosen before launching it. + +During interactive adjustment of the minimum CC norm, the matrix image gains +an additional behaviour: scrolling the mouse wheel removes rows from the top, +progressively revealing where the well-aligned seismograms end and the poor +ones begin. The point where the remaining rows stop looking coherent is a +natural place to set the threshold. + +--- + +## The `--all` flag + +By default, only seismograms with `select = True` appear in the plots. +Passing `--all` (or toggling **all seismograms** in the TUI/GUI) also shows +deselected seismograms. This is useful for checking whether deselected traces +could recover if parameters are adjusted — recall that deselected seismograms +are still cross-correlated against the stack and can be re-selected +automatically by autoselect in a subsequent ICCS run. diff --git a/docs/usage/index.md b/docs/usage/index.md index ffab79c..f9ed2fe 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -1,39 +1,262 @@ # Using AIMBAT +AIMBAT provides four interfaces that all read from and write to the **same +project database**. You can switch between them at any point — run alignment +from the CLI, inspect the result in the TUI, tweak a parameter in the shell, +then take a snapshot from the GUI. There is no synchronisation step; every +interface always reflects the current state of the project. + +--- + ## Interfaces -Once [installed](../first-steps/installation.md), AIMBAT can be used in several -ways, each of which has unique strengths depending on the task at hand: +### Command Line Interface (CLI) -- **[Command line](cli.md)**: Ideal for administrative tasks like - adding data to a project, and exploring the data after they are added. -- **[Interactive Shell](shell.md)**: Similar to the CLI but with the added benefit of extra - context and command history. Unlike the CLI, it is not possible to set - parameters to nonsensical values. -- **[Terminal UI](tui.md)**: This is where processing typically happens. - The TUI is designed for efficient, mouse-free, and keyboard-driven navigation - for users familiar with the workflow. -- **[Graphical UI](gui.md)**: Mouse driven interface for users who prefer a - more visual approach. The GUI is ideal for newer users who need more guidance - and visual cues to navigate the workflow. -- **[Python API](api.md)**: The preferred way for scripting and automated -processing by writing custom Python scripts. +```bash +aimbat [options] +``` -Complete walkthroughs for each of these options are presented in the following sections. +The CLI is the most direct interface. Every operation is a single command that +runs, prints its result, and exits. It is the natural choice for scripting, +batch jobs, and any task where you already know what you want to do. -## Default event +Every command accepts `--help` for a full option listing. Most processing +commands operate on the [default event](index.md#default-event) unless you +pass an explicit `--event` flag: -AIMBAT projects may contain multiple seismic events. To reduce repetition when -using the [CLI](cli.md), you can designate a **default event**: commands -automatically target it unless you explicitly pass an `--event` flag. +```bash +aimbat align iccs --event 6a4a +``` + +IDs can be supplied as the full UUID or any unique prefix. + +All commands exit with a non-zero status on error, making them safe to chain +in shell scripts: + +```bash +aimbat project create +aimbat data add *.sac +aimbat event default $(aimbat event dump | jq -r '.[0].id') +aimbat snapshot create "initial import" +aimbat align iccs --autoflip --autoselect +aimbat align mccc +``` -The default event is primarily a CLI convenience. The [Terminal UI](tui.md) -and [Graphical UI](gui.md) maintain their own event selection independently. -The [Shell](shell.md) also tracks its own event context, but will fall back to -the default event at startup if one is set and no `--event` flag is provided. +--- -Set or change the default at any time using: +### Interactive Shell + +```bash +aimbat shell +aimbat shell --event # start in the context of a specific event +``` + +The shell is a persistent session that wraps all CLI commands with tab +completion, command history (saved to `~/.aimbat_history`), and live ICCS +feedback. Commands are identical to the CLI but without the leading `aimbat`: + +``` +aimbat> event list +aimbat> align iccs +``` + +The shell maintains a local **event context** that is independent of the +database default event and is never written to the database. Switch it at any +time: + +``` +aimbat [6a4a]> event switch +``` + +After every command the shell prints whether the ICCS instance for the current +event is still valid, so you always know whether alignment is ready to run. + +Parameter validation in the shell is stricter than the CLI: setting a parameter +that would produce an invalid ICCS configuration is rejected before anything is +written to the database, and the error message explains exactly why. + +Exit with `exit`, `quit`, `q`, or **Ctrl+D**. + +--- + +### Terminal UI (TUI) + +```bash +aimbat tui +``` + +The TUI is a full-screen, keyboard-driven interface built for efficient +processing. It is best suited to the core iterative workflow — adjusting +parameters, running ICCS, inspecting alignment, managing snapshots — all +without leaving the terminal. + +#### Layout + +``` +┌─ AIMBAT ───────────────────────────────────────────────────────┐ +│ ▶ 2000-01-01 12:00 | 45.1°, 120.4° ● ICCS ready (abc12345) │ ← event bar +├────────────────────────────────────────────────────────────────┤ +│ Project │ Seismograms │ Snapshots │ ← tabs +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ ... │ │ +│ └───────────────────────────────────────────────────────────┘ │ +├────────────────────────────────────────────────────────────────┤ +│ e Events a Align t Tools p Parameters n Snapshot q Quit │ ← footer +└────────────────────────────────────────────────────────────────┘ +``` + +The **event bar** shows the event currently selected for processing and the +ICCS status (`● ICCS ready` / `○ no ICCS`). + +The **footer** lists the available key bindings. Actions that require an event +to be selected (Align, Tools, Parameters, New Snapshot) only appear once one +is chosen. + +#### Navigation + +Switch tabs with `H` / `L` (vim-style) or with the mouse. All tables support: + +| Key | Action | +|-----|--------| +| `j` / `↓` | Move down | +| `k` / `↑` | Move up | +| `g` / `G` | Jump to top / bottom | +| `Enter` | Open row action menu | + +#### Seismogram row actions + +Pressing `Enter` on a seismogram row opens a context menu with the following +actions: + +| Action | Description | +|--------|-------------| +| Toggle select | Include or exclude this seismogram from the ICCS stack | +| Toggle flip | Multiply the seismogram's data by −1 to correct polarity | +| Reset parameters | Restore all per-seismogram parameters to their defaults | +| Delete seismogram | Remove the seismogram from the project | + +#### Global key bindings + +| Key | Action | +|-----|--------| +| `e` | Open event switcher | +| `a` | Run alignment (ICCS or MCCC) | +| `t` | Open interactive tools (matplotlib picking) | +| `p` | Edit processing parameters | +| `n` | Create a new snapshot | +| `d` | Add data files to the project | +| `r` | Refresh all panels | +| `c` | Toggle colour theme | +| `q` | Quit | + +#### ICCS lifecycle in the TUI + +The TUI keeps an in-memory ICCS instance for the selected event. It is +built automatically on startup and rebuilt whenever the event or its parameters +change — including changes made externally by the CLI or shell. Every five +seconds the TUI polls the database for such changes and silently updates. + +If ICCS cannot be built (for example because a parameter was set to an invalid +value from outside the TUI), the event bar shows `○ no ICCS` and alignment and +interactive tools are disabled. Fixing the parameter from any interface +triggers an automatic retry on the next poll cycle. + +--- + +### Graphical UI (GUI) + +```bash +aimbat-gui +``` + +!!! note "Separate dependency group" + The GUI requires additional packages not installed by default. Install them + with: + ```bash + uv sync --group gui + # or: pip install "aimbat[gui]" + ``` + +The GUI opens a browser window (default: `http://localhost:8612`) and provides +a mouse-driven interface built with [NiceGUI](https://nicegui.io) and +[Plotly](https://plotly.com/python/). It is suited to users who prefer visual +interaction and to reviewing results — hovering over plots, clicking to set +picks, and comparing snapshots side-by-side. + +The four tabs — **Project**, **Event**, **Snapshots**, and **Processing** — +follow the left-to-right workflow. The Processing tab provides an interactive +ICCS stack and matrix image; clicking on the plot sets picks and thresholds +directly. + +--- + +## Shared concepts + +### Project location + +All interfaces look for `aimbat.db` in the current directory. Override this +with an environment variable: + +```bash +export AIMBAT_PROJECT=/path/to/my/project.db +``` + +### Default event + +Projects can contain multiple seismic events. The **default event** is a +database-level setting used by the CLI and, on startup, by the shell. Set it +with: ```bash aimbat event default ``` + +The TUI and GUI maintain their own **event selection** independently of the +database default and never change it. + +### The ICCS instance + +When you work on an event, AIMBAT builds an **ICCS instance** — an in-memory +container that holds all the seismograms for that event, their current picks +and parameters, and the data structures the alignment algorithm needs. Think +of it as the working set for a processing session: everything required to run +ICCS or MCCC, inspect the stack, or adjust picks is loaded into this container +and kept consistent with the database. + +The instance is built automatically when an event is selected. **ICCS ready** +means the container has been successfully built and reflects the current state +of the project. **No ICCS** means it could not be built — usually because a +parameter combination is invalid or a waveform file is inaccessible — and +alignment and interactive tools are unavailable until the problem is resolved. + +### Logging and debugging + +AIMBAT writes a log to `aimbat.log` in the current directory. By default only +`INFO`-level messages and above are recorded. To get more detail, pass +`--debug` to any CLI command: + +```bash +aimbat align iccs --debug +``` + +This sets the log level to `DEBUG` for that invocation and writes verbose +output — SQL queries, parameter validation steps, ICCS iteration details — to +the log file. The log is the first place to look when something behaves +unexpectedly. + +The log file path and level can also be set permanently via environment +variable or `.env`: + +```bash title=".env" +AIMBAT_LOG_LEVEL=DEBUG +AIMBAT_LOGFILE=/path/to/aimbat.log +``` + +Log files are rotated automatically at 100 MB. + +### Live consistency + +Because all interfaces share the same database file, changes from one are +immediately visible in another. The TUI polls for external changes every five +seconds. The shell reports ICCS status after every command. There is no need +to restart any interface to pick up changes made elsewhere. diff --git a/docs/usage/inspection.md b/docs/usage/inspection.md new file mode 100644 index 0000000..51f5002 --- /dev/null +++ b/docs/usage/inspection.md @@ -0,0 +1,118 @@ +# Initial Data Inspection + +Before running any alignment, it is worth visually inspecting the imported +seismograms to catch obvious problems: garbled waveforms, stations with +excessive noise, flat traces, or data gaps. Catching these early avoids +wasting time tuning parameters around fundamentally unusable data. + +AIMBAT provides two complementary views for this purpose. + +--- + +## By event — record section + +Plots all seismograms for an event as a record section: waveforms sorted by +epicentral distance, with absolute time on the x-axis. This gives an immediate +overview of the array — coherent arrivals should appear as a roughly linear +moveout across the traces. + +=== "CLI / Shell" + + ```bash + aimbat plot seismograms + aimbat plot seismograms --event # specific event + ``` + +=== "TUI" + + In the **Project** tab, navigate to the **Events** table, press `Enter` + on a row, and choose **View seismograms**. + +=== "GUI" + + Select an event in the **Project** tab and click **View seismograms**. + +**What to look for:** + +- Traces that are flat, clipped, or visually incoherent with the rest of the array +- Stations with excessive noise relative to the signal +- Traces where the arrival appears to arrive much earlier or later than expected from moveout +- Unusually large or small amplitudes after normalisation (can indicate a gain issue in the original file) + +--- + +## By station — across events + +Plots all seismograms recorded at a single station across every event in the +project, aligned on the initial pick (`t0`). The x-axis shows time relative +to the pick; traces are stacked vertically in chronological order. This view +is useful for checking whether a station is consistently problematic across +multiple events, or whether an issue is isolated to one. + +=== "CLI / Shell" + + ```bash + aimbat station plotseis + ``` + +=== "TUI" + + In the **Project** tab, navigate to the **Stations** table, press `Enter` + on a row, and choose **View seismograms**. + +=== "API" + + ```python + from sqlmodel import Session + from aimbat.db import engine + from aimbat.models import AimbatStation + from aimbat.plot import plot_seismograms + + with Session(engine) as session: + station = session.get(AimbatStation, station_id) + plot_seismograms(session, station, return_fig=False) + ``` + +--- + +## How the data is prepared + +Both plots apply the same preprocessing before displaying: + +1. **Detrend** — removes the mean and linear trend +2. **Bandpass filter** *(optional)* — applied if `bandpass_apply` is enabled in + the event parameters; uses the `bandpass_fmin` / `bandpass_fmax` values set + for that event. Filtering is **off by default**, so the initial inspection + shows the raw waveforms as imported. Users who pre-filter their data before + import can leave it disabled and work directly with the filtered waveforms + they already have +3. **Resample** — resampled to a common 10 Hz sample rate for consistent display +4. **Normalise** — each trace is normalised to unit amplitude so waveforms are + visually comparable regardless of original gain + +The original files are never modified. These steps are applied in memory for +display only. + +Because the bandpass filter uses the current event parameters, the inspection +plots will look different depending on whether a filter is applied. It can be +useful to inspect both with and without filtering to distinguish noise from +signal. + +--- + +## What to do with bad data + +If you identify a seismogram that should not be included in processing, see +[Removing data](data.md#removing-data). Deleting a seismogram from the project +does not affect the underlying file. + +For borderline cases — noisy but potentially usable traces — it is better to +leave them in and rely on ICCS autoselect to exclude them based on +cross-correlation quality rather than deleting them outright. + +--- + +!!! tip "Before moving on" + Once you are happy with the imported data, take a snapshot. This gives you + a clean baseline to return to if processing goes in an unexpected direction. + See [Snapshots](snapshots.md) for how to create one. diff --git a/docs/usage/mccc.md b/docs/usage/mccc.md new file mode 100644 index 0000000..d621f94 --- /dev/null +++ b/docs/usage/mccc.md @@ -0,0 +1,117 @@ +# Finalising with MCCC + +## When to run MCCC + +MCCC works best on data that is already reasonably well aligned — coherent +stack, high CC norms across most of the array, a clean and stable time window. +It cannot recover poor alignment; if the seismograms are badly misaligned, +many pairwise correlations will be weak and the inversion will be poorly +constrained. + +Running ICCS first is therefore the usual approach, not because of a strict +rule, but because ICCS gives you tools that MCCC does not: interactive +parameter adjustment, autoflip to correct polarity, and autoselect to remove +seismograms that consistently fail to align. Once the dataset is in a state +where those tools are no longer improving things, MCCC is the natural next +step — and because it produces formal standard errors for each delay estimate, +its output is directly usable in further analyses such as tomographic +inversion. + +Take a snapshot before running MCCC. + +--- + +## How MCCC differs from ICCS + +ICCS aligns each seismogram against a running stack: a reference constructed +from the combined array. MCCC takes a different approach — it computes +cross-correlation delays between **all pairs** of selected seismograms +simultaneously, then finds the set of time shifts that best satisfies all of +those pairwise constraints at once, using a weighted least-squares inversion +with Tikhonov regularisation. + +Because every seismogram pair contributes a constraint, the solution is not +anchored to any single reference waveform. The resulting picks are relative +shifts that sum to zero across the array — they express how much each +seismogram needs to move relative to the group mean, not relative to a stack. + +Because the solution comes from a least-squares inversion, MCCC also produces +a **standard error** for each delay estimate, derived from the covariance +matrix of the solution. ICCS provides no equivalent — its CC norms indicate +alignment quality but carry no formal uncertainty. The standard errors are what +make MCCC picks suitable as direct input to further analyses. + +This makes MCCC more rigorous but also slower — roughly three to five times +slower than a comparable ICCS run. More importantly, MCCC offers no equivalent +of ICCS's interactive controls: there is no autoflip, no autoselect, no +parameter tuning loop. It solves the problem it is given; shaping that problem +— deciding which seismograms to include, what window to use, whether to apply +a filter — is done beforehand with ICCS. + +!!! note "Reference" + VanDecar, J. C., and R. S. Crosson. "Determination of Teleseismic Relative + Phase Arrival Times Using Multi-Channel Cross-Correlation and Least Squares." + *Bulletin of the Seismological Society of America*, vol. 80, no. 1, 1990, + pp. 150–169. + +--- + +## Running MCCC + +=== "CLI / Shell" + + ```bash + aimbat align mccc # selected seismograms only + aimbat align mccc --all # include deselected seismograms + ``` + +=== "TUI" + + Press `a` to open the alignment menu and choose **MCCC**. + +=== "GUI" + + Use the **Run MCCC** button in the **Processing** tab. + +MCCC updates `t1` for all participating seismograms and writes the results back +to the database immediately. Inspect the stack and matrix image afterwards to +confirm the picks improved. + +--- + +## Parameters + +### Minimum CC norm (`mccc_min_ccnorm`) + +Pairs of seismograms whose cross-correlation coefficient falls below this +threshold are excluded from the inversion. Unlike ICCS's `min_ccnorm`, which +operates on whole seismograms, this threshold applies to **pairs**: a +seismogram can still contribute to the solution through its good pairs even if +some of its pairings are weak. + +Setting this too low allows noisy pairs to degrade the inversion; setting it +too high may leave too few constraints for a stable solution. The ICCS CC +norms give a rough sense of which seismograms are likely to correlate well with +each other. + +### Damping (`mccc_damp`) + +Tikhonov regularisation applied to the inversion. A small amount of damping +stabilises the solution when the constraint matrix is poorly conditioned — +for example, when a seismogram has few pairs above `mccc_min_ccnorm` and its +time shift is therefore weakly constrained. Higher damping pulls all shifts +closer to zero (the group mean), producing a more conservative solution. + +Setting damping to zero disables regularisation entirely, which is fine when +the dataset is large and well-correlated but can produce unstable results on +sparse or noisy datasets. + +--- + +## The `--all` flag + +By default, MCCC only includes seismograms with `select = True` — the same +subset that contributed to the ICCS stack. Passing `--all` includes deselected +seismograms in the inversion. Their picks are still updated, but they may +degrade the inversion if they are genuinely noisy or misaligned. Use with +caution. diff --git a/docs/usage/project.md b/docs/usage/project.md new file mode 100644 index 0000000..4b7fb76 --- /dev/null +++ b/docs/usage/project.md @@ -0,0 +1,106 @@ +# Project + +## Creating a project + +Before adding data, a project must be initialised. This creates the database +schema in a new SQLite file. + +=== "CLI / Shell" + + ```bash + aimbat project create + ``` + +=== "TUI" + + Launch the TUI — if no project is found in the current directory, a prompt + appears offering to create one or quit. + + ```bash + aimbat tui + ``` + +=== "GUI" + + The GUI creates a project automatically on startup if one does not already + exist. + + ```bash + aimbat-gui + ``` + +Re-running `project create` on an existing project is safe — it raises an +error rather than overwriting data. + +--- + +## Project location + +By default, AIMBAT reads and writes a file called `aimbat.db` in the current +working directory. All four interfaces respect the same configuration, so you +only need to set it once. + +!!! warning "Keep the project on local storage" + SQLite relies on POSIX file locking, which is not reliably supported over + network filesystems (NFS, SMB, etc.). Placing the project database on a + network share can lead to database corruption. Keep `aimbat.db` on a local + disk. + +### Using a different path + +Set `AIMBAT_PROJECT` to any file path: + +```bash +AIMBAT_PROJECT=/data/my-study/project.db aimbat tui +``` + +Or export it for the duration of a shell session: + +```bash +export AIMBAT_PROJECT=/data/my-study/project.db +aimbat project create +aimbat data add *.sac +aimbat tui +``` + +### Using a .env file + +Place a `.env` file in the directory where you run AIMBAT. Settings in `.env` +are loaded automatically and do not require exporting: + +```bash title=".env" +AIMBAT_PROJECT=/data/my-study/project.db +``` + +This is the recommended approach for persistent, per-project configuration — +commit `.env` alongside your scripts so the path is always consistent. + +### Using a full database URL + +For advanced use (e.g. a remote or in-memory database), set `AIMBAT_DB_URL` +to a full [SQLAlchemy connection URL](https://docs.sqlalchemy.org/en/20/core/engines.html#database-urls). +When set, it takes precedence over `AIMBAT_PROJECT`: + +```bash +AIMBAT_DB_URL=sqlite+pysqlite:////absolute/path/to/project.db aimbat tui +``` + +!!! note "In-memory databases" + `AIMBAT_DB_URL=sqlite+pysqlite:///:memory:` creates a temporary in-memory + database that is discarded when the process exits. This is used internally + for testing. + +### Precedence + +Configuration is resolved in this order (highest wins): + +1. `AIMBAT_DB_URL` environment variable or `.env` entry +2. `AIMBAT_PROJECT` environment variable or `.env` entry +3. Built-in default: `aimbat.db` in the current directory + +To inspect the settings currently in use: + +```bash +aimbat utils settings # human-readable table +aimbat utils settings --no-pretty # KEY="value" format, ready to paste into .env or export +``` diff --git a/docs/usage/shell.md b/docs/usage/shell.md deleted file mode 100644 index 42b3a30..0000000 --- a/docs/usage/shell.md +++ /dev/null @@ -1,79 +0,0 @@ -# Interactive Shell - -The AIMBAT shell is a persistent, interactive session that wraps all CLI commands -with tab-completion, command history, and live ICCS feedback. It is the recommended -interface when working interactively from the terminal. - -## Starting the shell - -```bash -aimbat shell # start in the context of the default event -aimbat shell --event # start in the context of a specific event -``` - -The `--event` flag accepts a full UUID or any unique prefix. It sets the shell's -initial event context without changing the database default event. - -## Event context - -The shell maintains a local **event context** — the event that all commands -operate on. This is independent of the database default event and is never -written to the database. - -The prompt reflects the current context: - -``` -aimbat> # using the database default event -aimbat [6a4a1b2c]> # using a specific event (first 8 chars of ID) -``` - -### Switching events - -``` -event switch # switch to a specific event -event switch # reset to the database default event -``` - -`event switch` accepts a full UUID or any unique prefix. Switching immediately -reports the ICCS status for the new event. - -## Commands - -All CLI commands are available in the shell, without the leading `aimbat`. For -example, `aimbat event list` becomes simply `event list`. - -## Tab completion and history - -Press **Tab** at any point to complete commands, subcommands, and flags. -Command history is saved to `~/.aimbat_history` and persists across sessions. -Use the up/down arrow keys to navigate it. - -## ICCS status - -After every command, the shell checks whether the ICCS instance for the current -event is still valid and prints a status line when something changes: - -``` -ICCS ready (event 6a4a1b2c) -ICCS not ready — -``` - -The status is also printed on startup. A warm ICCS cache is reused across -commands in the same session, so repeated operations on the same event avoid -redundant data loading. - -## Parameter validation - -Setting a parameter that would produce an invalid ICCS configuration is -rejected before anything is written to the database: - -``` -aimbat> event parameter set window_pre 999 -ValueError: ICCS rejected window_pre=999: -``` - -The database is left unchanged on rejection. - -## Exiting - -Type `exit`, `quit`, or `q`, or press **Ctrl+D**. diff --git a/docs/usage/snapshots.md b/docs/usage/snapshots.md new file mode 100644 index 0000000..d943b77 --- /dev/null +++ b/docs/usage/snapshots.md @@ -0,0 +1,189 @@ +# Snapshots + +## What a snapshot captures + +A snapshot saves the current processing parameters for an event at a point in +time. Specifically, it stores: + +- All event-level parameters: the time window, bandpass filter settings, and + minimum CC norm threshold +- Per-seismogram parameters for every seismogram in the event: the current `t1` + pick, `select` flag, and `flip` flag + +The seismogram waveform data itself is not copied — snapshots are lightweight. +They capture where you are in the parameter space, not the data. + +This works because the CC seismograms and context seismograms that ICCS +operates on are entirely deterministic: given the original waveform data and a +set of parameters, they are always reconstructed identically. Restoring a +snapshot therefore restores the exact state of the ICCS instance — there is +nothing lost by not saving the derived arrays. + +If seismograms are added to the project after a snapshot was taken, they have +no entry in that snapshot. When previewing or rolling back, those seismograms +are included using their current live parameters — the snapshot's event-level +parameters (window, filter, min CC norm) still apply to them. + +Snapshots are per-event. Each event maintains its own list. + +--- + +## When to take a snapshot + +Take a snapshot before making changes you might want to undo: + +- After importing data, before any processing — a clean baseline to return to +- After initial alignment looks good, before tightening parameters further +- Before trying an experimental configuration (different window, filter, etc.) +- Before running MCCC + +Snapshots are cheap. Taking one costs almost nothing, and having a rollback +point available is worth it. + +--- + +## Creating a snapshot + +=== "CLI / Shell" + + ```bash + aimbat snapshot create # no comment + aimbat snapshot create "after bandpass 1–3Hz" # with comment + ``` + + The comment is optional but useful for identifying the snapshot later. + +=== "TUI" + + Press `n` to open the snapshot comment dialog, optionally enter a comment, + and confirm. The new snapshot appears immediately in the **Snapshots** tab. + +=== "GUI" + + Click **New Snapshot** in the **Processing** tab. A dialog lets you enter + an optional comment. + +--- + +## Listing snapshots + +=== "CLI / Shell" + + ```bash + aimbat snapshot list # for the default event + aimbat snapshot list --all-events # across all events + ``` + + The table shows the snapshot ID, date and time, comment, and number of + seismograms captured. + +=== "TUI" + + Snapshots for the current event are listed in the **Snapshots** tab. + Switch events using the event switcher (`e`) to see another event's + snapshots. + +=== "GUI" + + The **Snapshots** tab lists all snapshots for the selected event. + +--- + +## Inspecting a snapshot + +Before rolling back, it can be useful to see what a snapshot contains. + +=== "CLI / Shell" + + ```bash + aimbat snapshot details # view saved event parameters + aimbat snapshot preview # view stack plot + aimbat snapshot preview --matrix # view matrix image + ``` + + `details` shows the event-level parameters (window, filter, min_ccnorm) as + they were when the snapshot was taken. `preview` builds the ICCS stack from + the snapshot's parameters and displays it — without modifying anything in + the database. + +=== "TUI" + + Press `Enter` on a snapshot row in the **Snapshots** tab to open the action + menu. Options include: + + - **Show details** — displays the saved event parameters + - **Preview stack** — opens the stack plot built from the snapshot + - **Preview matrix image** — opens the matrix image + + Both preview options support the **context** (`c`) and **all seismograms** + (`a`) toggles in the action menu before launching. + +=== "GUI" + + Select a snapshot in the **Snapshots** tab — its stack and matrix image + are shown in the right panel in read-only mode. + +--- + +## Rolling back + +Rolling back restores the snapshot's parameters as the current live values. +This overwrites the current event and seismogram parameters for this event. + +=== "CLI / Shell" + + ```bash + aimbat snapshot rollback + ``` + +=== "TUI" + + Press `Enter` on a snapshot row and choose **Rollback to this snapshot**. + A confirmation dialog appears before any changes are made. + +=== "GUI" + + Select a snapshot and click **Rollback to this**. + +After rolling back, the event's parameters are exactly as they were when +the snapshot was taken. Any ICCS runs or parameter changes made after that +snapshot are undone. The snapshot itself is not deleted — you can roll +back to it again. + +--- + +## Deleting a snapshot + +=== "CLI / Shell" + + ```bash + aimbat snapshot delete + ``` + +=== "TUI" + + Press `Enter` on a snapshot row and choose **Delete snapshot**. A + confirmation dialog appears. + +=== "GUI" + + Select a snapshot and click **Delete**. + +Deletion is permanent. The snapshot cannot be recovered after deletion. + +--- + +## Exporting snapshot data + +For archiving or scripting purposes, snapshot data can be exported to JSON: + +=== "CLI / Shell" + + ```bash + aimbat snapshot dump # default event + aimbat snapshot dump --all-events # all events + ``` + + The output contains three sections: snapshot metadata, event parameter + snapshots, and seismogram parameter snapshots, cross-referenced by + snapshot ID. diff --git a/docs/usage/tui.md b/docs/usage/tui.md deleted file mode 100644 index a90651e..0000000 --- a/docs/usage/tui.md +++ /dev/null @@ -1,180 +0,0 @@ -# Terminal User Interface (TUI) - -The TUI is the primary interface for processing seismic events. It is designed -for keyboard-driven, mouse-free operation and provides a live view of the -project state. - -## Launching - -```bash -aimbat tui -``` - -## Layout - -``` -┌─ AIMBAT ────────────────────────────────── ... -│ ● 2000-01-01 12:00:00 | 45.1°, 120.4° ... ← event bar -├───────────────────────────────────────────... -│ Seismograms │ Parameters │ Stations │ S ... ← tabs -│ ┌─────────────────────────────────────── ... -│ │ ... ... -│ └─────────────────────────────────────── ... -├───────────────────────────────────────────... -│ e Events d Add Data p Interactive Tools ... ← footer -└───────────────────────────────────────────... -``` - -### Event bar - -The event bar shows the event currently selected for processing. The `▶` -marker identifies the active event. The right side shows the ICCS status -(`● ICCS ready` / `○ no ICCS`) and a `modified:` timestamp if the event -parameters have been changed since the project was created. The timestamp -updates automatically when changes arrive from an external source such as -the CLI. - -## Navigation - -### Tabs - -Switch between tabs with the mouse or with `H` / `L` (vim-style left/right). - -### Tables - -All tables support vim-style keyboard navigation: - -| Key | Action | -|-----|--------| -| `j` / `↓` | Move down | -| `k` / `↑` | Move up | -| `g` | Jump to top | -| `G` | Jump to bottom | -| `Enter` | Open row action menu (or toggle/edit inline — see below) | - -## Tabs - -### Seismograms - -Lists every seismogram in the current event. Pressing `Enter` on a row opens a -context menu with the following actions: - -| Action | Description | -|--------|-------------| -| Toggle select | Include or exclude this seismogram from processing | -| Toggle flip | Flip the seismogram polarity | -| Reset parameters | Restore all per-seismogram parameters to their defaults | -| Delete seismogram | Remove the seismogram from the project | - -### Parameters - -Lists all processing parameters for the current event. Pressing `Enter` on a -parameter edits it: - -- **Boolean** parameters toggle immediately. -- **Numeric / timedelta** parameters open an input dialog pre-filled with the - current value. Press `Enter` to save or `Escape` to cancel. - -Parameter changes are validated against the current ICCS instance before being -written to the database. Invalid values are rejected with an error notification. - -### Stations - -Lists all stations associated with the current event. Pressing `Enter` opens a -context menu with one action: **Delete station and all seismograms**. - -### Snapshots - -Lists all snapshots saved for the current event. Pressing `Enter` opens a -context menu: - -| Action | Description | -|--------|-------------| -| Show details | Display all event parameters captured in this snapshot | -| Rollback to this snapshot | Restore event parameters to the snapshot state | -| Delete snapshot | Remove the snapshot | - -Press `n` (only available on this tab) to create a new snapshot. An optional -comment can be entered before saving. - -## Global actions - -### Switch event — `e` - -Opens the event switcher, which lists all events in the project. Each row shows: - -- **Marker**: `▶` = currently selected -- **✓**: event is marked as completed - -Available actions inside the switcher: - -| Key | Action | -|-----|--------| -| `Enter` | Select this event for processing | -| `c` | Toggle the completed flag | -| `Backspace` | Delete the event and all its data | -| `Escape` | Cancel | - -The selected event is used for all processing operations until changed. - -### Interactive tools — `p` - -Suspends the TUI and opens an interactive matplotlib window: - -| Tool | Description | -|------|-------------| -| Phase arrival (t1) | Adjust the phase arrival for each seismogram | -| Time window | Set the pre- and post-pick time window | -| Min CC norm | Set the minimum cross-correlation normalisation threshold | - -Two options can be toggled before launching: - -- **Context** (`c`): show surrounding waveform context -- **All seismograms** (`a`): apply to all seismograms, not only selected ones - -Close the matplotlib window to return to the TUI. - -### Align — `a` - -Runs a seismogram alignment algorithm in a background thread (the TUI remains -responsive). Choose between: - -| Algorithm | Description | -|-----------|-------------| -| ICCS | Iterative Cross-Correlation and Stack | -| MCCC | Multi-Channel Cross-Correlation | - -ICCS options (toggled before running): - -- **Autoflip** (`f`): automatically flip seismograms with negative cross-correlation -- **Autoselect** (`s`): automatically deselect seismograms below the CC norm threshold - -MCCC option: - -- **All seismograms** (`a`): include deselected seismograms - -### Other global keys - -| Key | Action | -|-----|--------| -| `d` | Add data files to the project | -| `r` | Refresh all panels | -| `t` | Toggle light / dark theme | -| `q` | Quit | - -## ICCS and external changes - -The TUI maintains an in-memory ICCS instance for the current event. It is -created automatically on startup and recreated whenever the event or its -parameters change. - -Every 5 seconds, the TUI polls the database for external changes. If the event -parameters or seismogram parameters have been modified from outside (e.g. via -the CLI), the ICCS instance is silently recreated and all panels refresh. This -means the TUI and CLI can be used side-by-side on the same project without -manual synchronisation. - -If ICCS creation fails (for example because a parameter was set to an invalid -value via the CLI), the `○ no ICCS` status is shown and interactive tools and -alignment are disabled. Fixing the parameter externally will trigger an -automatic retry on the next poll cycle. diff --git a/src/aimbat/_tui/aimbat.tcss b/src/aimbat/_tui/aimbat.tcss index 038c866..0abb027 100644 --- a/src/aimbat/_tui/aimbat.tcss +++ b/src/aimbat/_tui/aimbat.tcss @@ -29,6 +29,31 @@ TabbedContent { padding-top: 1; } +/* ---- Project tab ---- */ + + +.project-divider { + margin: 0; + height: 1; +} + +.project-table-title { + height: 1; + padding: 0 2; + color: $secondary; + text-style: bold; +} + +#project-event-table, +#project-station-table { + height: 1fr; +} + +/* ---- No-project modal ---- */ +NoProjectModal { + align: center middle; +} + /* ---- Confirm modal ---- */ ConfirmModal { align: center middle; @@ -65,6 +90,36 @@ EventSwitcherModal { text-style: bold; } +/* ---- Snapshot action menu modal ---- */ +SnapshotActionMenuModal { + align: center middle; +} + +#snapshot-action-dialog { + background: $background; + border: solid $primary; + width: 58; + height: auto; + padding: 1 2; +} + +#snapshot-action-dialog .modal-title { + color: $secondary; + text-style: bold; +} + +#snapshot-action-table { + background: $background; + border: none; +} + +#snapshot-action-options { + color: $foreground; + content-align: center middle; + padding-top: 1; + height: 2; +} + /* ---- Interactive Tools modal ---- */ InteractiveToolsModal { align: center middle; @@ -174,6 +229,29 @@ SnapshotDetailsModal { border: none; } +/* ---- Parameters modal ---- */ +ParametersModal { + align: center middle; +} + +#param-table-dialog { + background: $background; + border: solid $primary; + width: 90; + height: auto; + padding: 1 2; +} + +#param-table-dialog .modal-title { + color: $secondary; + text-style: bold; +} + +#param-modal-table { + background: $background; + border: none; +} + /* ---- Row-action menu modal ---- */ ActionMenuModal { align: center middle; diff --git a/src/aimbat/_tui/app.py b/src/aimbat/_tui/app.py index da40e1b..ab594a2 100644 --- a/src/aimbat/_tui/app.py +++ b/src/aimbat/_tui/app.py @@ -3,16 +3,15 @@ from __future__ import annotations import uuid -from collections.abc import Callable -from contextlib import suppress +from collections.abc import Callable, Generator +from contextlib import contextmanager, suppress from pathlib import Path from pandas import Timedelta, Timestamp -from pydantic import ValidationError from rich.console import Console from rich.panel import Panel from sqlalchemy.exc import NoResultFound -from sqlmodel import Session +from sqlmodel import Session, select from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding @@ -21,6 +20,7 @@ DataTable, Footer, Header, + Rule, Static, TabbedContent, TabPane, @@ -38,30 +38,41 @@ ConfirmModal, EventSwitcherModal, InteractiveToolsModal, - ParameterInputModal, + NoProjectModal, + ParametersModal, + SnapshotActionMenuModal, SnapshotCommentModal, SnapshotDetailsModal, ) -from aimbat._types import EventParameter, SeismogramParameter +from aimbat._types import SeismogramParameter from aimbat.core import ( BoundICCS, add_data_to_project, + build_iccs_from_snapshot, create_iccs_instance, + create_project, create_snapshot, + delete_event_by_id, delete_seismogram_by_id, delete_snapshot_by_id, delete_station_by_id, + get_stations_with_event_and_seismogram_count, reset_seismogram_parameters_by_id, rollback_to_snapshot_by_id, run_iccs, run_mccc, - set_event_parameter, ) +from aimbat.core._project import _project_exists from aimbat.db import engine from aimbat.io import DATATYPE_SUFFIXES, DataType -from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatSnapshot -from aimbat.models._parameters import AimbatEventParametersBase +from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatSnapshot, AimbatStation +from aimbat.models._parameters import ( + AimbatEventParametersBase, +) # used in _show_snapshot_details from aimbat.plot import ( + plot_matrix_image, + plot_seismograms, + plot_stack, update_min_ccnorm, update_pick, update_timewindow, @@ -73,18 +84,22 @@ # Extend this dict to add new per-row actions to any tab. _TAB_ROW_ACTIONS: dict[str, list[tuple[str, str]]] = { + "project-events": [ + ("select", "Select event"), + ("toggle_completed", "Toggle completed"), + ("view_seismograms", "View seismograms"), + ("delete", "Delete event"), + ], + "project-stations": [ + ("view_seismograms", "View seismograms"), + ("delete", "Delete station"), + ], "tab-seismograms": [ ("toggle_select", "Toggle select"), ("toggle_flip", "Toggle flip"), ("reset", "Reset parameters"), ("delete", "Delete seismogram"), ], - "tab-stations": [("delete", "Delete station and all seismograms (all events)")], - "tab-snapshots": [ - ("show_details", "Show details"), - ("rollback", "Rollback to this snapshot"), - ("delete", "Delete snapshot"), - ], } @@ -146,10 +161,32 @@ def _tool_ccnorm( ) +def _tool_stack( + session: Session, + event: AimbatEvent, + iccs: ICCS, + context: bool, + all_seismograms: bool, +) -> None: + plot_stack(iccs, context, all_seismograms, return_fig=False) + + +def _tool_image( + session: Session, + event: AimbatEvent, + iccs: ICCS, + context: bool, + all_seismograms: bool, +) -> None: + plot_matrix_image(iccs, context, all_seismograms, return_fig=False) + + _TOOL_REGISTRY: dict[str, tuple[str, _ToolFn]] = { "phase": ("Phase arrival (t1)", _tool_phase), "window": ("Time window", _tool_window), "ccnorm": ("Min CC norm", _tool_ccnorm), + "stack": ("Stack plot", _tool_stack), + "image": ("Matrix image", _tool_image), } @@ -167,11 +204,12 @@ class AimbatTUI(App[None]): BINDINGS = [ Binding("e", "switch_event", "Events", show=True), Binding("d", "add_data", "Add Data", show=True), - Binding("p", "open_interactive_tools", "Interactive Tools", show=True), + Binding("p", "open_parameters", "Parameters", show=True), + Binding("t", "open_interactive_tools", "Tools", show=True), Binding("a", "open_align", "Align", show=True), Binding("n", "new_snapshot", "New Snapshot", show=True), Binding("r", "refresh", "Refresh", show=True), - Binding("t", "toggle_theme", "Theme", show=True), + Binding("c", "toggle_theme", "Theme", show=True), Binding("H", "vim_left", "Vim left", show=False), Binding("L", "vim_right", "Vim right", show=False), Binding("q", "quit", "Quit", show=True), @@ -180,13 +218,15 @@ class AimbatTUI(App[None]): def compose(self) -> ComposeResult: yield Header() yield Static(id="event-bar") - with TabbedContent(initial="tab-seismograms"): + with TabbedContent(initial="tab-project"): + with TabPane("Project", id="tab-project"): + yield Static("Events", classes="project-table-title") + yield VimDataTable(id="project-event-table") + yield Rule(classes="project-divider") + yield Static("Stations", classes="project-table-title") + yield VimDataTable(id="project-station-table") with TabPane("Seismograms", id="tab-seismograms"): yield VimDataTable(id="seismogram-table") - with TabPane("Parameters", id="tab-parameters"): - yield VimDataTable(id="parameter-table") - with TabPane("Stations", id="tab-stations"): - yield VimDataTable(id="station-table") with TabPane("Snapshots", id="tab-snapshots"): yield VimDataTable(id="snapshot-table") yield Footer() @@ -200,14 +240,25 @@ def on_mount(self) -> None: self.theme = _DEFAULT_THEME + self._setup_project_tables() self._setup_seismogram_table() - self._setup_parameter_table() - self._setup_station_table() self._setup_snapshot_table() self.set_interval(5, self._check_iccs_staleness) - self._create_iccs() - self.refresh_all() + + if not _project_exists(engine): + self.push_screen(NoProjectModal(), self._on_no_project_modal) + else: + self._create_iccs() + self.refresh_all() + + def _on_no_project_modal(self, create: bool | None) -> None: + if create: + create_project(engine) + self._create_iccs() + self.refresh_all() + else: + self.exit() @on(TabbedContent.TabActivated) def on_tab_activated(self, event: TabbedContent.TabActivated) -> None: @@ -219,9 +270,13 @@ def on_tab_activated(self, event: TabbedContent.TabActivated) -> None: event.pane.query_one(DataTable).focus() def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: - tab = getattr(self, "_active_tab", "") - if action == "new_snapshot": - return True if tab == "tab-snapshots" else False + if action in { + "open_parameters", + "open_interactive_tools", + "open_align", + "new_snapshot", + }: + return self._current_event_id is not None return True # ------------------------------------------------------------------ @@ -241,6 +296,44 @@ def _get_current_event(self, session: Session) -> AimbatEvent: self._current_event_id = None raise NoResultFound("No event selected") + # ------------------------------------------------------------------ + # Suspend helper + # ------------------------------------------------------------------ + + @contextmanager + def _suspend(self, label: str | None = None) -> Generator[None, None, None]: + """Suspend Textual and handle errors gracefully. + + If ``label`` is given, a panel is shown with a "close matplotlib to + return" hint. Any exception raised inside the block is shown in the + terminal while still suspended, then re-raised after Textual has fully + resumed so callers can still react to it. + """ + console = Console() + caught: BaseException | None = None + with self.suspend(): + console.clear() + if label is not None: + console.print( + Panel( + f"[bold]{label}[/bold]\n\n" + "Close the matplotlib window to return to AIMBAT.", + title="Interactive Tool Running", + border_style="bright_blue", + padding=(1, 4), + ) + ) + try: + yield + except Exception as exc: + caught = exc + console.print(f"\n[bold red]Error:[/bold red] {exc}") + console.input("\n[dim]Press Enter to return to AIMBAT...[/dim]") + finally: + console.clear() + if caught is not None: + raise caught + # ------------------------------------------------------------------ # ICCS lifecycle # ------------------------------------------------------------------ @@ -286,6 +379,34 @@ def _assign_iccs(self, bound_iccs: BoundICCS) -> None: # Table setup # ------------------------------------------------------------------ + def _setup_project_tables(self) -> None: + et = self.query_one("#project-event-table", DataTable) + et.cursor_type = "row" + et.add_columns( + " ", + "ID", + "Time (UTC)", + "Lat °", + "Lon °", + "Depth km", + "Stations", + "Seismograms", + "Completed", + ) + st = self.query_one("#project-station-table", DataTable) + st.cursor_type = "row" + st.add_columns( + "ID", + "Network", + "Name", + "Location", + "Channel", + "Lat °", + "Lon °", + "Elev m", + "Seismograms", + ) + def _setup_seismogram_table(self) -> None: t = self.query_one("#seismogram-table", DataTable) t.cursor_type = "row" @@ -293,18 +414,6 @@ def _setup_seismogram_table(self) -> None: "ID", "Network", "Station", "Channel", "Select", "Flip", "Δt (s)", "CC" ) - def _setup_parameter_table(self) -> None: - t = self.query_one("#parameter-table", DataTable) - t.cursor_type = "row" - t.add_columns("Parameter", "Value", "Description") - - def _setup_station_table(self) -> None: - t = self.query_one("#station-table", DataTable) - t.cursor_type = "row" - t.add_columns( - "ID", "Network", "Name", "Location", "Channel", "Lat °", "Lon °", "Elev m" - ) - def _setup_snapshot_table(self) -> None: t = self.query_one("#snapshot-table", DataTable) t.cursor_type = "row" @@ -315,12 +424,80 @@ def _setup_snapshot_table(self) -> None: # ------------------------------------------------------------------ def refresh_all(self) -> None: + self.refresh_bindings() self._refresh_event_bar() + self._refresh_project() self._refresh_seismograms() - self._refresh_parameters() - self._refresh_stations() self._refresh_snapshots() + def _refresh_project(self) -> None: + et = self.query_one("#project-event-table", DataTable) + st = self.query_one("#project-station-table", DataTable) + et_saved, st_saved = et.cursor_row, st.cursor_row + et.clear() + st.clear() + with suppress(Exception): + with Session(engine) as session: + events = session.exec(select(AimbatEvent)).all() + stations = get_stations_with_event_and_seismogram_count(session) + for event in events: + marker = "▶" if event.id == self._current_event_id else " " + short_id = str(event.id)[:8] + time_str = str(event.time)[:19] if event.time else "—" + lat = f"{event.latitude:.3f}" if event.latitude is not None else "—" + lon = ( + f"{event.longitude:.3f}" if event.longitude is not None else "—" + ) + depth = ( + f"{event.depth / 1000:.1f}" if event.depth is not None else "—" + ) + done = "✓" if event.parameters.completed else " " + et.add_row( + marker, + short_id, + time_str, + lat, + lon, + depth, + str(event.station_count), + str(event.seismogram_count), + done, + key=str(event.id), + ) + for station, seis_count, _event_count in stations: + short_id = str(station.id)[:8] + lat = ( + f"{station.latitude:.3f}" + if station.latitude is not None + else "—" + ) + lon = ( + f"{station.longitude:.3f}" + if station.longitude is not None + else "—" + ) + elev = ( + f"{station.elevation:.0f}" + if station.elevation is not None + else "—" + ) + st.add_row( + short_id, + station.network, + station.name, + station.location or "—", + station.channel, + lat, + lon, + elev, + str(seis_count), + key=str(station.id), + ) + if et.row_count > 0: + et.move_cursor(row=min(et_saved, et.row_count - 1)) + if st.row_count > 0: + st.move_cursor(row=min(st_saved, st.row_count - 1)) + def _check_iccs_staleness(self) -> None: """Trigger ICCS recreation if the current event has been modified externally. @@ -367,7 +544,12 @@ def _refresh_event_bar(self) -> None: f" [dim]{iccs_status} e = switch event[/dim]" ) except NoResultFound: - bar.update("[red]No event selected — press e to select one[/red]") + with Session(engine) as session: + has_events = session.exec(select(AimbatEvent)).first() is not None + if has_events: + bar.update("[red]No event selected — press e to select one[/red]") + else: + bar.update("[red]No data in project — press d to add data[/red]") except RuntimeError as exc: bar.update(f"[red]{exc}[/red]") @@ -421,60 +603,6 @@ def _refresh_seismograms(self) -> None: if table.row_count > 0: table.move_cursor(row=min(saved_row, table.row_count - 1)) - def _refresh_parameters(self) -> None: - table = self.query_one("#parameter-table", DataTable) - saved_row = table.cursor_row - table.clear() - with suppress(NoResultFound, RuntimeError): - with Session(engine) as session: - event = self._get_current_event(session) - p = event.parameters - for attr, field_info in AimbatEventParametersBase.model_fields.items(): - value = getattr(p, attr) - if isinstance(value, bool): - display = "✓" if value else "✗" - elif isinstance(value, Timedelta): - display = f"{value.total_seconds():.2f}" - else: - display = f"{value}" - label = field_info.title or attr - desc = field_info.description or "" - table.add_row(label, display, desc, key=attr) - if table.row_count > 0: - table.move_cursor(row=min(saved_row, table.row_count - 1)) - - def _refresh_stations(self) -> None: - table = self.query_one("#station-table", DataTable) - saved_row = table.cursor_row - table.clear() - with suppress(NoResultFound, RuntimeError): - with Session(engine) as session: - event = self._get_current_event(session) - seen: set[uuid.UUID] = set() - for seis in event.seismograms: - st = seis.station - if st and st.id not in seen: - seen.add(st.id) - short_id = str(st.id)[:8] - lat = f"{st.latitude:.3f}" if st.latitude is not None else "—" - lon = f"{st.longitude:.3f}" if st.longitude is not None else "—" - elev = ( - f"{st.elevation:.0f}" if st.elevation is not None else "—" - ) - table.add_row( - short_id, - st.network, - st.name, - st.location or "—", - st.channel, - lat, - lon, - elev, - key=str(st.id), - ) - if table.row_count > 0: - table.move_cursor(row=min(saved_row, table.row_count - 1)) - def _refresh_snapshots(self) -> None: table = self.query_one("#snapshot-table", DataTable) saved_row = table.cursor_row @@ -502,112 +630,54 @@ def _refresh_snapshots(self) -> None: table.move_cursor(row=min(saved_row, table.row_count - 1)) # ------------------------------------------------------------------ - # Parameter editing + # Row event handlers # ------------------------------------------------------------------ - @on(DataTable.RowSelected, "#seismogram-table") - def seismogram_row_selected(self, event: DataTable.RowSelected) -> None: + @on(DataTable.RowSelected, "#project-event-table") + def project_event_row_selected(self, event: DataTable.RowSelected) -> None: if event.row_key.value: self._open_row_action_menu( - "tab-seismograms", + "project-events", event.row_key.value, - f"Seismogram {event.row_key.value[:8]}", + f"Event {event.row_key.value[:8]}", ) - @on(DataTable.RowSelected, "#station-table") - def station_row_selected(self, event: DataTable.RowSelected) -> None: + @on(DataTable.RowSelected, "#project-station-table") + def project_station_row_selected(self, event: DataTable.RowSelected) -> None: if event.row_key.value: self._open_row_action_menu( - "tab-stations", + "project-stations", event.row_key.value, f"Station {event.row_key.value[:8]}", ) - @on(DataTable.RowSelected, "#snapshot-table") - def snapshot_row_selected(self, event: DataTable.RowSelected) -> None: + @on(DataTable.RowSelected, "#seismogram-table") + def seismogram_row_selected(self, event: DataTable.RowSelected) -> None: if event.row_key.value: self._open_row_action_menu( - "tab-snapshots", + "tab-seismograms", event.row_key.value, - f"Snapshot {event.row_key.value[:8]}", + f"Seismogram {event.row_key.value[:8]}", ) - @on(DataTable.RowSelected, "#parameter-table") - def parameter_row_selected(self, event: DataTable.RowSelected) -> None: - attr = event.row_key.value - if not attr: - return - self._edit_parameter(attr) - - def _edit_parameter(self, attr: str) -> None: - """Toggle a bool parameter, or open input modal for others.""" - try: - with Session(engine) as session: - event = self._get_current_event(session) - current = getattr(event.parameters, attr) - except (NoResultFound, RuntimeError) as exc: - self.notify(str(exc), severity="error") - return - - if isinstance(current, bool): - self._apply_parameter(attr, not current) + @on(DataTable.RowSelected, "#snapshot-table") + def snapshot_row_selected(self, event: DataTable.RowSelected) -> None: + snap_id = event.row_key.value + if not snap_id: return - # Numeric / timedelta — open input modal - if isinstance(current, Timedelta): - current_str = f"{current.total_seconds():.2f}" - unit = "s" - else: - current_str = f"{current}" - unit = "" - - def on_input(raw: str | None) -> None: - if raw is None: + def on_action(result: tuple[str, bool, bool] | None) -> None: + if result is None: return - try: - if isinstance(current, Timedelta): - new_val: object = Timedelta(seconds=float(raw)) - else: - new_val = float(raw) - self._apply_parameter(attr, new_val) - except ValueError as exc: - self.notify(str(exc), severity="error") - - label = AimbatEventParametersBase.model_fields[attr].title or attr - self.push_screen(ParameterInputModal(label, current_str, unit), on_input) + action, context, all_seis = result + if action == "preview_stack": + self._preview_snapshot_plot(snap_id, "stack", context, all_seis) + elif action == "preview_image": + self._preview_snapshot_plot(snap_id, "image", context, all_seis) + else: + self._handle_row_action("tab-snapshots", snap_id, action) - def _apply_parameter(self, attr: str, value: object) -> None: - """Validate, write a parameter to the DB, and rebuild ICCS.""" - try: - with Session(engine) as session: - event = self._get_current_event(session) - if attr in {p.value for p in EventParameter}: - set_event_parameter( - session, event, EventParameter(attr), value, validate_iccs=True - ) # type: ignore[call-overload] - else: - # mccc_damp / mccc_min_ccnorm — not in EventParameter enum - validated = AimbatEventParametersBase.model_validate( - event.parameters, update={attr: value} - ) - setattr(event.parameters, attr, getattr(validated, attr)) - session.add(event) - session.commit() - except ValidationError as exc: - msgs = "; ".join( - e["msg"].removeprefix("Value error, ") for e in exc.errors() - ) - self.notify(msgs, severity="error") - return - except Exception as exc: - self.notify(str(exc), severity="error") - return - - self._create_iccs() - self._refresh_parameters() - self._refresh_seismograms() - self._refresh_event_bar() - self.notify(f"{attr} updated", timeout=2) + self.push_screen(SnapshotActionMenuModal(f"Snapshot {snap_id[:8]}"), on_action) # ------------------------------------------------------------------ # Row-action menu helpers @@ -626,6 +696,12 @@ def on_action(action: str | None) -> None: def _handle_row_action(self, tab: str, item_id: str, action: str | None) -> None: if action == "delete": self._confirm_delete(tab, item_id) + elif action == "select": + self._select_event(item_id) + elif action == "toggle_completed": + self._toggle_event_completed(item_id) + elif action == "view_seismograms": + self._view_seismograms(tab, item_id) elif action == "rollback": self._confirm_rollback(item_id) elif action == "show_details": @@ -637,6 +713,44 @@ def _handle_row_action(self, tab: str, item_id: str, action: str | None) -> None elif action == "reset": self._reset_seismogram_parameters(item_id) + def _select_event(self, item_id: str) -> None: + self._current_event_id = uuid.UUID(item_id) + self._create_iccs() + self.refresh_all() + self.notify("Event selected", timeout=2) + + def _toggle_event_completed(self, item_id: str) -> None: + try: + with Session(engine) as session: + event = session.get(AimbatEvent, uuid.UUID(item_id)) + if event is None: + return + event.parameters.completed = not event.parameters.completed + session.add(event) + session.commit() + self._refresh_project() + self.notify("Completed flag toggled", timeout=2) + except Exception as exc: + self.notify(str(exc), severity="error") + + def _view_seismograms(self, tab: str, item_id: str) -> None: + item_uuid = uuid.UUID(item_id) + try: + with self._suspend("View seismograms"): + with Session(engine) as session: + if tab == "project-events": + event = session.get(AimbatEvent, item_uuid) + if event is None: + return + plot_seismograms(session, event, return_fig=False) + else: + station = session.get(AimbatStation, item_uuid) + if station is None: + return + plot_seismograms(session, station, return_fig=False) + except Exception as exc: + self.notify(str(exc), severity="error") + def _toggle_seismogram_bool(self, item_id: str, param: SeismogramParameter) -> None: try: seis_uuid = uuid.UUID(item_id) @@ -671,8 +785,9 @@ def _reset_seismogram_parameters(self, item_id: str) -> None: def _confirm_delete(self, tab: str, item_id: str) -> None: messages = { + "project-events": "Delete this event and all its data?", + "project-stations": "Delete this station and all its seismograms?", "tab-seismograms": "Delete this seismogram?", - "tab-stations": "Delete this station and all its seismograms across all events?", "tab-snapshots": "Delete this snapshot?", } msg = messages.get(tab) @@ -683,18 +798,26 @@ def on_confirm(confirmed: bool | None) -> None: if not confirmed: return try: - if tab == "tab-seismograms": + if tab == "project-events": with Session(engine) as session: - delete_seismogram_by_id(session, uuid.UUID(item_id)) - self._create_iccs() + delete_event_by_id(session, uuid.UUID(item_id)) + if self._current_event_id == uuid.UUID(item_id): + self._current_event_id = None + self._bound_iccs = None self.refresh_all() - self.notify("Seismogram deleted", timeout=2) - elif tab == "tab-stations": + self.notify("Event deleted", timeout=2) + elif tab == "project-stations": with Session(engine) as session: delete_station_by_id(session, uuid.UUID(item_id)) self._create_iccs() self.refresh_all() self.notify("Station deleted", timeout=2) + elif tab == "tab-seismograms": + with Session(engine) as session: + delete_seismogram_by_id(session, uuid.UUID(item_id)) + self._create_iccs() + self.refresh_all() + self.notify("Seismogram deleted", timeout=2) elif tab == "tab-snapshots": with Session(engine) as session: delete_snapshot_by_id(session, uuid.UUID(item_id)) @@ -727,6 +850,20 @@ def _show_snapshot_details(self, snap_id: str) -> None: except Exception as exc: self.notify(str(exc), severity="error") + def _preview_snapshot_plot( + self, snap_id: str, plot_type: str, context: bool, all_seis: bool + ) -> None: + try: + with self._suspend("Previewing snapshot"): + with Session(engine) as session: + bound = build_iccs_from_snapshot(session, uuid.UUID(snap_id)) + if plot_type == "stack": + plot_stack(bound.iccs, context, all_seis, return_fig=False) + else: + plot_matrix_image(bound.iccs, context, all_seis, return_fig=False) + except Exception as exc: + self.notify(str(exc), severity="error") + def _confirm_rollback(self, snap_id: str) -> None: def on_confirm(confirmed: bool | None) -> None: if not confirmed: @@ -746,6 +883,22 @@ def on_confirm(confirmed: bool | None) -> None: # Actions # ------------------------------------------------------------------ + def action_open_parameters(self) -> None: + try: + with Session(engine) as session: + event = self._get_current_event(session) + event_id = event.id + except NoResultFound: + self.notify("No event selected — press e to select one", severity="warning") + return + + def on_close(changed: bool | None) -> None: + if changed: + self._create_iccs() + self.refresh_all() + + self.push_screen(ParametersModal(event_id), on_close) + def action_switch_event(self) -> None: def on_result(result: uuid.UUID | None) -> None: if result is not None: @@ -830,27 +983,14 @@ def _run_tool(self, tool: str, context: bool, all_seis: bool) -> None: iccs = self._bound_iccs.iccs try: - with self.suspend(): - console = Console() - console.clear() - console.print( - Panel( - f"[bold]{label}[/bold]\n\n" - "Close the matplotlib window to return to AIMBAT.", - title="Interactive Tool Running", - border_style="bright_blue", - padding=(1, 4), - ) - ) + with self._suspend(label): with Session(engine) as session: event = self._get_current_event(session) fn(session, event, iccs, context, all_seis) - console.clear() except Exception as exc: self.notify(str(exc), severity="error") return self._bound_iccs.created_at = Timestamp.now("UTC") - self._refresh_parameters() self._refresh_seismograms() self._refresh_event_bar() self.notify("Done", timeout=2) diff --git a/src/aimbat/_tui/modals.py b/src/aimbat/_tui/modals.py index 0e12bb0..684ceeb 100644 --- a/src/aimbat/_tui/modals.py +++ b/src/aimbat/_tui/modals.py @@ -5,6 +5,8 @@ import uuid from enum import StrEnum +from pandas import Timedelta +from pydantic import ValidationError from sqlmodel import Session, select from textual import on from textual.app import ComposeResult @@ -14,9 +16,11 @@ from textual.widgets import DataTable, Input, Label, Static from aimbat._tui._widgets import VimDataTable -from aimbat.core import delete_event_by_id +from aimbat._types import EventParameter +from aimbat.core import delete_event_by_id, set_event_parameter from aimbat.db import engine from aimbat.models import AimbatEvent +from aimbat.models._parameters import AimbatEventParametersBase class _CSS(StrEnum): @@ -37,6 +41,7 @@ class _Hint(StrEnum): NAVIGATE_RUN_CANCEL = "↑↓ navigate [@click='screen.select']⏎ run[/] [@click='screen.cancel']⎋ cancel[/]" CONFIRM_CANCEL = "[@click='screen.confirm'][bold]y[/bold] / ⏎ confirm[/] [@click='screen.cancel'][bold]n[/bold] / ⎋ cancel[/]" CLOSE = "[@click='screen.cancel']⎋ close[/]" + NAVIGATE_EDIT_CLOSE = "↑↓ navigate [@click='screen.select']⏎ edit[/] [@click='screen.cancel']⎋ close[/]" __all__ = [ @@ -45,7 +50,10 @@ class _Hint(StrEnum): "ConfirmModal", "EventSwitcherModal", "InteractiveToolsModal", + "NoProjectModal", "ParameterInputModal", + "ParametersModal", + "SnapshotActionMenuModal", "SnapshotCommentModal", "SnapshotDetailsModal", ] @@ -85,8 +93,7 @@ def on_mount(self) -> None: table = self.query_one(DataTable) table.cursor_type = "row" table.add_columns( - "", - "", + " ", "ID", "Time (UTC)", "Lat °", @@ -94,6 +101,7 @@ def on_mount(self) -> None: "Depth km", "Seismograms", "Stations", + "Completed", ) self._populate(table) @@ -116,7 +124,6 @@ def _populate(self, table: DataTable) -> None: ) table.add_row( marker, - done_marker, short_id, time_str, lat, @@ -124,6 +131,7 @@ def _populate(self, table: DataTable) -> None: depth, str(event.seismogram_count), str(event.station_count), + done_marker, key=str(event.id), ) except RuntimeError as exc: @@ -235,6 +243,43 @@ def action_cancel(self) -> None: self.dismiss(None) +# --------------------------------------------------------------------------- +# No-project modal +# --------------------------------------------------------------------------- + + +class NoProjectModal(ModalScreen[bool]): + """Shown on startup when no project exists. + + Dismisses True if the user chose to create a project, False to quit. + """ + + BINDINGS = [ + Binding("c", "create", show=False), + Binding("enter", "create", show=False), + Binding("q", "quit_app", show=False), + Binding("escape", "quit_app", show=False), + ] + + def compose(self) -> ComposeResult: + with Container(id="confirm-dialog"): + yield Label( + "No project found in the current directory.", classes=_CSS.TITLE + ) + yield Label( + "[@click='screen.create'][bold]c[/bold] / ⏎ create project[/]" + " " + "[@click='screen.quit_app'][bold]q[/bold] / ⎋ quit[/]", + classes=_CSS.HINT, + ) + + def action_create(self) -> None: + self.dismiss(True) + + def action_quit_app(self) -> None: + self.dismiss(False) + + # --------------------------------------------------------------------------- # Confirm modal # --------------------------------------------------------------------------- @@ -309,6 +354,140 @@ def action_cancel(self) -> None: self.dismiss(None) +# --------------------------------------------------------------------------- +# Parameters modal +# --------------------------------------------------------------------------- + + +class ParametersModal(ModalScreen[bool]): + """View and edit all event processing parameters inline. + + Dismisses with True if any parameter was changed, False otherwise. + """ + + BINDINGS = [Binding("escape", "cancel", show=False)] + + def __init__(self, event_id: uuid.UUID) -> None: + super().__init__() + self._event_id = event_id + self._changed = False + + def compose(self) -> ComposeResult: + with Container(id="param-table-dialog"): + yield Label("Parameters", classes=_CSS.TITLE) + yield VimDataTable(id="param-modal-table", show_header=True) + yield Label(_Hint.NAVIGATE_EDIT_CLOSE, classes=_CSS.HINT) + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.cursor_type = "row" + table.add_columns("Parameter", "Value", "Description") + self._populate() + table.focus() + + def _populate(self) -> None: + table = self.query_one("#param-modal-table", DataTable) + saved_row = table.cursor_row + table.clear() + with Session(engine) as session: + event = session.get(AimbatEvent, self._event_id) + if event is None: + return + fields = list(AimbatEventParametersBase.model_fields.items()) + p = event.parameters + for attr, field_info in fields: + value = getattr(p, attr) + if isinstance(value, bool): + display = "✓" if value else "✗" + elif isinstance(value, Timedelta): + display = f"{value.total_seconds():.2f}" + else: + display = f"{value}" + label = field_info.title or attr + desc = field_info.description or "" + table.add_row(label, display, desc, key=attr) + table.styles.height = len(fields) + 2 + if table.row_count > 0: + table.move_cursor(row=min(saved_row, table.row_count - 1)) + + @on(DataTable.RowSelected) + def row_selected(self, event: DataTable.RowSelected) -> None: + attr = event.row_key.value + if not attr: + return + self._edit_parameter(attr) + + def _edit_parameter(self, attr: str) -> None: + with Session(engine) as session: + ev = session.get(AimbatEvent, self._event_id) + if ev is None: + return + current = getattr(ev.parameters, attr) + + if isinstance(current, bool): + self._apply_parameter(attr, not current) + return + + if isinstance(current, Timedelta): + current_str = f"{current.total_seconds():.2f}" + unit = "s" + else: + current_str = f"{current}" + unit = "" + + def on_input(raw: str | None) -> None: + if raw is None: + return + try: + if isinstance(current, Timedelta): + new_val: object = Timedelta(seconds=float(raw)) + else: + new_val = float(raw) + self._apply_parameter(attr, new_val) + except ValueError as exc: + self.notify(str(exc), severity="error") + + label = AimbatEventParametersBase.model_fields[attr].title or attr + self.app.push_screen(ParameterInputModal(label, current_str, unit), on_input) + + def _apply_parameter(self, attr: str, value: object) -> None: + try: + with Session(engine) as session: + event = session.get(AimbatEvent, self._event_id) + if event is None: + return + if attr in {p.value for p in EventParameter}: + set_event_parameter( + session, event, EventParameter(attr), value, validate_iccs=True + ) # type: ignore[call-overload] + else: + # mccc_damp / mccc_min_ccnorm — not in EventParameter enum + validated = AimbatEventParametersBase.model_validate( + event.parameters, update={attr: value} + ) + setattr(event.parameters, attr, getattr(validated, attr)) + session.add(event) + session.commit() + except ValidationError as exc: + msgs = "; ".join( + e["msg"].removeprefix("Value error, ") for e in exc.errors() + ) + self.notify(msgs, severity="error") + return + except Exception as exc: + self.notify(str(exc), severity="error") + return + self._changed = True + self.notify(f"{attr} updated", timeout=2) + self._populate() + + def action_select(self) -> None: + self.query_one(DataTable).action_select_cursor() + + def action_cancel(self) -> None: + self.dismiss(self._changed) + + # --------------------------------------------------------------------------- # Row-action context menu modal # --------------------------------------------------------------------------- @@ -358,6 +537,97 @@ def action_cancel(self) -> None: self.dismiss(None) +# --------------------------------------------------------------------------- +# Snapshot action menu modal +# --------------------------------------------------------------------------- + +_SNAPSHOT_ACTIONS: list[tuple[str, str]] = [ + ("show_details", "Show details"), + ("preview_stack", "Preview stack"), + ("preview_image", "Preview matrix image"), + ("rollback", "Rollback to this snapshot"), + ("delete", "Delete snapshot"), +] + +_PREVIEW_ACTIONS: frozenset[str] = frozenset({"preview_stack", "preview_image"}) + + +class SnapshotActionMenuModal(ModalScreen[tuple[str, bool, bool] | None]): + """Action menu for a snapshot row. + + Shows context/all-seismograms toggles dynamically when a preview action + is highlighted. Dismisses with (action, context, all_seismograms) or None. + """ + + BINDINGS = [ + Binding("escape", "cancel", show=False), + Binding("c", "toggle_context", show=False), + Binding("a", "toggle_all", show=False), + ] + + def __init__(self, title: str) -> None: + super().__init__() + self._title = title + self._use_context = True + self._all_seis = False + self._highlighted: str = "" + + def compose(self) -> ComposeResult: + with Container(id="snapshot-action-dialog"): + yield Label(self._title, classes=_CSS.TITLE) + yield VimDataTable(id="snapshot-action-table", show_header=False) + yield Static(id="snapshot-action-options") + yield Label(_Hint.NAVIGATE_SELECT_CANCEL, classes=_CSS.HINT) + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.cursor_type = "row" + table.add_column("action") + for key, label in _SNAPSHOT_ACTIONS: + table.add_row(label, key=key) + table.styles.height = len(_SNAPSHOT_ACTIONS) + table.focus() + + def _update_options(self) -> None: + opts = self.query_one("#snapshot-action-options", Static) + if self._highlighted in _PREVIEW_ACTIONS: + ctx = "✓" if self._use_context else "✗" + al = "✓" if self._all_seis else "✗" + opts.update( + f" [@click='screen.toggle_context'][dim]c[/dim] context: {ctx}[/]" + f" [@click='screen.toggle_all'][dim]a[/dim] all seismograms: {al}[/]" + ) + else: + opts.update("") + + @on(DataTable.RowHighlighted, "#snapshot-action-table") + def row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._highlighted = event.row_key.value or "" + self._update_options() + + @on(DataTable.RowSelected, "#snapshot-action-table") + def row_selected(self, event: DataTable.RowSelected) -> None: + key = event.row_key.value + if key: + self.dismiss((key, self._use_context, self._all_seis)) + + def action_toggle_context(self) -> None: + if self._highlighted in _PREVIEW_ACTIONS: + self._use_context = not self._use_context + self._update_options() + + def action_toggle_all(self) -> None: + if self._highlighted in _PREVIEW_ACTIONS: + self._all_seis = not self._all_seis + self._update_options() + + def action_select(self) -> None: + self.query_one(DataTable).action_select_cursor() + + def action_cancel(self) -> None: + self.dismiss(None) + + # --------------------------------------------------------------------------- # Interactive Tools modal # --------------------------------------------------------------------------- @@ -367,6 +637,8 @@ def action_cancel(self) -> None: ("phase", "Phase arrival (t1)"), ("window", "Time window"), ("ccnorm", "Min CC norm"), + ("stack", "Stack plot"), + ("image", "Matrix image"), ] @@ -390,7 +662,7 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: with Container(id="tools-dialog"): - yield Label("Interactive Tools", classes=_CSS.TITLE) + yield Label("Tools", classes=_CSS.TITLE) yield VimDataTable(id="tools-table", show_header=False) yield Static(id="tools-options") yield Label( @@ -411,8 +683,8 @@ def _update_options(self) -> None: ctx = "✓" if self._use_context else "✗" al = "✓" if self._all_seis else "✗" self.query_one("#tools-options", Static).update( - f" [@click='screen.toggle_context'][dim]c[/dim] Context: {ctx}[/]" - f" [@click='screen.toggle_all'][dim]a[/dim] All seismograms: {al}[/]" + f" [@click='screen.toggle_context'][dim]c[/dim] context: {ctx}[/]" + f" [@click='screen.toggle_all'][dim]a[/dim] all seismograms: {al}[/]" ) @on(DataTable.RowSelected, "#tools-table") diff --git a/tests/functional/test_tui.py b/tests/functional/test_tui.py index 6291efe..47d0a11 100644 --- a/tests/functional/test_tui.py +++ b/tests/functional/test_tui.py @@ -54,10 +54,10 @@ async def _run() -> None: asyncio.run(_run()) - def test_four_tabs_present( + def test_three_tabs_present( self, patched_engine: Engine, monkeypatch: pytest.MonkeyPatch ) -> None: - """The four expected tab panes are mounted.""" + """The three expected tab panes are mounted.""" _patch_engine(monkeypatch, patched_engine) async def _run() -> None: @@ -65,26 +65,25 @@ async def _run() -> None: await pilot.pause() tab_ids = {pane.id for pane in pilot.app.query(TabPane)} for expected in ( + "tab-project", "tab-seismograms", - "tab-parameters", - "tab-stations", "tab-snapshots", ): assert expected in tab_ids asyncio.run(_run()) - def test_event_bar_shows_no_event_message( + def test_event_bar_shows_no_data_message( self, patched_engine: Engine, monkeypatch: pytest.MonkeyPatch ) -> None: - """Event bar indicates that no event is selected when the DB is empty.""" + """Event bar indicates that no data exists when the DB has no events.""" _patch_engine(monkeypatch, patched_engine) async def _run() -> None: async with AimbatTUI().run_test(size=_TUI_SIZE) as pilot: await pilot.pause() bar = pilot.app.query_one("#event-bar", Static) - assert "No event" in str(bar.render()) + assert "No data" in str(bar.render()) asyncio.run(_run()) @@ -157,29 +156,6 @@ async def _run() -> None: asyncio.run(_run()) - def test_station_table_populated( - self, loaded_engine: Engine, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Station table has rows once an event is selected.""" - _patch_engine(monkeypatch, loaded_engine) - - async def _run() -> None: - async with AimbatTUI().run_test(size=_TUI_SIZE) as pilot: - with Session(loaded_engine) as session: - event = session.exec(select(AimbatEvent)).first() - assert event is not None - app = cast(AimbatTUI, pilot.app) - app._current_event_id = event.id - app.refresh_all() - await pilot.pause(delay=0.5) - await pilot.press("L") # switch to next tab - await pilot.press("L") - await pilot.pause() - table = pilot.app.query_one("#station-table", DataTable) - assert table.row_count > 0 - - asyncio.run(_run()) - def test_snapshot_table_empty_initially( self, loaded_engine: Engine, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -249,12 +225,12 @@ async def _run() -> None: await pilot.pause() tc = pilot.app.query_one(TabbedContent) visited: list[str] = [tc.active] - for _ in range(3): + for _ in range(2): await pilot.press("L") await pilot.pause() visited.append(tc.active) - assert len(set(visited)) == 4, ( - f"Expected 4 distinct tabs, got {visited}" + assert len(set(visited)) == 3, ( + f"Expected 3 distinct tabs, got {visited}" ) asyncio.run(_run()) diff --git a/uv.lock b/uv.lock index 8953a65..d772247 100644 --- a/uv.lock +++ b/uv.lock @@ -1475,14 +1475,14 @@ wheels = [ [[package]] name = "optype" -version = "0.16.0" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/d3/c88bb4bd90867356275ca839499313851af4b36fce6919ebc5e1de26e7ca/optype-0.16.0.tar.gz", hash = "sha256:fa682fd629ef6b70ba656ebc9fdd6614ba06ce13f52e0416dd8014c7e691a2d1", size = 53498, upload-time = "2026-02-19T23:37:09.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9f/3b13bab05debf685678b8af004e46b8c67c6f98ffa08eaf5d33bcf162c16/optype-0.17.0.tar.gz", hash = "sha256:31351a1e64d9eba7bf67e14deefb286e85c66458db63c67dd5e26dd72e4664e5", size = 53484, upload-time = "2026-03-08T23:03:12.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a8/fe26515203cff140f1afc31236fb7f703d4bb4bd5679d28afcb3661c8d9f/optype-0.16.0-py3-none-any.whl", hash = "sha256:c28905713f55630b4bb8948f38e027ad13a541499ebcf957501f486da54b74d2", size = 65893, upload-time = "2026-02-19T23:37:08.217Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/dca78187415947d1bb90b2ee2a58e47d9573528331e8dc6196996b53612a/optype-0.17.0-py3-none-any.whl", hash = "sha256:8c2d88ff13149454bcf6eb47502f80d288bc542e7238fcc412ac4d222c439397", size = 65854, upload-time = "2026-03-08T23:03:11.425Z" }, ] [package.optional-dependencies] @@ -2237,14 +2237,14 @@ wheels = [ [[package]] name = "scipy-stubs" -version = "1.17.1.0" +version = "1.17.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "optype", extra = ["numpy"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/ad/413b0d18efca7bb48574d28e91253409d91ee6121e7937022d0d380dfc6a/scipy_stubs-1.17.1.0.tar.gz", hash = "sha256:5dc51c21765b145c2d132b96b63ff4f835dd5fb768006876d1554e7a59c61571", size = 381420, upload-time = "2026-02-23T10:33:04.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/c1/bf50e1cbf4015d4077835ffebea9a45dba7f5bf1cd781e931baff358cb20/scipy_stubs-1.17.1.1.tar.gz", hash = "sha256:e8f812a6eb298cfa3dd6b79a4af2b00fb4045f2ee28eadcc0eee8882208b5f1a", size = 383682, upload-time = "2026-03-08T21:49:40.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/ee/c6811e04ff9d5dd1d92236e8df7ebc4db6aa65c70b9938cec293348b8ec4/scipy_stubs-1.17.1.0-py3-none-any.whl", hash = "sha256:5c9c84993d36b104acb2d187b05985eb79f73491c60d83292dd738093d53d96a", size = 587059, upload-time = "2026-02-23T10:33:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/f6/23/15d568084d57cc99ba9f662f06c0fa647348b809df28bef1907aa8149eec/scipy_stubs-1.17.1.1-py3-none-any.whl", hash = "sha256:26576c5ed0f9007a07f4f4582f984c9bfac00ac93c268c6e558ad5ead0312b43", size = 589301, upload-time = "2026-03-08T21:49:38.794Z" }, ] [[package]] diff --git a/zensical.toml b/zensical.toml index 725872a..eabf87a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -19,10 +19,14 @@ nav = [ { "Usage" = [ { "Using AIMBAT" = "usage/index.md" }, { "Aimbat Defaults" = "usage/defaults.md" }, - { "Command line" = "usage/cli.md" }, - { "Shell" = "usage/shell.md" }, - { "Terminal UI" = "usage/tui.md" }, - { "Graphical UI" = "usage/gui.md" }, + { "Project" = "usage/project.md" }, + { "Adding Data" = "usage/data.md" }, + { "Selecting an Event" = "usage/event-selection.md" }, + { "Initial Inspection" = "usage/inspection.md" }, + { "The ICCS Stack" = "usage/iccs-stack.md" }, + { "Aligning with ICCS" = "usage/alignment.md" }, + { "Snapshots" = "usage/snapshots.md" }, + { "Finalising with MCCC" = "usage/mccc.md" }, { "Python API" = "usage/api.md" }, ] }, { "API reference" = [