From eb19d53c3589b33dc3836761c803d4df4b4e3156 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 5 Mar 2026 18:39:13 +0000 Subject: [PATCH] refactor: active event -> default event Allows processing any event, more flexible than the single active event workflow --- docs/first-steps/core-concepts.md | 73 +++---- docs/first-steps/data.md | 147 +++++-------- docs/first-steps/installation.md | 78 ++----- docs/first-steps/workflow.md | 94 ++++----- docs/usage/cli.md | 124 ++++++++++- docs/usage/index.md | 29 +++ docs/usage/tui.md | 186 +++++++++++++++++ src/aimbat/__init__.py | 18 +- src/aimbat/_cli/align.py | 18 +- src/aimbat/_cli/common.py | 115 +++++++---- src/aimbat/_cli/data.py | 16 +- src/aimbat/_cli/event.py | 65 +++--- src/aimbat/_cli/pick.py | 30 +-- src/aimbat/_cli/plot.py | 26 +-- src/aimbat/_cli/project.py | 28 ++- src/aimbat/_cli/seismogram.py | 36 ++-- src/aimbat/_cli/snapshot.py | 32 +-- src/aimbat/_cli/station.py | 14 +- src/aimbat/_tui/app.py | 215 +++++++++++++------- src/aimbat/_tui/modals.py | 60 ++++-- src/aimbat/core/__init__.py | 8 +- src/aimbat/core/_active_event.py | 93 --------- src/aimbat/core/_default_event.py | 113 ++++++++++ src/aimbat/core/_iccs.py | 8 +- src/aimbat/core/_project.py | 18 +- src/aimbat/core/_snapshot.py | 4 +- src/aimbat/io/_base.py | 8 +- src/aimbat/models/__init__.py | 4 +- src/aimbat/models/_models.py | 4 +- src/aimbat/models/_readers.py | 4 +- tests/conftest.py | 8 +- tests/functional/test_cli_basic_ops.py | 50 ++--- tests/functional/test_cli_parameters.py | 46 ++--- tests/functional/test_cli_snapshots.py | 16 +- tests/integration/core/test_data.py | 10 +- tests/integration/core/test_event.py | 169 +++++++-------- tests/integration/core/test_seismogram.py | 60 +++--- tests/integration/core/test_snapshots.py | 124 +++++------ tests/integration/core/test_station.py | 66 +++--- tests/integration/models/test_models.py | 84 ++++---- tests/integration/models/test_operations.py | 46 ++--- tests/unit/_cli/test_common.py | 12 +- uv.lock | 6 +- 43 files changed, 1331 insertions(+), 1034 deletions(-) delete mode 100644 src/aimbat/core/_active_event.py create mode 100644 src/aimbat/core/_default_event.py diff --git a/docs/first-steps/core-concepts.md b/docs/first-steps/core-concepts.md index 0276c5c..f738336 100644 --- a/docs/first-steps/core-concepts.md +++ b/docs/first-steps/core-concepts.md @@ -1,43 +1,34 @@ # Core concepts -## Motivation - -Precise phase arrival picks are the foundation of travel time tomography — -the accuracy of the resulting images of Earth's interior depends directly on -the quality of these measurements. Obtaining them requires picking the phase -arrival and assessing data quality for every seismogram, across every event -in the dataset. With modern seismic arrays recording each earthquake on -increasingly large numbers of seismometers, doing this seismogram by seismogram -quickly becomes impractical. - -AIMBAT addresses this by shifting the focus from individual seismograms to the -dataset as a whole. Rather than assessing and processing each trace in -isolation, the focus is at the array level — where data quality and phase -arrivals can be judged in the context of all seismograms at once. Decisions -about filter settings, time windows, and which seismograms to include apply to -the entire dataset, and picks are refined across all traces simultaneously. -Everything is processed in bulk. - -## Semi-automatic - -This bulk processing happens in a semi-automatic way, whereby initial picks -surrounded by large time windows are iteratively refined into accurate phase -arrival picks with narrow time windows. Selecting high quality seismograms and -updating picks (for all stations simultaneously) are either performed manually, -or automatically by the ICCS algorithm. The automatically refined picks depend -on user-adjustable parameters, which are typically tuned between iterations to -achieve the best results. Once satisfied with the picks and parameter settings, -MCCC is run to produce the final relative arrival time measurements. - -## Snapshots and rollback - -The iterative nature of the workflow means exploring different parameter -combinations is central to the process. This is safe to do because the -seismogram data themselves are never modified — AIMBAT only stores and updates -processing parameters separately from the data. - -To support this further, snapshots of the current parameter state can be saved -at any point during processing — including before any changes are made. -Rolling back to a snapshot restores the parameters exactly as they were, but -does not delete any other snapshots, so it is possible to switch freely between -saved states. +## The problem + +Teleseismic travel time tomography requires accurate phase arrival picks across +large seismic arrays. Picking each trace individually does not scale — and +doing so in isolation discards the most useful information available: the +coherence of the wavefield across the array. + +AIMBAT works at the array level. Filter settings, time windows, and data +quality decisions apply to the whole dataset, and picks are refined across all +traces simultaneously using cross-correlation. + +## Workflow + +Processing follows a standard pattern: + +1. **Initial picks** — broad time windows are placed around approximate phase + arrivals, typically from a reference model. +2. **ICCS** — the Iterative Cross-Correlation and Stack algorithm refines picks + and windows across all seismograms simultaneously. Parameters controlling + the algorithm are adjusted between iterations until the results are + satisfactory. +3. **Quality control** — seismograms can be selected or deselected manually, or + automatically by ICCS based on cross-correlation quality. +4. **MCCC** — Multi-Channel Cross-Correlation produces the final relative + arrival time measurements from the refined picks. + +## Snapshots + +Because tuning parameters is an inherently iterative process, AIMBAT supports +snapshots — named saves of the current parameter state. Rolling back to a +snapshot restores parameters exactly, without removing other snapshots. This +makes it safe to explore parameter space without losing previous results. diff --git a/docs/first-steps/data.md b/docs/first-steps/data.md index a84d1f4..5b02952 100644 --- a/docs/first-steps/data.md +++ b/docs/first-steps/data.md @@ -1,17 +1,14 @@ # Data AIMBAT treats input data as read-only. Processing parameters and results are -stored separately from the data in a database. Once imported into a project, -the data sources (e.g. SAC files) are only used to read the waveforms. All -metadata (e.g. event and station information) is stored in the database. +stored separately in a database. Once imported, data sources (e.g. SAC files) +are only read for waveform data — all metadata (event and station information) +is stored in the database. ## Data hierarchy -Moving away from storing all data in individual files means things are -organised differently. For example, a seismogram in AIMBAT is no longer a file; -it is an object in the database that links to a data source, station and event. -Stations and events are also objects in the database, and they are shared -between seismograms. +A seismogram in AIMBAT is a database object that links a data source, a +station, and an event. Stations and events are shared across seismograms. ```mermaid --- @@ -25,121 +22,71 @@ erDiagram ``` -!!! Tip - Seismograms that are supposed to be processed together are identified only - by their shared event and station in the database. You can therefore be - quite flexible in how you organise your data on disk, but you must make - sure they reference the exact same station and event information — small - differences in the metadata (e.g. a different number of decimal places) may - lead to AIMBAT treating seismograms as belonging to different events or - stations. +!!! tip + Seismograms that belong together are identified solely by shared event and + station records in the database. You can organise data files freely on disk, + but the metadata must match exactly — small differences (e.g. rounding in + coordinates) may cause AIMBAT to treat seismograms as belonging to different + events or stations. ## Deleting items -All of the above happens transparently to the user, but there are some things to -be mindful of when it comes to deleting items from a project: - -- Deleting[^1] an event or station from a project will remove all related - seismograms. -- Conversely, deleting a seismogram will *not* remove the event or station. - This remains true even if an event or station end up not being used by any - seismograms in the project. - -!!! Tip - If this all seems overly complicated to you, remember that often parameters - that apply to all seismograms of an event need to be changed to - the same value. Organising the data like this means we only need to change - the parameters in one place. And there are some additional - [benefits](#snapshots) when setting things up this way! +- Deleting[^1] an event or station removes all associated seismograms. +- Deleting a seismogram does *not* remove the event or station, even if they + are no longer referenced by any seismogram. [^1]: - Deleting items from a project simply drops them from the project. AIMBAT - will *never* delete (or modify) any files. + Deleting items from a project drops them from the database only. AIMBAT + will *never* delete or modify any files. ## Project file -If the above section sounded a bit "databasey" to you, that is because you are -spot on! AIMBAT projects consist of a single -[sqlite](https://www.sqlite.org){ target="_blank" } file (which is -automatically generated when a new project is created). This file contains a -database to manage all aspects of an AIMBAT project. Understanding the -internals of this file (or all the tables used in the database) is not -particularly important for normal usage, though it might be useful to look at -the data directly in cases where AIMBAT behaves in unexpected ways (e.g. due to -inconsistencies in the seismogram files used as input). To do this we suggest -viewing the database in tools such as -[DB Browser for SQLite](https://sqlitebrowser.org){ target="_blank" }. +An AIMBAT project is a single [SQLite](https://www.sqlite.org){ target="_blank" } +file, created automatically when a new project is initialised. All project +state lives in this file. You do not need to understand the database schema for +normal use, but tools like +[DB Browser for SQLite](https://sqlitebrowser.org){ target="_blank" } are +useful for inspecting the raw data when debugging unexpected behaviour. + ![DB Browser](../images/sqlbrowser.png){ loading=lazy } ## Parameters -As alluded to above, there is a hierarchy to how data are structured in AIMBAT, -and a major benefit of this is that parameters can be applied at different -levels. Thus there are three tiers of parameters that control behaviour and -processing: - - 1. AIMBAT defaults: shared across all events and seismograms in a project. - 2. Event parameters: specific to an event (or shared across all seismograms - of that event). - 3. Seismogram parameters: specific to a single seismogram. - -### AIMBAT Defaults - -AIMBAT [defaults](../usage/defaults.md) are global settings that control how -the application itself behaves, as well as defaults for -[event](#event-parameters) and [seismogram](#seismogram-parameters) parameters -when they are instantiated. The currently used values for these parameters can -be listed by running `#!bash aimbat utils settings` in the terminal. As some -settings are relevant before a project is created, they cannot be stored in -the project file. - -### Event Parameters - -Event parameters are used during processing. They are parameters that are -specific to an event (e.g. if an event should be marked as completed), or -parameters that are shared across all seismograms of that event (e.g. time -window for the cross-correlation, filter parameters, etc.). -These parameters are attributes of the -[`AimbatEventParametersBase`][aimbat.models.AimbatEventParametersBase] -class. - -### Seismogram Parameters +Parameters are organised in three tiers: -Seismogram parameters are also used during processing. Most notably the time -picks belong to this tier. These parameters are attributes of the -[`AimbatSeismogramParametersBase`][aimbat.models.AimbatSeismogramParametersBase] -class. +1. **AIMBAT defaults** — global settings that control application behaviour and + provide initial values for event and seismogram parameters. Listed with + `#!bash aimbat utils settings`. Stored outside the project file, since some + settings are needed before a project exists. +2. **Event parameters** — shared across all seismograms of an event (e.g. time + window, filter settings, completed flag). Attributes of + [`AimbatEventParametersBase`][aimbat.models.AimbatEventParametersBase]. +3. **Seismogram parameters** — specific to a single seismogram (e.g. arrival + time pick, select/deselect flag). Attributes of + [`AimbatSeismogramParametersBase`][aimbat.models.AimbatSeismogramParametersBase]. ## Snapshots -The event and seismogram parameters are stored separately from the events and -seismograms (much like the seismograms link to an event and station instead of -saving them in the same object). This opens up the possibility to save an -arbitrary number of copies of these parameters that capture the current state -of processing. This allows for risk-free experimentation with different -parameters - if something goes wrong, you can always roll back to the last (or -any other) snapshot. +Event and seismogram parameters can be snapshot at any point during processing. +Snapshots are independent copies of the parameter state — rolling back to one +restores parameters exactly without affecting other snapshots. !!! tip - It is always a good idea to create a snapshot right after importing new - data! + Create a snapshot immediately after importing data, before any processing + has been done. -## UUID (Universally Unique Identifiers) +## UUIDs -Internally, the items in a project use -[UUIDs](https://en.wikipedia.org/wiki/Universally_unique_identifier) to -identify themselves. They look something like this: +All items in a project are identified internally by +[UUIDs](https://en.wikipedia.org/wiki/Universally_unique_identifier): ```text 37a8245f-c508-46a7-9bbc-d1c601e42983 ``` -The length and randomness of these identifiers guarantee no two items will ever -have the same ID (even if generated on two separate computers, in different -databases etc.), but they are a bit unwieldy to use directly. To make things -easier, they are typically presented in a truncated form to the user (but -always long enough to be unique). For example, if there are only four -seismograms and they have these UUIDS: +Full UUIDs are unwieldy to type, so AIMBAT presents truncated forms — using +only as many characters as needed to be unambiguous within the project. For +example, four seismograms with these IDs: ```text 6a4acdf7-6c7b-4523-aaaa-0a674cdc5f2d @@ -148,7 +95,7 @@ c980918d-106d-44d9-a3fa-5740f58edf4e 5dcb5c4b-b416-4a7b-870f-9a8da42a7dd2 ``` -they can still be unambiguously identified using only the first two characters: +can be unambiguously referenced as: ```text 6a @@ -157,4 +104,4 @@ c9 5d ``` -If two characters are insufficient, then three will be used, and so on. +If two characters are insufficient, three are used, and so on. diff --git a/docs/first-steps/installation.md b/docs/first-steps/installation.md index 9b977aa..82bf5ee 100644 --- a/docs/first-steps/installation.md +++ b/docs/first-steps/installation.md @@ -1,85 +1,41 @@ # Installing AIMBAT -AIMBAT is built on top of standard [Python](https://www.python.org), combined -with a number of popular third party modules such as [NumPy][numpy] and -[SciPy][scipy]. It is available as a package from the -[Python Package Index][pypi], and can therefore be -installed using Python's standard package manager, -[`pip`](https://pip.pypa.io/en/stable/). +AIMBAT is available on [PyPI][pypi] and can be installed with any standard +Python package manager. We recommend [`uv`][uv] or [`pipx`][pipx], which +isolate the installation from the rest of your Python environment. -However, as AIMBAT is a standalone application, we recommend installing it -using tools like [`uv`][uv] or [`pipx`][pipx] instead[^1]. Here we show how to -install and run AIMBAT using `uv`. +## Running without installing -[^1]: If you want to get really fancy you can also use tools like - [MISE-EN-PLACE](https://mise.jdx.dev), which use `uv` or `pipx` under the - hood. - -## Running AIMBAT without installing - -Running applications with `uv` is very simple. It manages all dependencies and -even installs a compatible Python version if needed. A very convenient feature -of using `uv` is that it can run applications without installing them: - -```bash -$ # First check that uv is available: -$ uv self version -uv 0.10.6 -$ # Next run AIMBAT using uv tool: -$ uv tool run aimbat --version -⠴ Resolving dependencies... -2.0.0 -``` - -The initial run may take a while to download AIMBAT and its dependencies, but -once that is done subsequent runs will be much faster. `uv tool run` is such a -useful command that it has its own alias, `uvx`: +`uv` can run AIMBAT directly without a permanent installation: ```bash $ uvx aimbat --version 2.0.0 -... ``` -## Running the development version +The first run downloads AIMBAT and its dependencies; subsequent runs use the +cache and start immediately. -Running AIMBAT without installing it is particularly useful for trying out the -latest development version: +## Running the development version -``` +```bash $ uvx git+https://github.com/pysmo/aimbat --version -Updating https://github.com/pysmo/aimbat (HEAD) -⠇ Resolving dependencies... -⠦ Preparing packages... (66/69) ----> 100% ----> 100% 2.1.0.dev0 ``` -To clean up after yourself, you can remove the `uv` cache: +To clear the cache afterwards: ```bash $ uv clean -Clearing cache at: /home/bob/.cache/uv -Cleaning [==================> ] 91% -Removed 26702 files (2.1GiB) ``` -## Installing locally - -Permanent installation is just as easy. Just run `uv tool install`: +## Installing permanently ```bash $ uv tool install aimbat -⠦ Resolving dependencies... -⠇ Preparing packages... (66/69) -+ aimbat==2.0.0 -... Installed 1 executable: aimbat ``` -You can now run AIMBAT using the `aimbat` command directly: - ```bash $ aimbat Usage: aimbat COMMAND @@ -87,22 +43,16 @@ Usage: aimbat COMMAND ``` !!! tip - If the above command fails (because your shell can't find the `aimbat` - command), you may need to add `~/.local/bin` to your `PATH`. This can be - done automatically by running `#!bash uv tool update-shell`. + If your shell cannot find the `aimbat` command, add `~/.local/bin` to your + `PATH` by running `#!bash uv tool update-shell`. -Upgrading or uninstalling is just as easy with `uv tool upgrade` and `uv tool -uninstall`: +Upgrade or uninstall with: ```bash $ uv tool upgrade aimbat -Nothing to upgrade $ uv tool uninstall aimbat -Uninstalled 1 executable: aimbat ``` -[numpy]: https://numpy.org -[scipy]: https://scipy.org [pypi]: https://pypi.org/project/aimbat/ [uv]: https://docs.astral.sh/uv/ [pipx]: https://pipx.pypa.io diff --git a/docs/first-steps/workflow.md b/docs/first-steps/workflow.md index 9f92252..ef25b69 100644 --- a/docs/first-steps/workflow.md +++ b/docs/first-steps/workflow.md @@ -1,24 +1,16 @@ # Workflow and Strategy -The best results with AIMBAT are obtained if you understand the unique, -non-linear workflow that it prescribes. Unlike more traditional top-down -processing, AIMBAT is more of an iterative process whereby parameters are -constantly adjusted to gradually determine the optimal settings. The exact -strategy for a particular event may differ, but there are some general -guidelines below that we recommend following. - ## Without AIMBAT -Multi-Channel Cross-Correlation[^1] (MCCC) relies on -narrow time windows, focused on the initial arrival of the targeted -phase in order to yield high quality results. Manually picking the phase -arrival on each seismogram individually is a very time consuming task, which is -highlighted by the stacked cards in the flowchart below: +MCCC[^1] requires narrow time windows centred on the initial phase arrival to +produce accurate results. Without a tool like AIMBAT, phase arrivals must be +picked on each seismogram individually — a time-consuming task illustrated by +the stacked steps in the flowchart below: [^1]: - VanDecar, J. C., and R. S. Crosson. “Determination of Teleseismic + 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, + Least Squares." Bulletin of the Seismological Society of America, vol. 80, no. 1, Feb. 1990, pp. 150–69, . @@ -34,15 +26,14 @@ flowchart TD ## With AIMBAT -AIMBAT[^2] stacks all input seismograms (aligned on the picked -phase arrival) and operates on that stack instead of individual seismograms. -This allows picking the phase arrival once for all seismograms simultaneously, -and then improving it iteratively before running MCCC. Note that both the ICCS -algorithm, as well as adjusting AIMBAT parameters are iterative processes. +AIMBAT[^2] stacks all seismograms aligned on an initial pick, then +cross-correlates each seismogram against that stack to refine arrivals +simultaneously across the array. Parameters and picks are improved iteratively +before a final MCCC run. [^2]: - Lou, X., et al. “AIMBAT: A Python/Matplotlib Tool for Measuring - Teleseismic Arrival Times.” Seismological Research Letters, vol. 84, + Lou, X., et al. "AIMBAT: A Python/Matplotlib Tool for Measuring + Teleseismic Arrival Times." Seismological Research Letters, vol. 84, no. 1, Jan. 2013, pp. 85–93, . ``` mermaid @@ -62,41 +53,32 @@ flowchart TD ## Strategy -AIMBAT does not prescribe a single strategy for picking/updating processing -parameters. That said, some general principles to follow are: - -1. Only change one parameter at a time, and then run ICCS to see the effect - of that change on the alignment -2. Snapshots are immediate and use no storage, so create them often (and don't - skip adding a comment that describes the snapshot). -3. Don't get too distracted by individual seismograms that are not well aligned - or seem of poor quality. Trust the algorithm to deal with those. +1. Change one parameter at a time and run ICCS to observe the effect before + making further adjustments. +2. Take snapshots often, with a comment describing the current state. They are + lightweight and easy to roll back to. +3. Do not focus too much on individual poorly-aligned seismograms — use + autoflip and autoselect to let the algorithm handle them. ### ICCS running modes -Points 1 and 2 above are pretty self-explanatory, but point 3 deserves a bit -more explanation. The ICCS algorithm has two flags that can be set before each -run: - -- **Autoflip**: If set to `True`, the algorithm will toggle the ``flip`` - parameter for seismograms that are negatively correlated with the stack (i.e. - the maximum absolute correlation coefficient is negative). -- **Autoselect**: If set to `True`, the algorithm will automatically set the - `select` parameter for seismograms that are poorly correlated with the stack - to `False`, but it will also toggle previously deselected seismograms back - to `True` if they become well correlated with the stack. - -The `flip` parameter determines whether a seismogram is flipped in polarity as -the data are prepared for the stack and following cross-correlation. The -`select` parameter determines whether a seismogram is included in the stack; -however, *all* seismograms are cross-correlated with that stack regardless of -their `select` status. This means that all seismograms, even with -`select=False`, can self-recover if they fit the stack better after changing -parameters. - -!!! Tip - If seismograms are so bad that they wander off into the distance (i.e the - difference between initial pick and revised pick after running ICCS is - very large), consider deleting them completely from the AIMBAT project. - This will prevent these rogue seismograms from influencing the valid - ranges for updating time windows and picks. +ICCS has two optional flags: + +- **Autoflip**: automatically toggles the `flip` parameter for seismograms + whose maximum absolute correlation with the stack is negative (i.e. inverted + polarity). +- **Autoselect**: automatically sets `select=False` for seismograms poorly + correlated with the stack, and restores them to `select=True` if they improve + in a subsequent iteration. + +The `flip` parameter controls polarity when preparing seismograms for the +stack. The `select` parameter controls whether a seismogram is included in the +stack — but *all* seismograms are cross-correlated against the stack regardless +of their `select` status. A deselected seismogram can therefore recover +automatically if parameter changes bring it into alignment. + +!!! tip + If a seismogram drifts far from the stack (large difference between initial + and revised pick across iterations), consider deleting it from the project. + Rogue seismograms can distort the valid ranges used when updating picks and + time windows. diff --git a/docs/usage/cli.md b/docs/usage/cli.md index eec61e8..c2894a1 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -1,12 +1,116 @@ # Command Line Interface (CLI) -The AIMBAT CLI is the primary tool for administrative tasks like creating projects, -importing data, and managing snapshots. It is also suitable for batch -processing and scripting. - -!!! Warning "Parameter Validation" - The CLI performs basic validation of processing parameters (e.g., ensuring - values are provided in the correct format), but it does *not* perform the - same data-aware validation as the other interfaces. For example, the TUI - will prevent you from setting a time window that extends beyond the - available data for an event. +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. + +!!! warning "Parameter validation" + The CLI writes parameter values directly to the database without + data-aware validation. For example, it will not prevent you from setting a + time window that extends beyond the available data. Use the + [TUI](tui.md) or [Shell](shell.md) when validation matters. + +## 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/index.md b/docs/usage/index.md index fca7f2b..3f86661 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -1,5 +1,7 @@ # Using AIMBAT +## 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: @@ -18,3 +20,30 @@ ways, each of which has unique strengths depending on the task at hand: processing by writing custom Python scripts. Complete walkthroughs for each of these options are presented in the following sections. + +## Default event + +AIMBAT projects often contain many seismic events. To streamline work, you +can designate a **default event** to serve as a persistent context. + +The default event is a convenience fallback for the [CLI](cli.md). Commands +automatically target this event unless you explicitly provide an ID. This +reduces the need to repeatedly copy and paste IDs when focusing on one event. + +### Flexible Processing + +A default event is **not required** for processing tasks. AIMBAT allows +flexibility across all interfaces: + +- **Interactive Tools**: The [Terminal UI](tui.md), [Graphical UI](gui.md), + and [Shell](shell.md) can navigate and process any event in the project, + regardless of the default setting. +- **Command Line**: You can override the default for any command using the + global `--event` flag. + +Setting a default event is simply a way to reduce repetition. Change it at +any time using: + +```bash +aimbat event default +``` diff --git a/docs/usage/tui.md b/docs/usage/tui.md index 202edba..c4abd29 100644 --- a/docs/usage/tui.md +++ b/docs/usage/tui.md @@ -1 +1,187 @@ # 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: + +| Marker | Meaning | +|--------|---------| +| `●` | This event is also the [default event](index.md#default-event) | +| `▶` | This event is selected for TUI processing, but is not the default | + +The right side of the bar 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**: `★` = default + currently selected, `●` = default only, `▶` = currently selected only +- **✓**: event is marked as completed + +Available actions inside the switcher: + +| Key | Action | +|-----|--------| +| `Enter` | Select this event for TUI processing (does not change the default) | +| `d` | Select this event and set it as the [default event](index.md#default-event) | +| `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. If no +explicit selection has been made, the default event is used automatically. + +### 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/__init__.py b/src/aimbat/__init__.py index 25b876f..1c35b18 100644 --- a/src/aimbat/__init__.py +++ b/src/aimbat/__init__.py @@ -1,17 +1,11 @@ """ AIMBAT (Automated and Interactive Measurement of Body wave Arrival Times) -is an open-source software package for efficiently measuring teleseismic -body wave arrival times for large seismic arrays (Lou et al., 2012). It is -based on a widely used method called MCCC (Multi-Channel Cross-Correlation) -developed by VanDecar and Crosson (1990). The package is automated in the -sense of initially aligning seismograms for MCCC which is achieved by an -ICCS (Iterative Cross Correlation and Stack) algorithm. Meanwhile, a -graphical user interface is built to perform seismogram quality control -interactively. Therefore, user processing time is reduced while valuable -input from a user\'s expertise is retained. As a byproduct, SAC (Goldstein -et al., 2003) plotting and phase picking functionalities are replicated -and enhanced. - +is a tool for measuring teleseismic body wave arrival times across large +seismic arrays. It uses ICCS (Iterative Cross-Correlation and Stack) to +refine phase arrival picks simultaneously across all seismograms, followed +by MCCC (Multi-Channel Cross-Correlation) for final relative arrival time +measurements. The workflow is controlled through a CLI, a terminal UI, or +directly via the Python API. """ from ._config import settings as settings diff --git a/src/aimbat/_cli/align.py b/src/aimbat/_cli/align.py index ff0f943..5f26d95 100644 --- a/src/aimbat/_cli/align.py +++ b/src/aimbat/_cli/align.py @@ -20,7 +20,7 @@ def cli_iccs_run( autoselect: bool = False, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Run the ICCS algorithm to align seismograms for the active event. + """Run the ICCS algorithm to align seismograms for the default event. Iteratively cross-correlates seismograms against a running stack to refine arrival time picks (`t1`). If `t1` is not yet set, `t0` is used as the @@ -33,12 +33,12 @@ def cli_iccs_run( cross-correlation with the stack falls below `min_ccnorm`. """ from aimbat.db import engine - from aimbat.core import create_iccs_instance, run_iccs, get_active_event + from aimbat.core import create_iccs_instance, run_iccs, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs run_iccs(session, iccs, autoflip, autoselect) @@ -49,7 +49,7 @@ def cli_mccc_run( all_seismograms: Annotated[bool, Parameter(name="all")] = False, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Run the MCCC algorithm to refine arrival time picks for the active event. + """Run the MCCC algorithm to refine arrival time picks for the default event. Multi-channel cross-correlation simultaneously determines the optimal time shifts for all seismograms. Results are stored in `t1`. @@ -59,13 +59,13 @@ def cli_mccc_run( the currently selected ones. """ from aimbat.db import engine - from aimbat.core import create_iccs_instance, run_mccc, get_active_event + from aimbat.core import create_iccs_instance, run_mccc, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs - run_mccc(session, active_event, iccs, all_seismograms) + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs + run_mccc(session, event, iccs, all_seismograms) if __name__ == "__main__": diff --git a/src/aimbat/_cli/common.py b/src/aimbat/_cli/common.py index ababcc5..9132b0d 100644 --- a/src/aimbat/_cli/common.py +++ b/src/aimbat/_cli/common.py @@ -3,54 +3,13 @@ from aimbat import settings from dataclasses import dataclass from cyclopts import Parameter, Token -from typing import Callable, Any +from typing import Callable, Any, Annotated import uuid -# ----------------------------------------------------------------------- -# Common parameters -# ----------------------------------------------------------------------- - - -@Parameter(name="*") -@dataclass -class GlobalParameters: - debug: bool = False - "Run in debugging mode." - - def __post_init__(self) -> None: - if self.debug: - settings.log_level = "DEBUG" - from aimbat.logger import configure_logging - - configure_logging() - - -@Parameter(name="*") -@dataclass -class IccsPlotParameters: - context: bool = True - "Plot seismograms with extra context instead of the short tapered ones used for cross-correlation." - all: bool = False - "Include all seismograms in the plot, even if not used in stack." - - -@Parameter(name="*") -@dataclass -class TableParameters: - short: bool = True - "Shorten UUIDs and format data." - - # ----------------------------------------------------------------------- # Shared Parameter instances and factories # ----------------------------------------------------------------------- -#: Shared Parameter for --all (all events) flags. -ALL_EVENTS_PARAMETER = Parameter( - name="all", - help="Include records from all events instead of just the active one.", -) - def _make_uuid_converter(model_class: type) -> Callable[..., uuid.UUID]: """Return a cyclopts converter that resolves a UUID prefix for the given model.""" @@ -71,6 +30,13 @@ def converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: return converter +def _event_id_converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: + """Converter for the global --event parameter with late-bound model import.""" + from aimbat.models import AimbatEvent + + return _make_uuid_converter(AimbatEvent)(hint, tokens) + + def id_parameter(model_class: type) -> Parameter: """Create a Parameter for a record ID with automatic UUID prefix resolution.""" return Parameter( @@ -100,6 +66,67 @@ def use_event_parameter(model_class: type) -> Parameter: ) +#: Shared Parameter for --all (all events) flags. +ALL_EVENTS_PARAMETER = Parameter( + name="all", + help="Include records from all events instead of just the default one.", +) + +# ----------------------------------------------------------------------- +# Common parameters +# ----------------------------------------------------------------------- + + +@Parameter(name="*") +@dataclass +class DebugTrait: + debug: bool = False + """Enable verbose logging for troubleshooting.""" + + # NOTE: only one __post_init__ is allowed per dataclass + def __post_init__(self) -> None: + if self.debug: + settings.log_level = "DEBUG" + from aimbat.logger import configure_logging + + configure_logging() + + +@Parameter(name="*") +@dataclass +class EventContextTrait: + event_id: Annotated[ + uuid.UUID | None, + Parameter( + name=["event", "event-id"], + help="Process a specific event instead of the default one. " + "Full UUID or any unique prefix as shown in the table.", + converter=_event_id_converter, + ), + ] = None + + +@dataclass +class GlobalParameters(DebugTrait, EventContextTrait): + pass + + +@Parameter(name="*") +@dataclass +class IccsPlotParameters: + context: bool = True + "Plot seismograms with extra context instead of the short tapered ones used for cross-correlation." + all: bool = False + "Include all seismograms in the plot, even if not used in stack." + + +@Parameter(name="*") +@dataclass +class TableParameters: + short: bool = True + "Shorten UUIDs and format data." + + # ------------------------------------------------ # Hints for error messages # ------------------------------------------------ @@ -109,7 +136,9 @@ def use_event_parameter(model_class: type) -> Parameter: class CliHints: """Hints for error messages.""" - ACTIVATE_EVENT = "Hint: activate an event with `aimbat event activate `." + SET_DEFAULT_EVENT = ( + "Hint: set a default event with `aimbat event default `." + ) LIST_EVENTS = "Hint: view available events with `aimbat event list`." diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py index 0dae403..4d43b2a 100644 --- a/src/aimbat/_cli/data.py +++ b/src/aimbat/_cli/data.py @@ -20,7 +20,7 @@ aimbat project create aimbat data add *.sac aimbat event list # find the event ID -aimbat event activate +aimbat event default ``` Re-adding a data source that is already in the project is safe — existing @@ -134,7 +134,7 @@ def cli_data_list( ) -> None: """Print a table of data sources registered in the AIMBAT project.""" from aimbat.db import engine - from aimbat.core import get_active_event, get_data_for_event + from aimbat.core import resolve_event, get_data_for_event from aimbat.utils import uuid_shortener, make_table, TABLE_STYLING from aimbat.logger import logger from rich.console import Console @@ -148,14 +148,10 @@ def cli_data_list( aimbat_data_sources = session.exec(select(AimbatDataSource)).all() title = "Data sources for all events" else: - active_event = get_active_event(session) - aimbat_data_sources = get_data_for_event(session, active_event) - time = ( - active_event.time.strftime("%Y-%m-%d %H:%M:%S") - if short - else active_event.time - ) - id = uuid_shortener(session, active_event) if short else active_event.id + event = resolve_event(session, global_parameters.event_id) + aimbat_data_sources = get_data_for_event(session, event) + time = event.time.strftime("%Y-%m-%d %H:%M:%S") if short else event.time + id = uuid_shortener(session, event) if short else event.id title = f"Data sources for event {time} (ID={id})" logger.debug(f"Found {len(aimbat_data_sources)} data sources in total.") diff --git a/src/aimbat/_cli/event.py b/src/aimbat/_cli/event.py index 42ba4de..ec969c0 100644 --- a/src/aimbat/_cli/event.py +++ b/src/aimbat/_cli/event.py @@ -3,6 +3,7 @@ from .common import ( GlobalParameters, TableParameters, + DebugTrait, simple_exception, id_parameter, ALL_EVENTS_PARAMETER, @@ -37,19 +38,19 @@ def cli_event_delete( delete_event_by_id(session, event_id) -@app.command(name="activate") +@app.command(name="default") @simple_exception -def cli_event_activate( - event_id: Annotated[uuid.UUID, id_parameter(AimbatEvent)], +def cli_event_default( + new_default_event_id: Annotated[uuid.UUID, id_parameter(AimbatEvent)], *, - global_parameters: GlobalParameters = GlobalParameters(), + global_parameters: DebugTrait = DebugTrait(), ) -> None: - """Select the event to be active for processing.""" - from aimbat.core import set_active_event_by_id + """Select the event to be default for processing.""" + from aimbat.core import set_default_event_by_id from aimbat.db import engine with Session(engine) as session: - set_active_event_by_id(session, event_id) + set_default_event_by_id(session, new_default_event_id) @parameter.command(name="get") @@ -59,19 +60,19 @@ def cli_event_parameter_get( *, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Get parameter value for the active event. + """Get parameter value for an event. Args: name: Event parameter name. """ from aimbat.db import engine - from aimbat.core import get_event_parameter, get_active_event + from aimbat.core import get_event_parameter, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - value = get_event_parameter(session, active_event, name) + event = resolve_event(session, global_parameters.event_id) + value = get_event_parameter(session, event, name) if isinstance(value, Timedelta): print(f"{value.total_seconds()}s") else: @@ -86,7 +87,7 @@ def cli_event_parameter_set( *, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Set parameter value for the active event. + """Set parameter value for an event. Args: name: Event parameter name. @@ -94,7 +95,7 @@ def cli_event_parameter_set( are interpreted as seconds. """ from aimbat.db import engine - from aimbat.core import set_event_parameter, get_active_event + from aimbat.core import set_event_parameter, resolve_event from sqlmodel import Session _TIMEDELTA_PARAMS = (EventParameter.WINDOW_PRE, EventParameter.WINDOW_POST) @@ -106,8 +107,8 @@ def cli_event_parameter_set( parsed_value = Timedelta(value) with Session(engine) as session: - active_event = get_active_event(session) - set_event_parameter(session, active_event, name, parsed_value) + event = resolve_event(session, global_parameters.event_id) + set_event_parameter(session, event, name, parsed_value) @parameter.command(name="dump") @@ -118,15 +119,19 @@ def cli_event_parameter_dump( ) -> None: """Dump event parameter table to json.""" from aimbat.db import engine - from aimbat.core import dump_event_parameter_table_to_json, get_active_event + from aimbat.core import dump_event_parameter_table_to_json, resolve_event from sqlmodel import Session from rich import print_json with Session(engine) as session: - active_event = get_active_event(session) if not all_events else None + event = ( + resolve_event(session, global_parameters.event_id) + if not all_events + else None + ) print_json( dump_event_parameter_table_to_json( - session, all_events, as_string=True, event=active_event + session, all_events, as_string=True, event=event ) ) @@ -158,7 +163,7 @@ def cli_event_list( ) -> None: """Print a table of events stored in the AIMBAT project. - The active event is highlighted. Use `event activate` to change which event + The default event is highlighted. Use `event default` to change which event is processed by subsequent commands. """ from aimbat.db import engine @@ -177,7 +182,7 @@ def cli_event_list( title="AIMBAT Events", column_order=[ "id", - "active", + "is_default", "time", "latitude", "longitude", @@ -190,7 +195,7 @@ def cli_event_list( "id": lambda x: ( uuid_shortener(session, AimbatEvent, str_uuid=x) if short else x ), - "active": TABLE_STYLING.bool_formatter, + "is_default": TABLE_STYLING.bool_formatter, "time": lambda x: TABLE_STYLING.timestamp_formatter( Timestamp(x), short ), @@ -209,7 +214,11 @@ def cli_event_list( "style": TABLE_STYLING.id, "no_wrap": True, }, - "active": {"style": TABLE_STYLING.mine, "no_wrap": True}, + "is_default": { + "header": "Default", + "style": TABLE_STYLING.mine, + "no_wrap": True, + }, "time": { "header": "Date & Time", "style": TABLE_STYLING.mine, @@ -243,14 +252,14 @@ def cli_event_parameter_list( global_parameters: GlobalParameters = GlobalParameters(), table_parameters: TableParameters = TableParameters(), ) -> None: - """List processing parameter values for the active event. + """List processing parameter values for the default event. Displays all event-level parameters (e.g. time window, bandpass filter settings, minimum ccnorm) in a table. """ from aimbat.db import engine - from aimbat.core import dump_event_parameter_table_to_json, get_active_event + from aimbat.core import dump_event_parameter_table_to_json, resolve_event from aimbat.utils import uuid_shortener, json_to_table, TABLE_STYLING from aimbat.logger import logger @@ -287,12 +296,12 @@ def cli_event_parameter_list( }, ) else: - logger.info("Printing AIMBAT event parameters table for active event.") + logger.info("Printing AIMBAT event parameters table for default event.") - active_event = get_active_event(session) + event = resolve_event(session, global_parameters.event_id) json_to_table( - data=active_event.parameters.model_dump(mode="json"), - title=f"Event parameters for event: {uuid_shortener(session, active_event) if short else str(active_event.id)}", + data=event.parameters.model_dump(mode="json"), + title=f"Event parameters for event: {uuid_shortener(session, event) if short else str(event.id)}", skip_keys=["id", "event_id"], common_column_kwargs={"highlight": True}, column_kwargs={ diff --git a/src/aimbat/_cli/pick.py b/src/aimbat/_cli/pick.py index 8e2c750..d39e8ba 100644 --- a/src/aimbat/_cli/pick.py +++ b/src/aimbat/_cli/pick.py @@ -1,8 +1,8 @@ """Interactively pick phase arrival times and processing parameters. -These commands open an interactive matplotlib plot for the active event. +These commands open an interactive matplotlib plot for the default event. Click on the plot to set the chosen value, then close the window to save it. -Use `aimbat event activate` to switch the active event before picking. +Use `aimbat event default` to switch the default event before picking. """ from typing import Annotated @@ -20,7 +20,7 @@ def cli_update_phase_pick( use_seismogram_image: Annotated[bool, Parameter(name="img")] = False, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Interactively pick a new phase arrival time (t1) for the active event. + """Interactively pick a new phase arrival time (t1) for the default event. Opens an interactive plot; click on the waveform to place the new pick, then close the window to save. The pick is stored as `t1` for each @@ -30,12 +30,12 @@ def cli_update_phase_pick( use_seismogram_image: Use the seismogram image to update pick. """ from aimbat.db import engine - from aimbat.core import create_iccs_instance, update_pick, get_active_event + from aimbat.core import create_iccs_instance, update_pick, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs update_pick( session, iccs, @@ -54,7 +54,7 @@ def cli_pick_timewindow( use_seismogram_image: Annotated[bool, Parameter(name="img")] = False, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Interactively pick a new cross-correlation time window for the active event. + """Interactively pick a new cross-correlation time window for an event. Opens an interactive plot; click to set the left and right window boundaries, then close the window to save. The window controls which portion of the @@ -64,15 +64,15 @@ def cli_pick_timewindow( use_seismogram_image: Use the seismogram image to pick the time window. """ from aimbat.db import engine - from aimbat.core import create_iccs_instance, update_timewindow, get_active_event + from aimbat.core import create_iccs_instance, update_timewindow, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs update_timewindow( session, - active_event, + event, iccs, iccs_parameters.context, iccs_parameters.all, @@ -95,15 +95,15 @@ def cli_pick_min_ccnorm( automatically de-selected when running ICCS with `--autoselect`. """ from aimbat.db import engine - from aimbat.core import create_iccs_instance, update_min_ccnorm, get_active_event + from aimbat.core import create_iccs_instance, update_min_ccnorm, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs update_min_ccnorm( session, - active_event, + event, iccs, iccs_parameters.context, iccs_parameters.all, diff --git a/src/aimbat/_cli/plot.py b/src/aimbat/_cli/plot.py index 8da0621..47ce17b 100644 --- a/src/aimbat/_cli/plot.py +++ b/src/aimbat/_cli/plot.py @@ -3,7 +3,7 @@ Available plots: - **data**: raw seismograms sorted by epicentral distance -- **stack**: the ICCS cross-correlation stack for the active event +- **stack**: the ICCS cross-correlation stack for the default event - **image**: seismograms displayed as a 2-D image (wiggle plot) Most plot commands support `--context` / `--no-context` to toggle extra @@ -26,14 +26,14 @@ def cli_seismogram_plot( *, global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Plot raw seismograms for the active event sorted by epicentral distance.""" + """Plot raw seismograms for the default event sorted by epicentral distance.""" from aimbat.db import engine - from aimbat.core import plot_all_seismograms, get_active_event + from aimbat.core import plot_all_seismograms, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - plot_all_seismograms(session, active_event, return_fig=False) + event = resolve_event(session, global_parameters.event_id) + plot_all_seismograms(session, event, return_fig=False) @app.command(name="stack") @@ -43,14 +43,14 @@ def cli_iccs_plot_stack( iccs_parameters: IccsPlotParameters = IccsPlotParameters(), global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Plot the ICCS stack of the active event.""" + """Plot the ICCS stack of an event.""" from aimbat.db import engine - from aimbat.core import create_iccs_instance, plot_stack, get_active_event + from aimbat.core import create_iccs_instance, plot_stack, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs plot_stack(iccs, iccs_parameters.context, iccs_parameters.all, return_fig=False) @@ -61,18 +61,18 @@ def cli_iccs_plot_image( iccs_parameters: IccsPlotParameters = IccsPlotParameters(), global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Plot the ICCS seismograms of the active event as an image.""" + """Plot the ICCS seismograms of an event as an image.""" from aimbat.db import engine from aimbat.core import ( create_iccs_instance, plot_iccs_seismograms, - get_active_event, + resolve_event, ) from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - iccs = create_iccs_instance(session, active_event).iccs + event = resolve_event(session, global_parameters.event_id) + iccs = create_iccs_instance(session, event).iccs plot_iccs_seismograms( iccs, iccs_parameters.context, iccs_parameters.all, return_fig=False ) diff --git a/src/aimbat/_cli/project.py b/src/aimbat/_cli/project.py index 62dd311..8870f10 100644 --- a/src/aimbat/_cli/project.py +++ b/src/aimbat/_cli/project.py @@ -52,7 +52,7 @@ def cli_project_info( from aimbat.db import engine from aimbat.core._project import _project_exists - from aimbat.core import get_active_event + from aimbat.core import resolve_event from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatStation from aimbat.logger import logger from sqlmodel import Session, select @@ -95,21 +95,27 @@ def cli_project_info( ) try: - active_event = get_active_event(session) - active_event_id = active_event.id - active_stations = len(station.get_stations_in_event(session, active_event)) - seismograms_in_event = len(active_event.seismograms) + target_event = resolve_event(session, global_parameters.event_id) + target_event_id = target_event.id + active_stations = len(station.get_stations_in_event(session, target_event)) + seismograms_in_event = len(target_event.seismograms) selected_seismograms_in_event = len( - seismogram.get_selected_seismograms(session, event=active_event) + seismogram.get_selected_seismograms(session, event=target_event) ) - except NoResultFound: - active_event_id = None + except (NoResultFound, ValueError, RuntimeError): + target_event_id = None active_stations = None seismograms_in_event = None selected_seismograms_in_event = None - grid.add_row("Active Event ID: ", f"{active_event_id}") + + event_label = ( + "Selected Event ID: " + if global_parameters.event_id + else "Default Event ID: " + ) + grid.add_row(event_label, f"{target_event_id}") grid.add_row( - "Number of Stations in Project (total/active event): ", + "Number of Stations in Project (total/selected event): ", f"({stations}/{active_stations})", ) @@ -118,7 +124,7 @@ def cli_project_info( f"({seismograms}/{selected_seismograms})", ) grid.add_row( - "Number of Seismograms in Active Event (total/selected): ", + "Number of Seismograms in Selected Event (total/selected): ", f"({seismograms_in_event}/{selected_seismograms_in_event})", ) diff --git a/src/aimbat/_cli/seismogram.py b/src/aimbat/_cli/seismogram.py index df0e853..941daef 100644 --- a/src/aimbat/_cli/seismogram.py +++ b/src/aimbat/_cli/seismogram.py @@ -123,15 +123,19 @@ def cli_seismogram_parameter_dump( ) -> None: """Dump seismogram parameter table to json.""" from aimbat.db import engine - from aimbat.core import dump_seismogram_parameter_table_to_json, get_active_event + from aimbat.core import dump_seismogram_parameter_table_to_json, resolve_event from sqlmodel import Session from rich import print_json with Session(engine) as session: - active_event = get_active_event(session) if not all_events else None + event = ( + resolve_event(session, global_parameters.event_id) + if not all_events + else None + ) print_json( dump_seismogram_parameter_table_to_json( - session, all_events, as_string=True, event=active_event + session, all_events, as_string=True, event=event ) ) @@ -142,14 +146,14 @@ def cli_seismogram_parameter_list( global_parameters: GlobalParameters = GlobalParameters(), table_parameters: TableParameters = TableParameters(), ) -> None: - """List processing parameter values for seismograms in the active event. + """List processing parameter values for seismograms in an event. Displays per-seismogram parameters (e.g. `select`, `flip`, `t1` pick) in a table. Use `seismogram parameter set` to modify individual values. """ from aimbat.db import engine - from aimbat.core import get_active_event, dump_seismogram_parameter_table_to_json + from aimbat.core import resolve_event, dump_seismogram_parameter_table_to_json from aimbat.utils import uuid_shortener, json_to_table, TABLE_STYLING from aimbat.logger import logger from sqlmodel import Session @@ -157,14 +161,14 @@ def cli_seismogram_parameter_list( short = table_parameters.short with Session(engine) as session: - logger.info("Printing AIMBAT seismogram parameters table for active event.") + logger.info("Printing AIMBAT seismogram parameters table.") - active_event = get_active_event(session) - title = f"Seismogram parameters for event: {uuid_shortener(session, active_event) if short else str(active_event.id)}" + event = resolve_event(session, global_parameters.event_id) + title = f"Seismogram parameters for event: {uuid_shortener(session, event) if short else str(event.id)}" json_to_table( data=dump_seismogram_parameter_table_to_json( - session, all_events=False, as_string=False, event=active_event + session, all_events=False, as_string=False, event=event ), title=title, skip_keys=["id"], @@ -195,9 +199,9 @@ def cli_seismogram_list( table_parameters: TableParameters = TableParameters(), global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Print information on the seismograms in the active event.""" + """Print information on the seismograms in an event.""" from aimbat.db import engine - from aimbat.core import get_active_event + from aimbat.core import resolve_event from aimbat.utils import uuid_shortener, make_table, TABLE_STYLING from aimbat.logger import logger from rich.console import Console @@ -214,13 +218,13 @@ def cli_seismogram_list( logger.debug("Selecting seismograms for all events.") seismograms = session.exec(select(AimbatSeismogram)).all() else: - logger.debug("Selecting seismograms for active event only.") - active_event = get_active_event(session) - seismograms = active_event.seismograms + logger.debug("Selecting seismograms for event.") + event = resolve_event(session, global_parameters.event_id) + seismograms = event.seismograms if short: - title = f"AIMBAT seismograms for event {active_event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, active_event)})" + title = f"AIMBAT seismograms for event {event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, event)})" else: - title = f"AIMBAT seismograms for event {active_event.time} (ID={active_event.id})" + title = f"AIMBAT seismograms for event {event.time} (ID={event.id})" logger.debug(f"Found {len(seismograms)} seismograms for the table.") diff --git a/src/aimbat/_cli/snapshot.py b/src/aimbat/_cli/snapshot.py index a8c7c31..7531c4c 100644 --- a/src/aimbat/_cli/snapshot.py +++ b/src/aimbat/_cli/snapshot.py @@ -30,19 +30,19 @@ def cli_snapshot_create( ) -> None: """Create a new snapshot of current processing parameters. - Saves the current event and seismogram parameters for the active event so + Saves the current event and seismogram parameters for an event so they can be restored later with `snapshot rollback`. Args: comment: Optional description to help identify this snapshot later. """ from aimbat.db import engine - from aimbat.core import create_snapshot, get_active_event + from aimbat.core import create_snapshot, resolve_event from sqlmodel import Session with Session(engine) as session: - active_event = get_active_event(session) - create_snapshot(session, active_event, comment) + event = resolve_event(session, global_parameters.event_id) + create_snapshot(session, event, comment) @app.command(name="rollback") @@ -85,15 +85,19 @@ def cli_snapshot_dump( ) -> None: """Dump the contents of the AIMBAT snapshot table to json.""" from aimbat.db import engine - from aimbat.core import dump_snapshot_tables_to_json, get_active_event + from aimbat.core import dump_snapshot_tables_to_json, resolve_event from sqlmodel import Session from rich import print_json with Session(engine) as session: - active_event = get_active_event(session) if not all_events else None + event = ( + resolve_event(session, global_parameters.event_id) + if not all_events + else None + ) print_json( dump_snapshot_tables_to_json( - session, all_events, as_string=True, event=active_event + session, all_events, as_string=True, event=event ) ) @@ -105,9 +109,9 @@ def cli_snapshot_list( table_parameters: TableParameters = TableParameters(), global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Print information on the snapshots for the active event.""" + """Print information on the snapshots for an event.""" from aimbat.db import engine - from aimbat.core import get_active_event, dump_snapshot_tables_to_json + from aimbat.core import resolve_event, dump_snapshot_tables_to_json from aimbat.utils import uuid_shortener, json_to_table, TABLE_STYLING from aimbat.logger import logger from aimbat.models import AimbatEvent @@ -121,16 +125,16 @@ def cli_snapshot_list( title = "AIMBAT snapshots for all events" - active_event = None + event = None if not all_events: - active_event = get_active_event(session) + event = resolve_event(session, global_parameters.event_id) if short: - title = f"AIMBAT snapshots for event {active_event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, active_event)})" + title = f"AIMBAT snapshots for event {event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, event)})" else: - title = f"AIMBAT snapshots for event {active_event.time} (ID={active_event.id})" + title = f"AIMBAT snapshots for event {event.time} (ID={event.id})" data = dump_snapshot_tables_to_json( - session, all_events, as_string=False, event=active_event + session, all_events, as_string=False, event=event ) snapshot_data = data["snapshots"] diff --git a/src/aimbat/_cli/station.py b/src/aimbat/_cli/station.py index f4bb6af..5cc43ee 100644 --- a/src/aimbat/_cli/station.py +++ b/src/aimbat/_cli/station.py @@ -59,10 +59,10 @@ def cli_station_list( table_parameters: TableParameters = TableParameters(), global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Print information on the stations used in the active event.""" + """Print information on the stations used in an event.""" from aimbat.db import engine from aimbat.core import ( - get_active_event, + resolve_event, get_stations_in_event, ) from aimbat.utils import uuid_shortener, json_to_table, TABLE_STYLING @@ -83,14 +83,14 @@ def cli_station_list( data = dump_station_table_with_counts(session) else: - logger.debug("Selecting AIMBAT stations used by active event.") - active_event = get_active_event(session) - data = get_stations_in_event(session, active_event, as_json=True) + logger.debug("Selecting AIMBAT stations used by event.") + event = resolve_event(session, global_parameters.event_id) + data = get_stations_in_event(session, event, as_json=True) if short: - title = f"AIMBAT stations for event {active_event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, active_event)})" + title = f"AIMBAT stations for event {event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, event)})" else: - title = f"AIMBAT stations for event {active_event.time} (ID={active_event.id})" + title = f"AIMBAT stations for event {event.time} (ID={event.id})" column_order = [ "id", diff --git a/src/aimbat/_tui/app.py b/src/aimbat/_tui/app.py index 0ec0cd7..43b639d 100644 --- a/src/aimbat/_tui/app.py +++ b/src/aimbat/_tui/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from collections.abc import Callable from pathlib import Path from pydantic import ValidationError @@ -41,7 +42,7 @@ sync_iccs_parameters, delete_snapshot_by_id, delete_station_by_id, - get_active_event, + get_default_event, rollback_to_snapshot_by_id, run_iccs, run_mccc, @@ -53,7 +54,7 @@ from aimbat import settings from aimbat.db import engine from aimbat.io import DataType, DATATYPE_SUFFIXES -from aimbat.models import AimbatSeismogram, AimbatSnapshot +from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatSnapshot from aimbat._tui._widgets import VimDataTable from aimbat._tui.modals import ( ActionMenuModal, @@ -78,7 +79,7 @@ ("reset", "Reset parameters"), ("delete", "Delete seismogram"), ], - "tab-stations": [("delete", "Delete station and all seismograms")], + "tab-stations": [("delete", "Delete station and all seismograms (all events)")], "tab-snapshots": [ ("show_details", "Show details"), ("rollback", "Rollback to this snapshot"), @@ -87,6 +88,37 @@ } +# Extend _TOOL_REGISTRY to register new interactive tools. Each entry maps a +# key to a (label, callable) pair. The callable receives +# (session, event, iccs, context, all_seismograms) and returns None. +type _ToolFn = Callable[[Session, AimbatEvent, ICCS, bool, bool], None] + + +def _tool_phase( + session: Session, event: AimbatEvent, iccs: ICCS, context: bool, all_seis: bool +) -> None: + update_pick(session, iccs, context, all_seis, False, return_fig=False) + + +def _tool_window( + session: Session, event: AimbatEvent, iccs: ICCS, context: bool, all_seis: bool +) -> None: + update_timewindow(session, event, iccs, context, all_seis, False, return_fig=False) + + +def _tool_ccnorm( + session: Session, event: AimbatEvent, iccs: ICCS, context: bool, all_seis: bool +) -> None: + update_min_ccnorm(session, event, iccs, context, all_seis, 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), +} + + # --------------------------------------------------------------------------- # Main application # --------------------------------------------------------------------------- @@ -127,7 +159,9 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self._bound_iccs: BoundICCS | None = None + self._iccs_creating: bool = False self._iccs_last_modified_seen: Timestamp | None = None + self._current_event_id: uuid.UUID | None = None self._active_tab: str = "tab-seismograms" self.theme = _DEFAULT_THEME @@ -137,6 +171,16 @@ def on_mount(self) -> None: self._setup_station_table() self._setup_snapshot_table() + # Prime _last_known_default_id so the first poll doesn't fire a + # spurious refresh_all(). + try: + with Session(engine) as session: + self._last_known_default_id: uuid.UUID | None = get_default_event( + session + ).id + except (NoResultFound, RuntimeError): + self._last_known_default_id = None + self.set_interval(5, self._check_iccs_staleness) self._create_iccs() self.refresh_all() @@ -158,6 +202,23 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No return True if tab == "tab-snapshots" else False return True + # ------------------------------------------------------------------ + # Event selection + # ------------------------------------------------------------------ + + def _get_current_event(self, session: Session) -> AimbatEvent: + """Return the event currently selected for processing in the TUI. + + Falls back to the DB default event when no explicit selection has been made. + Clears a stale ``_current_event_id`` if the referenced event no longer exists. + """ + if self._current_event_id is not None: + event = session.get(AimbatEvent, self._current_event_id) + if event is not None: + return event + self._current_event_id = None + return get_default_event(session) + # ------------------------------------------------------------------ # ICCS lifecycle # ------------------------------------------------------------------ @@ -166,7 +227,11 @@ def _create_iccs(self) -> None: """Discard the existing ICCS instance and create a new one in a background worker. ICCS construction reads waveform data, so it must not block the asyncio event loop. + Concurrent calls are ignored — only one worker runs at a time. """ + if self._iccs_creating: + return + self._iccs_creating = True self._bound_iccs = None self._worker_create_iccs() @@ -175,19 +240,22 @@ def _worker_create_iccs(self) -> None: """Background worker: create ICCS instance without blocking the UI.""" try: with Session(engine) as session: - active_event = get_active_event(session) - bound_iccs = create_iccs_instance(session, active_event) + event = self._get_current_event(session) + bound_iccs = create_iccs_instance(session, event) except (NoResultFound, RuntimeError): + self.call_from_thread(setattr, self, "_iccs_creating", False) return except Exception as exc: self.call_from_thread( self.notify, f"ICCS init failed: {exc}", severity="error" ) + self.call_from_thread(setattr, self, "_iccs_creating", False) return self.call_from_thread(self._assign_iccs, bound_iccs) def _assign_iccs(self, bound_iccs: BoundICCS) -> None: """Main-thread callback: store the new BoundICCS instance and refresh status.""" + self._iccs_creating = False self._bound_iccs = bound_iccs self._refresh_event_bar() self._refresh_seismograms() @@ -232,7 +300,7 @@ def refresh_all(self) -> None: self._refresh_snapshots() def _check_iccs_staleness(self) -> None: - """Trigger ICCS recreation if the active event has been modified externally. + """Trigger ICCS recreation if the default event has been modified externally. When ICCS creation previously failed (e.g. due to an invalid parameter set via the CLI), retries whenever ``event.last_modified`` changes. On any detected @@ -240,8 +308,15 @@ def _check_iccs_staleness(self) -> None: """ try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) + try: + default_id: uuid.UUID | None = get_default_event(session).id + except NoResultFound: + default_id = None changed = False + if default_id != self._last_known_default_id: + self._last_known_default_id = default_id + changed = True if self._bound_iccs is not None: if self._bound_iccs.is_stale(event): self._iccs_last_modified_seen = event.last_modified @@ -260,7 +335,12 @@ def _refresh_event_bar(self) -> None: bar = self.query_one("#event-bar", Static) try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) + try: + default_id = get_default_event(session).id + except NoResultFound: + default_id = None + marker = "●" if event.id == default_id else "▶" iccs_status = ( " ● ICCS ready" if self._bound_iccs is not None else " ○ no ICCS" ) @@ -273,11 +353,11 @@ def _refresh_event_bar(self) -> None: else "" ) bar.update( - f"Active event: {time_str} | {lat}, {lon}{modified}" + f"{marker} {time_str} | {lat}, {lon}{modified}" f" [dim]{iccs_status} e = switch event[/dim]" ) except NoResultFound: - bar.update("[red]No active event — press e to select one[/red]") + bar.update("[red]No event selected — press e to select one[/red]") except RuntimeError as exc: bar.update(f"[red]{exc}[/red]") @@ -298,7 +378,7 @@ def _refresh_seismograms(self) -> None: try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) seismograms = sorted( event.seismograms, key=lambda s: ccnorm_map.get(s.id, -2.0), @@ -341,7 +421,7 @@ def _refresh_parameters(self) -> None: table.clear() try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) p = event.parameters for attr, field_info in AimbatEventParametersBase.model_fields.items(): value = getattr(p, attr) @@ -365,7 +445,7 @@ def _refresh_stations(self) -> None: table.clear() try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) seen: set[uuid.UUID] = set() for seis in event.seismograms: st = seis.station @@ -399,7 +479,7 @@ def _refresh_snapshots(self) -> None: table.clear() try: with Session(engine) as session: - event = get_active_event(session) + event = self._get_current_event(session) for snap in event.snapshots: short_id = str(snap.id)[:8] date_str = str(snap.date)[:19] if snap.date else "—" @@ -463,8 +543,8 @@ def _edit_parameter(self, attr: str) -> None: """Toggle a bool parameter, or open input modal for others.""" try: with Session(engine) as session: - active_event = get_active_event(session) - current = getattr(active_event.parameters, attr) + event = self._get_current_event(session) + current = getattr(event.parameters, attr) except (NoResultFound, RuntimeError) as exc: self.notify(str(exc), severity="error") return @@ -512,18 +592,16 @@ def _apply_parameter(self, attr: str, value: object) -> None: try: with Session(engine) as session: - active_event = get_active_event(session) + event = self._get_current_event(session) if attr in {p.value for p in EventParameter}: - set_event_parameter( - session, active_event, EventParameter(attr), value - ) # type: ignore[call-overload] + set_event_parameter(session, event, EventParameter(attr), value) # type: ignore[call-overload] else: # mccc_damp / mccc_min_ccnorm — not in EventParameter enum validated = AimbatEventParametersBase.model_validate( - active_event.parameters, update={attr: value} + event.parameters, update={attr: value} ) - setattr(active_event.parameters, attr, getattr(validated, attr)) - session.add(active_event) + setattr(event.parameters, attr, getattr(validated, attr)) + session.add(event) session.commit() except ValidationError as exc: msgs = "; ".join( @@ -612,7 +690,7 @@ def _reset_seismogram_parameters(self, item_id: str) -> None: def _confirm_delete(self, tab: str, item_id: str) -> None: messages = { "tab-seismograms": "Delete this seismogram?", - "tab-stations": "Delete this station and all its seismograms?", + "tab-stations": "Delete this station and all its seismograms across all events?", "tab-snapshots": "Delete this snapshot?", } msg = messages.get(tab) @@ -675,10 +753,8 @@ def on_confirm(confirmed: bool | None) -> None: with Session(engine) as session: rollback_to_snapshot_by_id(session, uuid.UUID(snap_id)) if self._bound_iccs is not None: - active_event = get_active_event(session) - sync_iccs_parameters( - session, active_event, self._bound_iccs.iccs - ) + event = self._get_current_event(session) + sync_iccs_parameters(session, event, self._bound_iccs.iccs) self._bound_iccs.created_at = Timestamp.now("UTC") if self._bound_iccs is None: self._create_iccs() @@ -694,12 +770,14 @@ def on_confirm(confirmed: bool | None) -> None: # ------------------------------------------------------------------ def action_switch_event(self) -> None: - def on_result(event_id: uuid.UUID | None) -> None: - if event_id is not None: + def on_result(result: tuple[uuid.UUID, bool] | None) -> None: + if result is not None: + event_id, _set_as_default = result + self._current_event_id = event_id self._create_iccs() self.refresh_all() - self.push_screen(EventSwitcherModal(), on_result) + self.push_screen(EventSwitcherModal(self._current_event_id), on_result) def action_add_data(self) -> None: actions = [(dt.value, dt.name.replace("_", " ")) for dt in DataType] @@ -743,15 +821,25 @@ def _require_iccs(self) -> bool: """Return True if ICCS is ready; show a contextual warning and return False otherwise.""" if self._bound_iccs is not None: return True - try: - with Session(engine) as session: - get_active_event(session) + # If an event is explicitly selected or a default exists, ICCS simply + # isn't ready yet (bad parameters, still loading, etc.). Otherwise + # there is no event at all. + if self._current_event_id is not None: + has_event = True + else: + try: + with Session(engine) as session: + get_default_event(session) + has_event = True + except (NoResultFound, RuntimeError): + has_event = False + if has_event: self.notify( "ICCS not ready — check event parameters (Parameters tab)", severity="warning", ) - except (NoResultFound, RuntimeError): - self.notify("No active event — press e to select one", severity="warning") + else: + self.notify("No event selected — press e to select one", severity="warning") return False def action_open_interactive_tools(self) -> None: @@ -760,12 +848,12 @@ def action_open_interactive_tools(self) -> None: def on_result(result: tuple[str, bool, bool] | None) -> None: if result is not None: - self._run_pick_tool(*result) + self._run_tool(*result) self.push_screen(InteractiveToolsModal(), on_result) - def _run_pick_tool(self, tool: str, context: bool, all_seis: bool) -> None: - """Run an interactive pick tool, suspending Textual while matplotlib is active. + def _run_tool(self, tool: str, context: bool, all_seis: bool) -> None: + """Run an interactive tool, suspending Textual while matplotlib is active. Uses the long-lived ICCS instance (waveform data already loaded) and runs matplotlib on the main thread via App.suspend(), which is the correct @@ -774,12 +862,8 @@ def _run_pick_tool(self, tool: str, context: bool, all_seis: bool) -> None: if self._bound_iccs is None: self.notify("ICCS not ready — please wait", severity="warning") return - _TOOL_LABELS = { - "phase": "Phase arrival (t1)", - "window": "Time window", - "ccnorm": "Min CC norm", - } - tool_label = _TOOL_LABELS.get(tool, tool) + label, fn = _TOOL_REGISTRY[tool] + iccs = self._bound_iccs.iccs try: with self.suspend(): @@ -787,7 +871,7 @@ def _run_pick_tool(self, tool: str, context: bool, all_seis: bool) -> None: console.clear() console.print( Panel( - f"[bold]{tool_label}[/bold]\n\n" + f"[bold]{label}[/bold]\n\n" "Close the matplotlib window to return to AIMBAT.", title="Interactive Tool Running", border_style="bright_blue", @@ -795,35 +879,8 @@ def _run_pick_tool(self, tool: str, context: bool, all_seis: bool) -> None: ) ) with Session(engine) as session: - active_event = get_active_event(session) - if tool == "phase": - update_pick( - session, - self._bound_iccs.iccs, - context, - all_seis, - False, - return_fig=False, - ) - elif tool == "window": - update_timewindow( - session, - active_event, - self._bound_iccs.iccs, - context, - all_seis, - False, - return_fig=False, - ) - elif tool == "ccnorm": - update_min_ccnorm( - session, - active_event, - self._bound_iccs.iccs, - context, - all_seis, - return_fig=False, - ) + 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") @@ -859,8 +916,8 @@ def _run_align_tool( if algorithm == "iccs": run_iccs(session, iccs, autoflip, autoselect) elif algorithm == "mccc": - active_event = get_active_event(session) - run_mccc(session, active_event, iccs, all_seis) + event = self._get_current_event(session) + run_mccc(session, event, iccs, all_seis) except Exception as exc: self.call_from_thread(self.notify, str(exc), severity="error") return @@ -880,8 +937,8 @@ def on_comment(comment: str | None) -> None: return try: with Session(engine) as session: - active_event = get_active_event(session) - create_snapshot(session, active_event, comment or None) + event = self._get_current_event(session) + create_snapshot(session, event, comment or None) self._refresh_snapshots() self.notify("Snapshot created", timeout=2) except Exception as exc: diff --git a/src/aimbat/_tui/modals.py b/src/aimbat/_tui/modals.py index 7f38396..60b0519 100644 --- a/src/aimbat/_tui/modals.py +++ b/src/aimbat/_tui/modals.py @@ -16,7 +16,7 @@ from aimbat._tui._widgets import VimDataTable -from aimbat.core import delete_event_by_id, get_active_event, set_active_event_by_id +from aimbat.core import delete_event_by_id, get_default_event, set_default_event_by_id from aimbat.db import engine from aimbat.models import AimbatEvent @@ -34,7 +34,7 @@ class _Hint(StrEnum): SAVE_CANCEL = ( "[@click='screen.save']⏎ save[/] [@click='screen.cancel']⎋ cancel[/]" ) - NAVIGATE_ACTIVATE_CANCEL = "↑↓ navigate [@click='screen.select']⏎ activate[/] [@click='screen.toggle_completed']c complete[/] [@click='screen.delete_event']⌫ delete[/] [@click='screen.cancel']⎋ cancel[/]" + NAVIGATE_EVENT_SWITCHER = "↑↓ navigate [@click='screen.select']⏎ select[/] [@click='screen.set_default']d default[/] [@click='screen.toggle_completed']c complete[/] [@click='screen.delete_event']⌫ delete[/] [@click='screen.cancel']⎋ cancel[/]" NAVIGATE_SELECT_CANCEL = "↑↓ navigate [@click='screen.select']⏎ select[/] [@click='screen.cancel']⎋ cancel[/]" 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[/]" @@ -58,29 +58,35 @@ class _Hint(StrEnum): # --------------------------------------------------------------------------- -class EventSwitcherModal(ModalScreen[uuid.UUID | None]): - """Modal screen for selecting and activating a seismic event.""" +class EventSwitcherModal(ModalScreen[tuple[uuid.UUID, bool] | None]): + """Modal screen for selecting a seismic event to process. + + Enter selects the event for TUI processing without changing the DB default. + Press ``d`` to also promote it to the DB default event. + """ BINDINGS = [ Binding("escape", "cancel", "Cancel", show=False), Binding("c", "toggle_completed", "Complete", show=True), + Binding("d", "set_default", "Set Default", show=True), Binding("backspace", "delete_event", "Delete", show=True), ] - def __init__(self) -> None: + def __init__(self, current_event_id: uuid.UUID | None = None) -> None: super().__init__() + self._current_event_id = current_event_id self._selected_event_id: str | None = None def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: - if action in {"delete_event", "toggle_completed"}: + if action in {"delete_event", "toggle_completed", "set_default"}: return True if self._selected_event_id else False return True def compose(self) -> ComposeResult: with Container(id="switcher-dialog"): - yield Label("Switch Active Event", classes=_CSS.TITLE) + yield Label("Switch Event", classes=_CSS.TITLE) yield VimDataTable(id="event-table") - yield Label(_Hint.NAVIGATE_ACTIVATE_CANCEL, classes=_CSS.HINT) + yield Label(_Hint.NAVIGATE_EVENT_SWITCHER, classes=_CSS.HINT) def on_mount(self) -> None: table = self.query_one(DataTable) @@ -102,14 +108,23 @@ def _populate(self, table: DataTable) -> None: try: with Session(engine) as session: events = session.exec(select(AimbatEvent)).all() - active_id: uuid.UUID | None = None + default_id: uuid.UUID | None = None try: - active_id = get_active_event(session).id + default_id = get_default_event(session).id except NoResultFound: pass for event in events: - active_marker = "●" if event.id == active_id else " " + is_default = event.id == default_id + is_current = event.id == self._current_event_id + if is_default and is_current: + marker = "★" + elif is_default: + marker = "●" + elif is_current: + marker = "▶" + else: + marker = " " done_marker = "✓" if event.parameters.completed else " " short_id = str(event.id)[:8] time_str = str(event.time)[:19] if event.time else "—" @@ -121,7 +136,7 @@ def _populate(self, table: DataTable) -> None: f"{event.depth / 1000:.1f}" if event.depth is not None else "—" ) table.add_row( - active_marker, + marker, done_marker, short_id, time_str, @@ -154,12 +169,18 @@ def row_selected(self, event: DataTable.RowSelected) -> None: row_key = event.row_key.value if not row_key: return + self.dismiss((uuid.UUID(row_key), False)) + + def action_set_default(self) -> None: + event_id = self._selected_event_id + if not event_id: + return try: - event_uuid = uuid.UUID(row_key) + event_uuid = uuid.UUID(event_id) with Session(engine) as session: - set_active_event_by_id(session, event_uuid) + set_default_event_by_id(session, event_uuid) session.commit() - self.dismiss(event_uuid) + self.dismiss((event_uuid, True)) except Exception as exc: self.notify(str(exc), severity="error") @@ -372,10 +393,11 @@ def action_cancel(self) -> None: # --------------------------------------------------------------------------- -# Interactive Tools modal (pick phase / window / ccnorm) +# Interactive Tools modal # --------------------------------------------------------------------------- -_PICK_TOOLS: list[tuple[str, str]] = [ +# Keep in sync with _TOOL_REGISTRY in app.py. +_TOOLS: list[tuple[str, str]] = [ ("phase", "Phase arrival (t1)"), ("window", "Time window"), ("ccnorm", "Min CC norm"), @@ -383,7 +405,7 @@ def action_cancel(self) -> None: class InteractiveToolsModal(ModalScreen[tuple[str, bool, bool] | None]): - """Menu for launching interactive matplotlib pick tools. + """Menu for launching interactive matplotlib tools. Options are toggled with key bindings so no Checkbox widgets are needed. Dismisses with (tool_key, context, all_seismograms) or None on cancel. @@ -414,7 +436,7 @@ def on_mount(self) -> None: table = self.query_one("#tools-table", DataTable) table.cursor_type = "row" table.add_column("tool") - for key, label in _PICK_TOOLS: + for key, label in _TOOLS: table.add_row(label, key=key) self._update_options() table.focus() diff --git a/src/aimbat/core/__init__.py b/src/aimbat/core/__init__.py index cba0413..d7c18b2 100644 --- a/src/aimbat/core/__init__.py +++ b/src/aimbat/core/__init__.py @@ -4,9 +4,9 @@ All functions take a SQLModel `Session` and work with the models in `aimbat.models`. The main areas covered are: -- **Active event** — get and set the active event (`get_active_event`, - `set_active_event`). Only one event is processed at a time; switching clears - the seismogram data cache. +- **Default event** — get and set the default event (`get_default_event`, + `set_default_event`). Only one event is set as default at a time; switching + clears the seismogram data cache. - **Data** — add data to the project, linking each source to its station, event, and seismogram records (`add_data_to_project`). - **Events, seismograms, stations** — query, update, and delete records; read @@ -24,7 +24,7 @@ _internal_names = set(dir()) -from ._active_event import * +from ._default_event import * from ._data import * from ._event import * from ._iccs import * diff --git a/src/aimbat/core/_active_event.py b/src/aimbat/core/_active_event.py deleted file mode 100644 index 87a2176..0000000 --- a/src/aimbat/core/_active_event.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Get and set the active event (i.e. the one being processed).""" - -# WARNING: Do not import other modules from `aimbat.core` here to avoid circular imports -from sqlmodel import Session, select -from sqlalchemy.exc import NoResultFound -from contextlib import suppress -from uuid import UUID -from aimbat.io import clear_seismogram_cache -from aimbat.logger import logger -from aimbat.models import AimbatEvent -from aimbat._cli.common import HINTS - -__all__ = [ - "get_active_event", - "set_active_event_by_id", - "set_active_event", -] - - -def get_active_event(session: Session) -> AimbatEvent: - """ - Return the currently active event (i.e. the one being processed). - - Args: - session: SQL session. - - Returns: - Active Event - - Raises - NoResultFound: When no event is active. - """ - - logger.debug("Attempting to determine active event.") - - statement = select(AimbatEvent).where(AimbatEvent.active == 1) - - # NOTE: While there technically can be no active event in the database, - # we typically don't really want to go beyond this point when that is the - # case. Hence we call `one` rather than `one_or_none`. - try: - active_event = session.exec(statement).one() - except NoResultFound: - raise NoResultFound(f"No active event found. {HINTS.ACTIVATE_EVENT}") - - logger.debug(f"Active event: {active_event.id}") - - return active_event - - -def set_active_event_by_id(session: Session, event_id: UUID) -> None: - """ - Set the currently selected event (i.e. the one being processed) by its ID. - - Args: - session: SQL session. - event_id: ID of AIMBAT Event to set as active one. - - Raises: - ValueError: If no event with the given ID is found. - """ - logger.info(f"Setting active event to event with id={event_id}.") - - if event_id not in session.exec(select(AimbatEvent.id)).all(): - raise ValueError( - f"No AimbatEvent found with id: {event_id}. {HINTS.LIST_EVENTS}" - ) - - aimbat_event = session.exec( - select(AimbatEvent).where(AimbatEvent.id == event_id) - ).one() - set_active_event(session, aimbat_event) - - -def set_active_event(session: Session, event: AimbatEvent) -> None: - """ - Set the active event (i.e. the one being processed). - - Args: - session: SQL session. - event: AIMBAT Event to set as active. - """ - - logger.info(f"Activating {event=}") - - with suppress(NoResultFound): - if event.id == get_active_event(session).id: - return - - clear_seismogram_cache() - event.active = True - session.add(event) - session.commit() diff --git a/src/aimbat/core/_default_event.py b/src/aimbat/core/_default_event.py new file mode 100644 index 0000000..d0ac008 --- /dev/null +++ b/src/aimbat/core/_default_event.py @@ -0,0 +1,113 @@ +"""Get and set the default event (i.e. the one being processed by default).""" + +from sqlmodel import Session, select +from sqlalchemy.exc import NoResultFound +from contextlib import suppress +from uuid import UUID +from aimbat.logger import logger +from aimbat.models import AimbatEvent +from aimbat._cli.common import HINTS + +__all__ = [ + "get_default_event", + "set_default_event_by_id", + "set_default_event", + "resolve_event", +] + + +def get_default_event(session: Session) -> AimbatEvent: + """ + Return the currently default event (i.e. the one being processed by default). + + Args: + session: SQL session. + + Returns: + Default Event + + Raises + NoResultFound: When no event is set as default. + """ + + logger.debug("Attempting to determine default event.") + + statement = select(AimbatEvent).where(AimbatEvent.is_default == 1) + + # NOTE: While there technically can be no default event in the database, + # we typically don't really want to go beyond this point when that is the + # case. Hence we call `one` rather than `one_or_none`. + try: + default_event = session.exec(statement).one() + except NoResultFound: + raise NoResultFound(f"No default event found. {HINTS.SET_DEFAULT_EVENT}") + + logger.debug(f"Default event: {default_event.id}") + + return default_event + + +def resolve_event(session: Session, event_id: UUID | None = None) -> AimbatEvent: + """ + Resolve an event from either an explicit ID or the default event. + + Args: + session: SQL session. + event_id: Optional event ID. + + Returns: + The specified event or the default event. + """ + if event_id: + logger.debug(f"Resolving event by explicit ID: {event_id}") + event = session.get(AimbatEvent, event_id) + if event is None: + raise ValueError( + f"No AimbatEvent found with id: {event_id}. {HINTS.LIST_EVENTS}" + ) + return event + return get_default_event(session) + + +def set_default_event_by_id(session: Session, event_id: UUID) -> None: + """ + Set the currently selected event (i.e. the one being processed) by its ID. + + Args: + session: SQL session. + event_id: ID of AIMBAT Event to set as default one. + + Raises: + ValueError: If no event with the given ID is found. + """ + logger.info(f"Setting default event to event with id={event_id}.") + + if event_id not in session.exec(select(AimbatEvent.id)).all(): + raise ValueError( + f"No AimbatEvent found with id: {event_id}. {HINTS.LIST_EVENTS}" + ) + + aimbat_event = session.exec( + select(AimbatEvent).where(AimbatEvent.id == event_id) + ).one() + set_default_event(session, aimbat_event) + + +def set_default_event(session: Session, event: AimbatEvent) -> None: + """ + Set the default event (i.e. the one being processed). + + Args: + session: SQL session. + event: AIMBAT Event to set as default. + """ + + logger.info(f"Setting default {event=}") + + with suppress(NoResultFound): + if event.id == get_default_event(session).id: + return + + event.is_default = True + session.add(event) + session.commit() diff --git a/src/aimbat/core/_iccs.py b/src/aimbat/core/_iccs.py index 31c8ac7..6d9a537 100644 --- a/src/aimbat/core/_iccs.py +++ b/src/aimbat/core/_iccs.py @@ -198,7 +198,7 @@ def plot_stack(iccs: ICCS, context: bool, all: bool, return_fig: bool) -> tuple A tuple of (Figure, Axes) if return_fig is True, otherwise None. """ - logger.info("Plotting ICCS stack for active event.") + logger.info("Plotting ICCS stack for default event.") return _plot_stack(iccs, context, all, return_fig=return_fig) # type: ignore[call-overload] @@ -217,7 +217,7 @@ def plot_iccs_seismograms( A tuple of (Figure, Axes) if return_fig is True, otherwise None. """ - logger.info("Plotting ICCS seismograms for active event.") + logger.info("Plotting ICCS seismograms for default event.") return _plot_seismograms(iccs, context, all, return_fig=return_fig) # type: ignore[call-overload] @@ -230,7 +230,7 @@ def update_pick( use_seismogram_image: bool, return_fig: bool, ) -> tuple | None: - """Update the pick for the active event. + """Update the pick for the default event. Args: iccs: ICCS instance. @@ -243,7 +243,7 @@ def update_pick( A tuple of (Figure, Axes, widgets) if return_fig is True, otherwise None. """ - logger.info("Updating pick for active event.") + logger.info("Updating pick for default event.") result = _update_pick( # type: ignore[call-overload] iccs, context, all, use_seismogram_image, return_fig=return_fig diff --git a/src/aimbat/core/_project.py b/src/aimbat/core/_project.py index e61590b..1464bbd 100644 --- a/src/aimbat/core/_project.py +++ b/src/aimbat/core/_project.py @@ -58,23 +58,23 @@ def create_project(engine: Engine) -> None: with engine.begin() as connection: # Trigger 1: Handle updates to existing rows connection.execute(text(""" - CREATE TRIGGER IF NOT EXISTS single_active_event_update + CREATE TRIGGER IF NOT EXISTS single_default_event_update BEFORE UPDATE ON aimbatevent - FOR EACH ROW WHEN NEW.active = TRUE + FOR EACH ROW WHEN NEW.is_default = TRUE BEGIN - UPDATE aimbatevent SET active = NULL - WHERE active = TRUE AND id != NEW.id; + UPDATE aimbatevent SET is_default = NULL + WHERE is_default = TRUE AND id != NEW.id; END; """)) - # Trigger 2: Handle brand new active events being inserted + # Trigger 2: Handle brand new default events being inserted connection.execute(text(""" - CREATE TRIGGER IF NOT EXISTS single_active_event_insert + CREATE TRIGGER IF NOT EXISTS single_default_event_insert BEFORE INSERT ON aimbatevent - FOR EACH ROW WHEN NEW.active = TRUE + FOR EACH ROW WHEN NEW.is_default = TRUE BEGIN - UPDATE aimbatevent SET active = NULL - WHERE active = TRUE; + UPDATE aimbatevent SET is_default = NULL + WHERE is_default = TRUE; END; """)) diff --git a/src/aimbat/core/_snapshot.py b/src/aimbat/core/_snapshot.py index 498bab8..90be371 100644 --- a/src/aimbat/core/_snapshot.py +++ b/src/aimbat/core/_snapshot.py @@ -44,7 +44,7 @@ def create_snapshot( event_parameters_snapshot = AimbatEventParametersSnapshot.model_validate( event.parameters, update={ - "id": uuid.uuid4(), # we don't want to carry over the id from the active parameters + "id": uuid.uuid4(), # we don't want to carry over the id from the input event parameters "parameters_id": event.parameters.id, }, ) @@ -57,7 +57,7 @@ def create_snapshot( seismogram_parameter_snapshot = AimbatSeismogramParametersSnapshot.model_validate( aimbat_seismogram.parameters, update={ - "id": uuid.uuid4(), # we don't want to carry over the id from the active parameters + "id": uuid.uuid4(), # we don't want to carry over the id from the input seismogram parameters "seismogram_parameters_id": aimbat_seismogram.parameters.id, }, ) diff --git a/src/aimbat/io/_base.py b/src/aimbat/io/_base.py index aec5973..553a747 100644 --- a/src/aimbat/io/_base.py +++ b/src/aimbat/io/_base.py @@ -27,7 +27,6 @@ __all__ = [ - "clear_seismogram_cache", "create_event", "create_seismogram", "create_station", @@ -154,11 +153,6 @@ def supports_seismogram_data_writing(datatype: DataType) -> bool: return datatype in _seismogram_data_writers -def clear_seismogram_cache() -> None: - """Clear the in-memory seismogram data cache.""" - _cache.clear() - - def create_station(datasource: str | PathLike, datatype: DataType) -> AimbatStation: """Create an `AimbatStation` from a data source. @@ -228,7 +222,7 @@ def read_seismogram_data( Results are cached in memory by `(datasource, datatype)` key. The cache entry is invalidated when `write_seismogram_data` is called for the same - key, and can be cleared manually with `clear_seismogram_cache`. + key. Args: datasource: Data source path or name. diff --git a/src/aimbat/models/__init__.py b/src/aimbat/models/__init__.py index cd570c0..3a6f6d4 100644 --- a/src/aimbat/models/__init__.py +++ b/src/aimbat/models/__init__.py @@ -7,8 +7,8 @@ The main classes and their relationships are: -- `AimbatEvent` — a seismic event. Only one event can be active at a time, - enforced by a database constraint on the `active` column. +- `AimbatEvent` — a seismic event. Only one event can be the default at a time, + enforced by a database constraint on the `is_default` column. - `AimbatStation` — a seismic recording station. - `AimbatSeismogram` — links an `AimbatEvent` to an `AimbatStation` and holds the timing metadata (`begin_time`, `delta`, `t0`). Waveform data is accessed diff --git a/src/aimbat/models/_models.py b/src/aimbat/models/_models.py index 80e76a0..b673b23 100644 --- a/src/aimbat/models/_models.py +++ b/src/aimbat/models/_models.py @@ -338,10 +338,10 @@ class AimbatEvent(SQLModel, table=True): default_factory=uuid.uuid4, primary_key=True, description="Unique ID." ) - active: bool | None = Field( + is_default: bool | None = Field( default=None, unique=True, - description="Indicates if an event is the active event.", + description="Indicates if an event is the default event.", ) time: PydanticTimestamp = Field( diff --git a/src/aimbat/models/_readers.py b/src/aimbat/models/_readers.py index c48f800..987244d 100644 --- a/src/aimbat/models/_readers.py +++ b/src/aimbat/models/_readers.py @@ -13,7 +13,7 @@ class _AimbatEventRead(SQLModel): """Read model for AimbatEvent including computed counts.""" id: uuid.UUID - active: bool | None + is_default: bool | None time: PydanticTimestamp latitude: float longitude: float @@ -28,7 +28,7 @@ def from_event(cls, event: "AimbatEvent") -> Self: """Create an AimbatEventRead from an AimbatEvent ORM instance.""" return cls( id=event.id, - active=event.active, + is_default=event.is_default, time=event.time, latitude=event.latitude, longitude=event.longitude, diff --git a/tests/conftest.py b/tests/conftest.py index 4df07e6..52bd820 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import subprocess from aimbat.app import app from aimbat.io import DataType -from aimbat.core import add_data_to_project, set_active_event, create_project +from aimbat.core import add_data_to_project, set_default_event, create_project from aimbat.models import AimbatEvent from aimbat.logger import configure_logging from dataclasses import dataclass, field @@ -245,7 +245,7 @@ def patched_engine( @pytest.fixture() def loaded_engine(patched_engine: Engine, multi_event_data: Sequence[Path]) -> Engine: - """A patched engine pre-populated with multi-event data and an active event. + """A patched engine pre-populated with multi-event data and an default event. Args: patched_engine: The monkeypatched SQLAlchemy Engine. @@ -260,7 +260,7 @@ def loaded_engine(patched_engine: Engine, multi_event_data: Sequence[Path]) -> E add_data_to_project(session, datasources, DataType.SAC) events = session.exec(select(AimbatEvent)).all() lengths = [len(e.seismograms) for e in events] - set_active_event(session, events[lengths.index(max(lengths))]) + set_default_event(session, events[lengths.index(max(lengths))]) return patched_engine @@ -280,7 +280,7 @@ def patched_session(patched_engine: Engine) -> Generator[Session, None, None]: @pytest.fixture() def loaded_session(loaded_engine: Engine) -> Generator[Session, None, None]: - """A session pre-populated with multi-event data and an active event. + """A session pre-populated with multi-event data and an default event. Args: loaded_engine: The monkeypatched SQLAlchemy Engine with data loaded. diff --git a/tests/functional/test_cli_basic_ops.py b/tests/functional/test_cli_basic_ops.py index 96a44a4..eadb19e 100644 --- a/tests/functional/test_cli_basic_ops.py +++ b/tests/functional/test_cli_basic_ops.py @@ -158,43 +158,43 @@ def test_event_dump( events = cli_json("event dump") assert len(events) > 1 - def test_activate_event( + def test_default_event( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that an event can be activated.""" + """Verifies that an event can be set as default.""" events = cli_json("event dump") - inactive = [e for e in events if e["active"] is None] - assert len(inactive) > 0 - target_id = inactive[0]["id"] + non_default_events = [e for e in events if e["is_default"] is None] + assert len(non_default_events) > 0 + target_id = non_default_events[0]["id"] - cli(f"event activate {target_id}") + cli(f"event default {target_id}") events_after = cli_json("event dump") - active = [e for e in events_after if e["active"] is True] - assert len(active) == 1 - assert active[0]["id"] == target_id + default_events = [e for e in events_after if e["is_default"] is True] + assert len(default_events) == 1 + assert default_events[0]["id"] == target_id - def test_activate_switches_active( + def test_default_switches_previous( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], ) -> None: - """Activating a different event deactivates the previous one.""" + """Setting a different default event replaces the previous one.""" events = cli_json("event dump") ids = [e["id"] for e in events] - cli(f"event activate {ids[0]}") - cli(f"event activate {ids[1]}") + cli(f"event default {ids[0]}") + cli(f"event default {ids[1]}") events_after = cli_json("event dump") - active = [e for e in events_after if e["active"] is True] - assert len(active) == 1 - assert active[0]["id"] == ids[1] + default_events = [e for e in events_after if e["is_default"] is True] + assert len(default_events) == 1 + assert default_events[0]["id"] == ids[1] def test_delete_event( self, @@ -213,13 +213,13 @@ def test_delete_event( assert target_id not in remaining_ids assert len(events_after) == len(events_before) - 1 - def test_activate_event_with_short_id( + def test_default_event_with_short_id( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that an event can be activated using a shortened ID. + """Verifies that an event can be set as default using a shortened ID. Args: loaded_engine: The monkeypatched engine with data loaded. @@ -227,17 +227,17 @@ def test_activate_event_with_short_id( cli_json: The in-process CLI JSON dump callable. """ events = cli_json("event dump") - inactive = [e for e in events if e["active"] is None] - assert len(inactive) > 0 - target_id = inactive[0]["id"] + non_default_events = [e for e in events if e["is_default"] is None] + assert len(non_default_events) > 0 + target_id = non_default_events[0]["id"] short_id = target_id[:8] - cli(f"event activate {short_id}") + cli(f"event default {short_id}") events_after = cli_json("event dump") - active = [e for e in events_after if e["active"] is True] - assert len(active) == 1 - assert active[0]["id"] == target_id + default_events = [e for e in events_after if e["is_default"] is True] + assert len(default_events) == 1 + assert default_events[0]["id"] == target_id def test_delete_event_with_short_id( self, diff --git a/tests/functional/test_cli_parameters.py b/tests/functional/test_cli_parameters.py index da89a7c..45d41b1 100644 --- a/tests/functional/test_cli_parameters.py +++ b/tests/functional/test_cli_parameters.py @@ -115,13 +115,13 @@ def test_set_completed_true( cli_json: The in-process CLI JSON dump callable. """ before = cli_json("event parameter dump") - assert isinstance(before, dict), "Dump should return a dict for active event" + assert isinstance(before, dict), "Dump should return a dict for default event" assert before["completed"] is False, "'completed' should default to False" cli("event parameter set completed true") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["completed"] is True, "'completed' should be True after being set" def test_set_completed_false( @@ -140,7 +140,7 @@ def test_set_completed_false( cli("event parameter set completed true") cli("event parameter set completed false") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert ( after["completed"] is False ), "'completed' should be False after being set back" @@ -159,13 +159,13 @@ def test_set_bandpass_apply( cli_json: The in-process CLI JSON dump callable. """ before = cli_json("event parameter dump") - assert isinstance(before, dict), "Dump should return a dict for active event" + assert isinstance(before, dict), "Dump should return a dict for default event" original = before["bandpass_apply"] cli(f"event parameter set bandpass_apply {not original}".lower()) after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert ( after["bandpass_apply"] is not original ), "'bandpass_apply' should have toggled after set" @@ -190,7 +190,7 @@ def test_set_min_ccnorm( """ cli("event parameter set min_ccnorm 0.42") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["min_ccnorm"] == pytest.approx( 0.42 ), "'min_ccnorm' should be 0.42 after being set" @@ -210,7 +210,7 @@ def test_set_bandpass_fmin( """ cli("event parameter set bandpass_fmin 0.1") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["bandpass_fmin"] == pytest.approx( 0.1 ), "'bandpass_fmin' should be 0.1 after being set" @@ -230,7 +230,7 @@ def test_set_bandpass_fmax( """ cli("event parameter set bandpass_fmax 2.0") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["bandpass_fmax"] == pytest.approx( 2.0 ), "'bandpass_fmax' should be 2.0 after being set" @@ -260,7 +260,7 @@ def test_set_window_pre_as_bare_number( """ cli("event parameter set window_pre -20") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["window_pre"] == pytest.approx( -20.0 ), "'window_pre' should be -20.0 seconds after being set with a bare number" @@ -280,7 +280,7 @@ def test_set_window_post_as_bare_number( """ cli("event parameter set window_post 30") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["window_post"] == pytest.approx( 30.0 ), "'window_post' should be 30.0 seconds after being set with a bare number" @@ -300,7 +300,7 @@ def test_set_window_pre_with_unit_string( """ cli("event parameter set window_post 20s") after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for active event" + assert isinstance(after, dict), "Dump should return a dict for default event" assert after["window_post"] == pytest.approx( 20.0 ), "'window_post' should be 20.0 seconds after being set with '20s'" @@ -315,12 +315,12 @@ def test_set_window_pre_with_unit_string( class TestEventParameterDump: """Tests for ``event parameter dump``.""" - def test_active_event_returns_dict( + def test_default_event_returns_dict( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that the active-event dump returns a dict. + """Verifies that the default-event dump returns a dict. Args: loaded_engine: The monkeypatched engine with data loaded. @@ -329,7 +329,7 @@ def test_active_event_returns_dict( data = cli_json("event parameter dump") assert isinstance(data, dict), "Active-event dump should be a dict" - def test_active_event_contains_all_parameter_keys( + def test_default_event_contains_all_parameter_keys( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], @@ -391,7 +391,7 @@ def test_set_visible_in_all_events_dump( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that a parameter change to the active event appears in the all-events dump. + """Verifies that a parameter change to the default event appears in the all-events dump. Args: loaded_engine: The monkeypatched engine with data loaded. @@ -401,9 +401,9 @@ def test_set_visible_in_all_events_dump( cli("event parameter set completed true") all_data = cli_json("event parameter dump --all") assert isinstance(all_data, list), "All-events dump should be a list" - active_entries = [e for e in all_data if e.get("completed") is True] + default_entries = [e for e in all_data if e.get("completed") is True] assert ( - len(active_entries) == 1 + len(default_entries) == 1 ), "Exactly one event should have completed=True after setting it" @@ -712,7 +712,7 @@ def test_set_does_not_affect_other_seismograms( ), "Seismogram parameter dump should be a list" assert ( len(params_before) > 1 - ), "Need at least two seismograms in the active event for this test" + ), "Need at least two seismograms in the default event for this test" target_id = params_before[0]["seismogram_id"] other_id = params_before[1]["seismogram_id"] other_select_before = params_before[1]["select"] @@ -779,23 +779,23 @@ def test_all_events_returns_more_entries( loaded_engine: Engine, cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that ``--all`` returns at least as many entries as the active-event dump. + """Verifies that ``--all`` returns at least as many entries as the default-event dump. Args: loaded_engine: The monkeypatched engine with data loaded. cli_json: The in-process CLI JSON dump callable. """ - active_data = cli_json("seismogram parameter dump") + default_data = cli_json("seismogram parameter dump") all_data = cli_json("seismogram parameter dump --all") assert isinstance( - active_data, list + default_data, list ), "Active-event seismogram parameter dump should be a list" assert isinstance( all_data, list ), "All-events seismogram parameter dump should be a list" assert len(all_data) >= len( - active_data - ), "--all should return at least as many entries as the active-event dump" + default_data + ), "--all should return at least as many entries as the default-event dump" def test_count_matches_seismogram_dump( self, diff --git a/tests/functional/test_cli_snapshots.py b/tests/functional/test_cli_snapshots.py index 1a4201a..5563238 100644 --- a/tests/functional/test_cli_snapshots.py +++ b/tests/functional/test_cli_snapshots.py @@ -460,13 +460,13 @@ def test_dump_contains_expected_keys( "seismogram_parameters" in data ), "Dump should contain 'seismogram_parameters' key" - def test_dump_all_events_includes_active( + def test_dump_all_events_includes_default( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that ``--all`` includes at least the active event's snapshots. + """Verifies that ``--all`` includes at least the default event's snapshots. Args: loaded_engine: The monkeypatched engine with data loaded. @@ -474,13 +474,13 @@ def test_dump_all_events_includes_active( cli_json: The in-process CLI JSON dump callable. """ cli("snapshot create") - active_data = cli_json("snapshot dump") + default_data = cli_json("snapshot dump") all_data = cli_json("snapshot dump --all") - assert isinstance(active_data, dict), "Active dump should return a dict" + assert isinstance(default_data, dict), "Default dump should return a dict" assert isinstance(all_data, dict), "All-events dump should return a dict" assert len(all_data["snapshots"]) >= len( - active_data["snapshots"] - ), "--all should return at least as many snapshots as the active-event dump" + default_data["snapshots"] + ), "--all should return at least as many snapshots as the default-event dump" def test_dump_snapshot_ids_are_consistent( self, @@ -518,13 +518,13 @@ def test_dump_snapshot_ids_are_consistent( class TestSnapshotList: """Tests for the ``snapshot list`` CLI command.""" - def test_list_active_event( + def test_list_default_event( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], ) -> None: - """Verifies that the list command produces output for the active event. + """Verifies that the list command produces output for the default event. Args: loaded_engine: The monkeypatched engine with data loaded. diff --git a/tests/integration/core/test_data.py b/tests/integration/core/test_data.py index bae713a..84eadb3 100644 --- a/tests/integration/core/test_data.py +++ b/tests/integration/core/test_data.py @@ -14,7 +14,7 @@ add_data_to_project, get_data_for_event, dump_data_table_to_json, - get_active_event, + get_default_event, ) from aimbat.models import ( AimbatDataSource, @@ -266,15 +266,15 @@ def session(self, loaded_session: Session) -> Generator[Session, None, None]: """ yield loaded_session - def test_get_data_sources_for_active_event(self, session: Session) -> None: + def test_get_data_sources_for_default_event(self, session: Session) -> None: """Verifies that get_data_sources returns the expected data sources. Args: session (Session): Database session. """ - active_event = get_active_event(session) - data_sources = get_data_for_event(session, active_event) - assert len(data_sources) != 0, "Expected data sources for the active event." + default_event = get_default_event(session) + data_sources = get_data_for_event(session, default_event) + assert len(data_sources) != 0, "Expected data sources for the default event." assert all( isinstance(ds, AimbatDataSource) for ds in data_sources ), "expected all items to be AimbatDataSource instances" diff --git a/tests/integration/core/test_event.py b/tests/integration/core/test_event.py index 736cc02..16dd379 100644 --- a/tests/integration/core/test_event.py +++ b/tests/integration/core/test_event.py @@ -3,11 +3,10 @@ import json import uuid import pytest -from unittest.mock import patch from aimbat.core import ( - set_active_event, - set_active_event_by_id, - get_active_event, + set_default_event, + set_default_event_by_id, + get_default_event, delete_event, delete_event_by_id, get_completed_events, @@ -26,7 +25,7 @@ @pytest.fixture def session(loaded_session: Session) -> Session: - """Provides a session with multi-event data and an active event pre-loaded. + """Provides a session with multi-event data and a default event pre-loaded. Args: loaded_session: A SQLModel Session with data populated. @@ -38,68 +37,68 @@ def session(loaded_session: Session) -> Session: # =================================================================== -# Active event +# Default event # =================================================================== -class TestActiveEvent: - """Tests for retrieving and switching the active event.""" +class TestDefaultEvent: + """Tests for retrieving and switching the default event.""" def test_get(self, session: Session) -> None: - """Verifies that `get_active_event` returns the event marked as active in the DB. + """Verifies that `get_default_event` returns the event marked as default in the DB. Args: session (Session): The database session. """ - active_event = session.exec( - select(AimbatEvent).where(AimbatEvent.active == 1) + default_event = session.exec( + select(AimbatEvent).where(AimbatEvent.is_default == 1) ).one() - assert active_event == get_active_event(session) + assert default_event == get_default_event(session) def test_switch(self, session: Session) -> None: - """Verifies switching the active event using an event object. + """Verifies switching the default event using an event object. Args: session (Session): The database session. """ - active_event = get_active_event(session) - assert active_event is not None, "expected an active event in the test data" + default_event = get_default_event(session) + assert default_event is not None, "expected a default event in the test data" all_events = list(session.exec(select(AimbatEvent)).all()) assert len(all_events) > 1, "expected multiple events in the test data" - all_events.remove(active_event) - new_active_event = all_events.pop() + all_events.remove(default_event) + new_default_event = all_events.pop() assert ( - new_active_event != active_event + new_default_event != default_event ), "expected a different event to switch to" - set_active_event(session, new_active_event) - assert get_active_event(session) == new_active_event + set_default_event(session, new_default_event) + assert get_default_event(session) == new_default_event def test_switch_by_id(self, session: Session) -> None: - """Verifies switching the active event using an event ID. + """Verifies switching the default event using an event ID. Args: session (Session): The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) event_ids = list(session.exec(select(AimbatEvent.id)).all()) - event_ids.remove(active_event.id) - new_active_event_id = event_ids.pop() + event_ids.remove(default_event.id) + new_default_event_id = event_ids.pop() assert ( - new_active_event_id != active_event.id + new_default_event_id != default_event.id ), "expected a different event id to switch to" - set_active_event_by_id(session, new_active_event_id) + set_default_event_by_id(session, new_default_event_id) assert ( - get_active_event(session).id == new_active_event_id - ), "expected the active event to switch to the new event by id" + get_default_event(session).id == new_default_event_id + ), "expected the default event to switch to the new event by id" def test_switch_by_id_invalid(self, session: Session) -> None: - """Verifies that switching the active event using an invalid event ID raises an error.""" + """Verifies that switching the default event using an invalid event ID raises an error.""" new_uuid = uuid.uuid4() assert ( @@ -112,53 +111,24 @@ def test_switch_by_id_invalid(self, session: Session) -> None: ), "expected no event with the generated UUID in the test data" with pytest.raises(ValueError): - set_active_event_by_id(session, uuid.uuid4()) + set_default_event_by_id(session, uuid.uuid4()) - def test_set_same_event_does_not_clear_cache(self, session: Session) -> None: - """Verifies that re-activating the already-active event does not clear the cache. - - Args: - session: The database session. - """ - active_event = get_active_event(session) - - with patch("aimbat.core._active_event.clear_seismogram_cache") as mock_clear: - set_active_event(session, active_event) - mock_clear.assert_not_called() - - def test_set_different_event_clears_cache(self, session: Session) -> None: - """Verifies that switching to a different event clears the cache. - - Args: - session: The database session. - """ - active_event = get_active_event(session) - other_event = next( - e - for e in session.exec(select(AimbatEvent)).all() - if e.id != active_event.id - ) - - with patch("aimbat.core._active_event.clear_seismogram_cache") as mock_clear: - set_active_event(session, other_event) - mock_clear.assert_called_once() - - def test_get_active_event_no_active(self, session: Session) -> None: - """Verifies that `get_active_event` returns None if no event is marked as active. + def test_get_default_event_no_default(self, session: Session) -> None: + """Verifies that `get_default_event` returns None if no event is marked as default. Args: session (Session): The database session. """ - active_event = get_active_event(session) - assert active_event is not None, "expected an active event in the test data" - active_event.active = None + default_event = get_default_event(session) + assert default_event is not None, "expected a default event in the test data" + default_event.is_default = None assert ( - session.exec(select(AimbatEvent).where(AimbatEvent.active == 1)).first() + session.exec(select(AimbatEvent).where(AimbatEvent.is_default == 1)).first() is None - ), "expected no active event in the database after deactivating" + ), "expected no default event in the database after deactivating" with pytest.raises(NoResultFound): - get_active_event(session) + get_default_event(session) # =================================================================== @@ -177,13 +147,13 @@ def test_delete_event(self, session: Session) -> None: """ events = session.exec(select(AimbatEvent)).all() count_before = len(events) - non_active = next(e for e in events if not e.active) + non_default = next(e for e in events if not e.is_default) - delete_event(session, non_active) + delete_event(session, non_default) remaining = session.exec(select(AimbatEvent)).all() assert len(remaining) == count_before - 1 - assert non_active not in remaining + assert non_default not in remaining def test_delete_event_by_id(self, session: Session) -> None: """Verifies that an event is removed from the database when deleted by ID. @@ -193,9 +163,9 @@ def test_delete_event_by_id(self, session: Session) -> None: """ events = session.exec(select(AimbatEvent)).all() count_before = len(events) - non_active = next(e for e in events if not e.active) + non_default = next(e for e in events if not e.is_default) - delete_event_by_id(session, non_active.id) + delete_event_by_id(session, non_default.id) remaining = session.exec(select(AimbatEvent)).all() assert len(remaining) == count_before - 1 @@ -289,7 +259,7 @@ def test_get_events_using_station_no_match(self, session: Session) -> None: class TestGetEventParameter: - """Tests for reading parameter values from the active event.""" + """Tests for reading parameter values from the default event.""" def test_get_timedelta_parameter(self, session: Session) -> None: """Verifies that a Timedelta parameter is returned as a Timedelta. @@ -297,8 +267,8 @@ def test_get_timedelta_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - value = get_event_parameter(session, active_event, EventParameter.WINDOW_PRE) + default_event = get_default_event(session) + value = get_event_parameter(session, default_event, EventParameter.WINDOW_PRE) assert isinstance(value, Timedelta) def test_get_float_parameter(self, session: Session) -> None: @@ -307,8 +277,8 @@ def test_get_float_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - value = get_event_parameter(session, active_event, EventParameter.MIN_CCNORM) + default_event = get_default_event(session) + value = get_event_parameter(session, default_event, EventParameter.MIN_CCNORM) assert isinstance(value, float) def test_get_bool_parameter(self, session: Session) -> None: @@ -317,13 +287,13 @@ def test_get_bool_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - value = get_event_parameter(session, active_event, EventParameter.COMPLETED) + default_event = get_default_event(session) + value = get_event_parameter(session, default_event, EventParameter.COMPLETED) assert isinstance(value, bool) class TestSetEventParameter: - """Tests for writing parameter values to the active event.""" + """Tests for writing parameter values to the default event.""" def test_set_timedelta_parameter(self, session: Session) -> None: """Verifies that a Timedelta parameter is persisted correctly. @@ -331,13 +301,13 @@ def test_set_timedelta_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) new_value = Timedelta(seconds=20) set_event_parameter( - session, active_event, EventParameter.WINDOW_POST, new_value + session, default_event, EventParameter.WINDOW_POST, new_value ) assert ( - get_event_parameter(session, active_event, EventParameter.WINDOW_POST) + get_event_parameter(session, default_event, EventParameter.WINDOW_POST) == new_value ) @@ -347,11 +317,13 @@ def test_set_float_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) new_value = 0.75 - set_event_parameter(session, active_event, EventParameter.MIN_CCNORM, new_value) + set_event_parameter( + session, default_event, EventParameter.MIN_CCNORM, new_value + ) assert ( - get_event_parameter(session, active_event, EventParameter.MIN_CCNORM) + get_event_parameter(session, default_event, EventParameter.MIN_CCNORM) == new_value ) @@ -361,10 +333,11 @@ def test_set_bool_parameter(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - set_event_parameter(session, active_event, EventParameter.COMPLETED, True) + default_event = get_default_event(session) + set_event_parameter(session, default_event, EventParameter.COMPLETED, True) assert ( - get_event_parameter(session, active_event, EventParameter.COMPLETED) is True + get_event_parameter(session, default_event, EventParameter.COMPLETED) + is True ) @@ -398,21 +371,21 @@ def test_as_list(self, session: Session) -> None: assert isinstance(result, list) assert len(result) > 0 assert "id" in result[0] - assert "active" in result[0] + assert "is_default" in result[0] class TestDumpEventParameterTableToJson: """Tests for serialising the event parameter table to JSON.""" - def test_active_event_as_string(self, session: Session) -> None: - """Verifies that a JSON string of the active event parameters is returned. + def test_default_event_as_string(self, session: Session) -> None: + """Verifies that a JSON string of the default event parameters is returned. Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_event_parameter_table_to_json( - session, all_events=False, as_string=True, event=active_event + session, all_events=False, as_string=True, event=default_event ) assert isinstance(result, str) parsed = json.loads(result) @@ -420,15 +393,15 @@ def test_active_event_as_string(self, session: Session) -> None: assert "window_pre" in parsed assert "window_post" in parsed - def test_active_event_as_dict(self, session: Session) -> None: - """Verifies that a dict of the active event parameters is returned. + def test_default_event_as_dict(self, session: Session) -> None: + """Verifies that a dict of the default event parameters is returned. Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_event_parameter_table_to_json( - session, all_events=False, as_string=False, event=active_event + session, all_events=False, as_string=False, event=default_event ) assert isinstance(result, dict) assert "min_ccnorm" in result diff --git a/tests/integration/core/test_seismogram.py b/tests/integration/core/test_seismogram.py index c3bf2fd..f6d2b44 100644 --- a/tests/integration/core/test_seismogram.py +++ b/tests/integration/core/test_seismogram.py @@ -16,7 +16,7 @@ dump_seismogram_table_to_json, dump_seismogram_parameter_table_to_json, plot_all_seismograms, - get_active_event, + get_default_event, ) from aimbat.models._parameters import AimbatSeismogramParametersBase from aimbat._types import SeismogramParameter @@ -29,7 +29,7 @@ @pytest.fixture def session(loaded_session: Session) -> Session: - """Provides a session with multi-event data and an active event pre-loaded. + """Provides a session with multi-event data and an default event pre-loaded. Args: loaded_session: A SQLModel Session with data populated. @@ -42,13 +42,13 @@ def session(loaded_session: Session) -> Session: @pytest.fixture def seismogram(session: Session) -> AimbatSeismogram: - """Provides the first seismogram from the active event. + """Provides the first seismogram from the default event. Args: session: The database session. Returns: - An AimbatSeismogram from the active event. + An AimbatSeismogram from the default event. """ return session.exec(select(AimbatSeismogram)).first() # type: ignore[return-value] @@ -270,13 +270,13 @@ class TestGetSelectedSeismograms: """Tests for retrieving selected seismograms.""" def test_all_selected_by_default(self, session: Session) -> None: - """Verifies that all seismograms in the active event are selected by default. + """Verifies that all seismograms in the default event are selected by default. Args: session: The database session. """ - active_event = get_active_event(session) - selected = get_selected_seismograms(session, event=active_event) + default_event = get_default_event(session) + selected = get_selected_seismograms(session, event=default_event) assert len(selected) > 0 def test_after_deselecting_one( @@ -288,11 +288,11 @@ def test_after_deselecting_one( session: The database session. seismogram: An AimbatSeismogram to deselect. """ - active_event = get_active_event(session) - count_before = len(get_selected_seismograms(session, event=active_event)) + default_event = get_default_event(session) + count_before = len(get_selected_seismograms(session, event=default_event)) set_seismogram_parameter(session, seismogram, SeismogramParameter.SELECT, False) assert ( - len(get_selected_seismograms(session, event=active_event)) + len(get_selected_seismograms(session, event=default_event)) == count_before - 1 ) @@ -302,12 +302,12 @@ def test_all_events(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - selected_active = get_selected_seismograms( - session, event=active_event, all_events=False + default_event = get_default_event(session) + selected_default = get_selected_seismograms( + session, event=default_event, all_events=False ) selected_all = get_selected_seismograms(session, all_events=True) - assert len(selected_all) >= len(selected_active) + assert len(selected_all) >= len(selected_default) class TestDumpSeismogramTableToJson: @@ -329,30 +329,30 @@ def test_returns_json_string(self, session: Session) -> None: class TestDumpSeismogramParameterTableToJson: """Tests for serialising the seismogram parameter table to JSON.""" - def test_active_event_as_string(self, session: Session) -> None: - """Verifies that a JSON string of the active event's parameters is returned. + def test_default_event_as_string(self, session: Session) -> None: + """Verifies that a JSON string of the default event's parameters is returned. Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_seismogram_parameter_table_to_json( - session, all_events=False, as_string=True, event=active_event + session, all_events=False, as_string=True, event=default_event ) assert isinstance(result, str) parsed = json.loads(result) assert isinstance(parsed, list) assert len(parsed) > 0 - def test_active_event_as_list(self, session: Session) -> None: - """Verifies that a list of dicts of the active event's parameters is returned. + def test_default_event_as_list(self, session: Session) -> None: + """Verifies that a list of dicts of the default event's parameters is returned. Args: session: The database session. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_seismogram_parameter_table_to_json( - session, all_events=False, as_string=False, event=active_event + session, all_events=False, as_string=False, event=default_event ) assert isinstance(result, list) assert len(result) > 0 @@ -385,20 +385,20 @@ def test_all_events_as_list(self, session: Session) -> None: assert len(result) > 0 assert "select" in result[0] - def test_all_events_returns_more_than_active_only(self, session: Session) -> None: - """Verifies that all_events=True returns more rows than active event only. + def test_all_events_returns_more_than_default_only(self, session: Session) -> None: + """Verifies that all_events=True returns more rows than default event only. Args: session: The database session. """ - active_event = get_active_event(session) - active_only = dump_seismogram_parameter_table_to_json( - session, all_events=False, as_string=False, event=active_event + default_event = get_default_event(session) + default_only = dump_seismogram_parameter_table_to_json( + session, all_events=False, as_string=False, event=default_event ) all_events = dump_seismogram_parameter_table_to_json( session, all_events=True, as_string=False ) - assert len(all_events) >= len(active_only) + assert len(all_events) >= len(default_only) class TestPlotAllSeismograms: @@ -410,6 +410,6 @@ def test_returns_figure(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - fig, _ = plot_all_seismograms(session, event=active_event, return_fig=True) + default_event = get_default_event(session) + fig, _ = plot_all_seismograms(session, event=default_event, return_fig=True) assert isinstance(fig, Figure) diff --git a/tests/integration/core/test_snapshots.py b/tests/integration/core/test_snapshots.py index 895e952..9d3685c 100644 --- a/tests/integration/core/test_snapshots.py +++ b/tests/integration/core/test_snapshots.py @@ -13,14 +13,14 @@ rollback_to_snapshot_by_id, dump_snapshot_tables_to_json, ) -from aimbat.core import get_active_event +from aimbat.core import get_default_event from aimbat.models import AimbatSnapshot, AimbatSeismogram from sqlmodel import Session, select @pytest.fixture def session(loaded_session: Session) -> Session: - """Provides a session with multi-event data and an active event pre-loaded. + """Provides a session with multi-event data and an default event pre-loaded. Args: loaded_session: A SQLModel Session with data populated. @@ -33,16 +33,16 @@ def session(loaded_session: Session) -> Session: @pytest.fixture def snapshot(session: Session) -> AimbatSnapshot: - """Provides a snapshot of the active event's current parameters. + """Provides a snapshot of the default event's current parameters. Args: session: The database session. Returns: - An AimbatSnapshot for the active event. + An AimbatSnapshot for the default event. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) return session.exec(select(AimbatSnapshot)).one() @@ -56,20 +56,20 @@ def test_creates_snapshot(self, session: Session) -> None: session: The database session. """ assert len(session.exec(select(AimbatSnapshot)).all()) == 0 - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) assert len(session.exec(select(AimbatSnapshot)).all()) == 1 - def test_snapshot_linked_to_active_event(self, session: Session) -> None: - """Verifies that the snapshot is associated with the active event. + def test_snapshot_linked_to_default_event(self, session: Session) -> None: + """Verifies that the snapshot is associated with the default event. Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() - assert snapshot.event_id == active_event.id + assert snapshot.event_id == default_event.id def test_snapshot_with_comment(self, session: Session) -> None: """Verifies that the optional comment is stored on the snapshot. @@ -77,8 +77,8 @@ def test_snapshot_with_comment(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event, comment="test comment") + default_event = get_default_event(session) + create_snapshot(session, default_event, comment="test comment") snapshot = session.exec(select(AimbatSnapshot)).one() assert snapshot.comment == "test comment" @@ -88,8 +88,8 @@ def test_snapshot_without_comment(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() assert snapshot.comment is None @@ -99,10 +99,10 @@ def test_snapshot_captures_seismogram_parameters(self, session: Session) -> None Args: session: The database session. """ - active_event = get_active_event(session) - n_seismograms = len(active_event.seismograms) + default_event = get_default_event(session) + n_seismograms = len(default_event.seismograms) - create_snapshot(session, active_event) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_parameters_snapshots) == n_seismograms @@ -113,12 +113,12 @@ def test_snapshot_captures_event_parameters( Args: session: The database session. - snapshot: An AimbatSnapshot for the active event. + snapshot: An AimbatSnapshot for the default event. """ - active_event = get_active_event(session) + default_event = get_default_event(session) assert ( snapshot.event_parameters_snapshot.parameters_id - == active_event.parameters.id + == default_event.parameters.id ) @@ -169,18 +169,18 @@ def test_rollback_restores_event_parameters( session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - active_event = get_active_event(session) + default_event = get_default_event(session) original_min_ccnorm = snapshot.event_parameters_snapshot.min_ccnorm # Mutate the parameter after taking the snapshot - active_event.parameters.min_ccnorm = 0.0 - session.add(active_event) + default_event.parameters.min_ccnorm = 0.0 + session.add(default_event) session.commit() - assert active_event.parameters.min_ccnorm == 0.0 + assert default_event.parameters.min_ccnorm == 0.0 rollback_to_snapshot(session, snapshot) - session.refresh(active_event) - assert active_event.parameters.min_ccnorm == original_min_ccnorm + session.refresh(default_event) + assert default_event.parameters.min_ccnorm == original_min_ccnorm def test_rollback_restores_seismogram_parameters( self, session: Session, snapshot: AimbatSnapshot @@ -191,8 +191,8 @@ def test_rollback_restores_seismogram_parameters( session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - active_event = get_active_event(session) - seismogram = active_event.seismograms[0] + default_event = get_default_event(session) + seismogram = default_event.seismograms[0] original_select = snapshot.seismogram_parameters_snapshots[0].select # Mutate the parameter after taking the snapshot @@ -211,16 +211,16 @@ def test_rollback_by_id(self, session: Session, snapshot: AimbatSnapshot) -> Non session: The database session. snapshot: An AimbatSnapshot to roll back to. """ - active_event = get_active_event(session) + default_event = get_default_event(session) original_min_ccnorm = snapshot.event_parameters_snapshot.min_ccnorm - active_event.parameters.min_ccnorm = 0.0 - session.add(active_event) + default_event.parameters.min_ccnorm = 0.0 + session.add(default_event) session.commit() rollback_to_snapshot_by_id(session, snapshot.id) - session.refresh(active_event) - assert active_event.parameters.min_ccnorm == original_min_ccnorm + session.refresh(default_event) + assert default_event.parameters.min_ccnorm == original_min_ccnorm def test_rollback_restores_all_event_parameters( self, session: Session, snapshot: AimbatSnapshot @@ -231,8 +231,8 @@ def test_rollback_restores_all_event_parameters( session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - active_event = get_active_event(session) - params = active_event.parameters + default_event = get_default_event(session) + params = default_event.parameters snap = snapshot.event_parameters_snapshot # Mutate every event parameter to a value distinct from the snapshot @@ -270,8 +270,8 @@ def test_rollback_restores_all_seismogram_parameters( session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - active_event = get_active_event(session) - seismogram = active_event.seismograms[0] + default_event = get_default_event(session) + seismogram = default_event.seismograms[0] params = seismogram.parameters snap = next( s @@ -312,20 +312,20 @@ def test_no_snapshots_initially(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - assert len(get_snapshots(session, event=active_event)) == 0 + default_event = get_default_event(session) + assert len(get_snapshots(session, event=default_event)) == 0 - def test_get_snapshots_for_active_event( + def test_get_snapshots_for_default_event( self, session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that snapshots for the active event are returned. + """Verifies that snapshots for the default event are returned. Args: session: The database session. - snapshot: An AimbatSnapshot for the active event. + snapshot: An AimbatSnapshot for the default event. """ - active_event = get_active_event(session) - snapshots = get_snapshots(session, event=active_event, all_events=False) + default_event = get_default_event(session) + snapshots = get_snapshots(session, event=default_event, all_events=False) assert len(snapshots) == 1 assert snapshots[0].id == snapshot.id @@ -336,7 +336,7 @@ def test_get_snapshots_all_events( Args: session: The database session. - snapshot: An AimbatSnapshot for the active event. + snapshot: An AimbatSnapshot for the default event. """ all_snapshots = get_snapshots(session, all_events=True) assert len(all_snapshots) >= 1 @@ -347,10 +347,10 @@ def test_multiple_snapshots(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event, comment="first") - create_snapshot(session, active_event, comment="second") - assert len(get_snapshots(session, event=active_event)) == 2 + default_event = get_default_event(session) + create_snapshot(session, default_event, comment="first") + create_snapshot(session, default_event, comment="second") + assert len(get_snapshots(session, event=default_event)) == 2 class TestDumpSnapshotTablesToJson: @@ -363,9 +363,9 @@ def test_as_string(self, session: Session, snapshot: AimbatSnapshot) -> None: session: The database session. snapshot: An AimbatSnapshot to include in the dump. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_snapshot_tables_to_json( - session, all_events=False, as_string=True, event=active_event + session, all_events=False, as_string=True, event=default_event ) assert isinstance(result, str) parsed = json.loads(result) @@ -380,9 +380,9 @@ def test_as_dict(self, session: Session, snapshot: AimbatSnapshot) -> None: session: The database session. snapshot: An AimbatSnapshot to include in the dump. """ - active_event = get_active_event(session) + default_event = get_default_event(session) result = dump_snapshot_tables_to_json( - session, all_events=False, as_string=False, event=active_event + session, all_events=False, as_string=False, event=default_event ) assert isinstance(result, dict) assert "snapshots" in result @@ -391,25 +391,25 @@ def test_as_dict(self, session: Session, snapshot: AimbatSnapshot) -> None: def test_all_events_includes_more_snapshots( self, session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that all_events=True returns at least as many snapshots as active only. + """Verifies that all_events=True returns at least as many snapshots as default only. Args: session: The database session. snapshot: An AimbatSnapshot to include in the dump. """ - active_event = get_active_event(session) - active_only = dump_snapshot_tables_to_json( - session, all_events=False, as_string=False, event=active_event + default_event = get_default_event(session) + default_only = dump_snapshot_tables_to_json( + session, all_events=False, as_string=False, event=default_event ) all_events = dump_snapshot_tables_to_json( session, all_events=True, as_string=False ) - assert len(all_events["snapshots"]) >= len(active_only["snapshots"]) + assert len(all_events["snapshots"]) >= len(default_only["snapshots"]) def test_seismogram_parameters_count( self, session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that seismogram_parameters count matches the active event's seismograms. + """Verifies that seismogram_parameters count matches the default event's seismograms. Args: session: The database session. diff --git a/tests/integration/core/test_station.py b/tests/integration/core/test_station.py index 6e10f93..b61f0fb 100644 --- a/tests/integration/core/test_station.py +++ b/tests/integration/core/test_station.py @@ -6,7 +6,7 @@ from sqlalchemy.exc import NoResultFound from sqlmodel import Session, select -from aimbat.core import get_active_event +from aimbat.core import get_default_event from aimbat.core._station import ( delete_station, delete_station_by_id, @@ -20,7 +20,7 @@ @pytest.fixture def session(loaded_session: Session) -> Session: - """Provides a session with multi-event data and an active event pre-loaded. + """Provides a session with multi-event data and an default event pre-loaded. Args: loaded_session: A SQLModel Session with data populated. @@ -33,16 +33,16 @@ def session(loaded_session: Session) -> Session: @pytest.fixture def station(session: Session) -> AimbatStation: - """Provides the first station associated with the active event. + """Provides the first station associated with the default event. Args: session: The database session. Returns: - The first AimbatStation in the active event. + The first AimbatStation in the default event. """ - active_event = get_active_event(session) - return active_event.seismograms[0].station + default_event = get_default_event(session) + return default_event.seismograms[0].station class TestDeleteStation: @@ -86,18 +86,18 @@ def test_delete_station_by_id_not_found(self, session: Session) -> None: delete_station_by_id(session, uuid.uuid4()) -class TestGetStationsInActiveEvent: - """Tests for retrieving stations in the active event.""" +class TestGetStationsInDefaultEvent: + """Tests for retrieving stations in the default event.""" def test_returns_stations(self, session: Session) -> None: - """Verifies that stations for the active event are returned. + """Verifies that stations for the default event are returned. Args: session: The database session. """ - active_event = get_active_event(session) - stations = get_stations_in_event(session, active_event, as_json=False) - assert len(stations) > 0, "Expected at least one station for the active event" + default_event = get_default_event(session) + stations = get_stations_in_event(session, default_event, as_json=False) + assert len(stations) > 0, "Expected at least one station for the default event" def test_returns_aimbat_station_instances(self, session: Session) -> None: """Verifies that all returned items are AimbatStation instances. @@ -105,8 +105,8 @@ def test_returns_aimbat_station_instances(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - stations = get_stations_in_event(session, active_event, as_json=False) + default_event = get_default_event(session) + stations = get_stations_in_event(session, default_event, as_json=False) assert all( isinstance(s, AimbatStation) for s in stations ), "All returned items should be AimbatStation instances" @@ -117,8 +117,8 @@ def test_as_json_returns_list_of_dicts(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - result = get_stations_in_event(session, active_event, as_json=True) + default_event = get_default_event(session) + result = get_stations_in_event(session, default_event, as_json=True) assert isinstance(result, list), "Expected a list when as_json=True" assert all( isinstance(item, dict) for item in result @@ -130,26 +130,26 @@ def test_as_json_count_matches_objects(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - objects = get_stations_in_event(session, active_event, as_json=False) - json_list = get_stations_in_event(session, active_event, as_json=True) + default_event = get_default_event(session) + objects = get_stations_in_event(session, default_event, as_json=False) + json_list = get_stations_in_event(session, default_event, as_json=True) assert len(objects) == len( json_list ), "Object and JSON representations should have the same length" - def test_stations_belong_to_active_event(self, session: Session) -> None: - """Verifies that the returned stations are associated with the active event. + def test_stations_belong_to_default_event(self, session: Session) -> None: + """Verifies that the returned stations are associated with the default event. Args: session: The database session. """ - active_event = get_active_event(session) - active_station_ids = {s.station_id for s in active_event.seismograms} - stations = get_stations_in_event(session, active_event, as_json=False) + default_event = get_default_event(session) + default_station_ids = {s.station_id for s in default_event.seismograms} + stations = get_stations_in_event(session, default_event, as_json=False) returned_ids = {s.id for s in stations} assert ( - returned_ids == active_station_ids - ), "Returned station IDs should match those linked to the active event" + returned_ids == default_station_ids + ), "Returned station IDs should match those linked to the default event" class TestGetStationsInEvent: @@ -161,8 +161,8 @@ def test_returns_stations_for_event(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - stations = get_stations_in_event(session, active_event) + default_event = get_default_event(session) + stations = get_stations_in_event(session, default_event) assert len(stations) > 0, "Expected at least one station for the given event" def test_returns_aimbat_station_instances(self, session: Session) -> None: @@ -171,8 +171,8 @@ def test_returns_aimbat_station_instances(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - stations = get_stations_in_event(session, active_event) + default_event = get_default_event(session) + stations = get_stations_in_event(session, default_event) assert all( isinstance(s, AimbatStation) for s in stations ), "All returned items should be AimbatStation instances" @@ -183,9 +183,9 @@ def test_station_ids_match_event_seismograms(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - expected_ids = {s.station_id for s in active_event.seismograms} - returned_ids = {s.id for s in get_stations_in_event(session, active_event)} + default_event = get_default_event(session) + expected_ids = {s.station_id for s in default_event.seismograms} + returned_ids = {s.id for s in get_stations_in_event(session, default_event)} assert ( returned_ids == expected_ids ), "Station IDs should match those linked to the event's seismograms" diff --git a/tests/integration/models/test_models.py b/tests/integration/models/test_models.py index b6f90eb..26709c2 100644 --- a/tests/integration/models/test_models.py +++ b/tests/integration/models/test_models.py @@ -1,6 +1,6 @@ """Integration tests for AIMBAT SQLModel ORM classes. -Tests cover cascade deletes, the single-active-event constraint, +Tests cover cascade deletes, the single-default-event constraint, type validation, and round-trip persistence of custom time types. """ @@ -65,14 +65,14 @@ def _make_event( session: Session, *, time: str = "2010-02-27T06:34:14", - active: bool | None = None, + is_default: bool | None = None, ) -> AimbatEvent: """Insert and return an event together with its mandatory parameters. Args: session (Session): Database session. time (str): Event time string (default: "2010-02-27T06:34:14"). - active (bool | None): Whether the event is active (default: None). + is_default (bool | None): Whether the event is is_default (default: None). Returns: AimbatEvent: The created event. @@ -82,7 +82,7 @@ def _make_event( latitude=-36.12, longitude=-72.90, depth=22.9, - active=active, + is_default=is_default, ) session.add(ev) session.flush() @@ -218,16 +218,16 @@ def test_delete_event_cascades_to_snapshots(self, session: Session) -> None: Args: session (Session): Database session. """ - ev = _make_event(session, active=True) + ev = _make_event(session, is_default=True) sta = _make_station(session) _make_seismogram(session, ev, sta) session.commit() - # Create a snapshot via the core helper (uses the active event). - from aimbat.core import create_snapshot, get_active_event + # Create a snapshot via the core helper (uses the default event). + from aimbat.core import create_snapshot, get_default_event - active_event = get_active_event(session) - create_snapshot(session, active_event, comment="before delete") + default_event = get_default_event(session) + create_snapshot(session, default_event, comment="before delete") assert len(session.exec(select(AimbatSnapshot)).all()) == 1 assert len(session.exec(select(AimbatEventParametersSnapshot)).all()) == 1 assert len(session.exec(select(AimbatSeismogramParametersSnapshot)).all()) == 1 @@ -291,15 +291,15 @@ def test_delete_snapshot_cascades_to_parameter_snapshots( Args: session (Session): Database session. """ - ev = _make_event(session, active=True) + ev = _make_event(session, is_default=True) sta = _make_station(session) _make_seismogram(session, ev, sta) session.commit() - from aimbat.core import create_snapshot, get_active_event + from aimbat.core import create_snapshot, get_default_event - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() session.delete(snapshot) @@ -310,53 +310,53 @@ def test_delete_snapshot_cascades_to_parameter_snapshots( # =================================================================== -# Single active event constraint +# Single default event constraint # =================================================================== -class TestSingleActiveEvent: - """The DB trigger ensures at most one event has active=True.""" +class TestSingleDefaultEvent: + """The DB trigger ensures at most one event has is_default=True.""" - def test_only_one_active_event_via_insert(self, session: Session) -> None: - """Inserting a new active event deactivates the previous one. + def test_only_one_default_event_via_insert(self, session: Session) -> None: + """Inserting a new default event deactivates the previous one. Args: session (Session): Database session. """ - ev1 = _make_event(session, active=True) + ev1 = _make_event(session, is_default=True) session.commit() session.refresh(ev1) - assert ev1.active is True + assert ev1.is_default is True - ev2 = _make_event(session, time="2011-03-11T05:46:24", active=True) + ev2 = _make_event(session, time="2011-03-11T05:46:24", is_default=True) session.commit() session.refresh(ev1) session.refresh(ev2) - assert ev1.active is None - assert ev2.active is True + assert ev1.is_default is None + assert ev2.is_default is True - def test_only_one_active_event_via_update(self, session: Session) -> None: - """Updating an event to active deactivates the previous one. + def test_only_one_default_event_via_update(self, session: Session) -> None: + """Updating an event to the default event replaces the previous one. Args: session (Session): Database session. """ - ev1 = _make_event(session, active=True) + ev1 = _make_event(session, is_default=True) ev2 = _make_event(session, time="2011-03-11T05:46:24") session.commit() - ev2.active = True + ev2.is_default = True session.add(ev2) session.commit() session.refresh(ev1) session.refresh(ev2) - assert ev1.active is None - assert ev2.active is True + assert ev1.is_default is None + assert ev2.is_default is True - def test_multiple_inactive_events_allowed(self, session: Session) -> None: - """Multiple events may exist without any being active. + def test_multiple_non_default_events_allowed(self, session: Session) -> None: + """Multiple events may exist without any being the default. Args: session (Session): Database session. @@ -366,33 +366,33 @@ def test_multiple_inactive_events_allowed(self, session: Session) -> None: _make_event(session, time="2012-01-01T00:00:00") session.commit() - active = session.exec( - select(AimbatEvent).where(AimbatEvent.active == True) # noqa: E712 + is_default_events = session.exec( + select(AimbatEvent).where(AimbatEvent.is_default == True) # noqa: E712 ).all() - assert len(active) == 0 + assert len(is_default_events) == 0 - def test_cycling_active_through_three_events(self, session: Session) -> None: - """Verifies cycling active status through multiple events ensures only one is active at a time. + def test_cycling_default_through_three_events(self, session: Session) -> None: + """Verifies cycling default status through multiple events ensures only one is the default at a time. Args: session (Session): Database session. """ - ev1 = _make_event(session, time="2010-01-01T00:00:00", active=True) + ev1 = _make_event(session, time="2010-01-01T00:00:00", is_default=True) ev2 = _make_event(session, time="2011-01-01T00:00:00") ev3 = _make_event(session, time="2012-01-01T00:00:00") session.commit() for target in [ev2, ev3, ev1]: - target.active = True + target.is_default = True session.add(target) session.commit() - active = session.exec( - select(AimbatEvent).where(AimbatEvent.active == True) # noqa: E712 + is_default_events = session.exec( + select(AimbatEvent).where(AimbatEvent.is_default == True) # noqa: E712 ).all() - assert len(active) == 1 + assert len(is_default_events) == 1 session.refresh(target) - assert target.active is True + assert target.is_default is True # =================================================================== diff --git a/tests/integration/models/test_operations.py b/tests/integration/models/test_operations.py index 11c2fcf..353e2c6 100644 --- a/tests/integration/models/test_operations.py +++ b/tests/integration/models/test_operations.py @@ -1,7 +1,7 @@ """Integration tests for ORM relationships and cascade deletes in AIMBAT models.""" import pytest -from aimbat.core import get_active_event +from aimbat.core import get_default_event from aimbat.core._snapshot import create_snapshot from aimbat.models import ( AimbatDataSource, @@ -19,7 +19,7 @@ @pytest.fixture def session(loaded_session: Session) -> Session: - """Provides a session with multi-event data and an active event pre-loaded. + """Provides a session with multi-event data and an default event pre-loaded. Args: loaded_session: A SQLModel Session with data populated. @@ -214,8 +214,8 @@ def test_snapshot_has_event_parameters_snapshot(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() assert isinstance( snapshot.event_parameters_snapshot, AimbatEventParametersSnapshot @@ -229,8 +229,8 @@ def test_snapshot_has_seismogram_parameter_snapshots( Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_parameters_snapshots) > 0 assert all( @@ -244,8 +244,8 @@ def test_snapshot_back_reference_to_event(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() assert isinstance(snapshot.event, AimbatEvent) @@ -255,8 +255,8 @@ def test_snapshot_seismogram_count(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) assert snapshot.seismogram_count == len( @@ -269,8 +269,8 @@ def test_snapshot_selected_seismogram_count(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) expected = sum(1 for s in snapshot.seismogram_parameters_snapshots if s.select) @@ -282,8 +282,8 @@ def test_snapshot_flipped_seismogram_count(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) expected = sum(1 for s in snapshot.seismogram_parameters_snapshots if s.flip) @@ -301,8 +301,8 @@ def test_snapshot_counts_reflect_toggled_flip_and_select( Args: session: The database session. """ - active_event = get_active_event(session) - seismograms = active_event.seismograms + default_event = get_default_event(session) + seismograms = default_event.seismograms assert len(seismograms) >= 2 to_flip = seismograms[0] @@ -314,7 +314,7 @@ def test_snapshot_counts_reflect_toggled_flip_and_select( session.add(to_deselect.parameters) session.commit() - create_snapshot(session, active_event) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) @@ -503,8 +503,8 @@ def test_parameter_snapshots_deleted( session: The database session. seismogram: An AimbatSeismogram to delete. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) parameters_id = seismogram.parameters.id session.delete(seismogram) @@ -524,8 +524,8 @@ def test_event_parameters_snapshot_deleted(self, session: Session) -> None: Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() ep_snapshot_id = snapshot.event_parameters_snapshot.id @@ -540,8 +540,8 @@ def test_seismogram_parameters_snapshots_deleted(self, session: Session) -> None Args: session: The database session. """ - active_event = get_active_event(session) - create_snapshot(session, active_event) + default_event = get_default_event(session) + create_snapshot(session, default_event) snapshot = session.exec(select(AimbatSnapshot)).one() sp_snapshot_ids = [s.id for s in snapshot.seismogram_parameters_snapshots] assert len(sp_snapshot_ids) > 0 diff --git a/tests/unit/_cli/test_common.py b/tests/unit/_cli/test_common.py index 4bee444..1136d03 100644 --- a/tests/unit/_cli/test_common.py +++ b/tests/unit/_cli/test_common.py @@ -73,10 +73,10 @@ def test_short_can_be_set_false(self) -> None: class TestCliHints: """Tests for the CliHints frozen dataclass.""" - def test_activate_event_hint_content(self) -> None: - """Verifies that ACTIVATE_EVENT hint references the activate command.""" - assert "activate" in CliHints.ACTIVATE_EVENT - assert "aimbat event activate" in CliHints.ACTIVATE_EVENT + def test_set_default_event_hint_content(self) -> None: + """Verifies that SET_DEFAULT_EVENT hint references the default command.""" + assert "default" in CliHints.SET_DEFAULT_EVENT + assert "aimbat event default" in CliHints.SET_DEFAULT_EVENT def test_list_events_hint_content(self) -> None: """Verifies that LIST_EVENTS hint references the list command.""" @@ -86,11 +86,11 @@ def test_list_events_hint_content(self) -> None: def test_hints_instance_is_frozen(self) -> None: """Verifies that the CliHints dataclass is frozen (immutable).""" with pytest.raises((AttributeError, TypeError)): - HINTS.ACTIVATE_EVENT = "new value" + HINTS.SET_DEFAULT_EVENT = "new value" def test_hints_singleton_values(self) -> None: """Verifies that the HINTS singleton has the expected attribute values.""" - assert HINTS.ACTIVATE_EVENT == CliHints.ACTIVATE_EVENT + assert HINTS.SET_DEFAULT_EVENT == CliHints.SET_DEFAULT_EVENT assert HINTS.LIST_EVENTS == CliHints.LIST_EVENTS diff --git a/uv.lock b/uv.lock index cbdfc46..dcb780f 100644 --- a/uv.lock +++ b/uv.lock @@ -473,7 +473,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.6.0" +version = "4.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -481,9 +481,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/a7/61825c9c46dd9d3d2a231c9792753fc3fe2822a90734a619b1a23ed0f05f/cyclopts-4.7.0.tar.gz", hash = "sha256:1d0fd440b8d21a55d14f830033eb1ac156933424df3e90afeea34cfb3ed73822", size = 163447, upload-time = "2026-03-05T02:57:49.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" }, + { url = "https://files.pythonhosted.org/packages/5b/08/a631a99df0e9f49c73ec682a9d1e05e5887cf79f04076792aacb4caac6b2/cyclopts-4.7.0-py3-none-any.whl", hash = "sha256:c659d930797a8470f2914a8f8f8be263b339cb6ffb6593b4a59fa9d84b8e0e38", size = 201270, upload-time = "2026-03-05T02:57:50.988Z" }, ] [[package]]